Конкурентные UPDATE в SQL Server 2025 обработали другой набор строк: как найти такой код до включения Optimized Locking
Конкурентные UPDATE обрабатывают другой набор строк из-за lock after qualification: при включённых Optimized Locking и RCSI условие WHERE проверяется по последней зафиксированной версии строки без ожидания, и строка, которая станет подходящей только после коммита соседней транзакции, молча пропускается. Ниже три шаблона кода, которые из-за этого ломаются, способ найти их за вечер и точечные исправления.
Почему конкурентный UPDATE при Optimized Locking обрабатывает другие строки
Я много лет сопровождаю базы MS SQL и PostgreSQL в компаниях до 50 рабочих мест, и новые функции движка обычно приходят ко мне в виде вопроса «а можно просто включить?». С Optimized Locking в SQL Server 2025 отвечать «просто» нельзя. Функция честно уменьшает блокировки и память под них, но один из её компонентов меняет семантику конкурентных запросов. Речь о lock after qualification (LAQ): движок проверяет условие WHERE по последней зафиксированной версии строки, не беря блокировку. Если по этой версии строка не подходит, он идёт дальше и не ждёт никого.
Разберём на примере из документации Microsoft. Таблица из одной строки, a = 1 и b = 1. Транзакция T1 делает UPDATE, ставит b = 2 и пока не завершается. Транзакция T2 запускает UPDATE, который ставит b = 3 там, где b = 2. Раньше T2 вставала в очередь за T1, после коммита видела b = 2 и меняла строку на 3. Теперь T2 смотрит на зафиксированную версию, где b ещё равно 1, видит, что строка не подходит, и завершается без ожидания и без ошибки. Итог в таблице: b = 2 вместо b = 3. Документация прямо пишет, что LAQ убирает блокировку, но приводит к другому результату.
CREATE TABLE t4 (a int NOT NULL, b int NULL);
INSERT INTO t4 VALUES (1, 1);
-- сессия 1
BEGIN TRANSACTION T1;
UPDATE t4 SET b = 2 WHERE a = 1;
-- сессия 2
BEGIN TRANSACTION T2;
UPDATE t4 SET b = 3 WHERE b = 2;
-- затем COMMIT в сессии 1 и COMMIT в сессии 2Важная деталь, которую часто упускают: LAQ не лишает UPDATE ожидания вообще. Если строка по зафиксированной версии подходит под условие, а другая транзакция держит на ней блокировку TID, движок ждёт и затем перепроверяет условие уже по новому значению. Пропускаются только строки, которые на момент проверки не подходили. То есть ломается не «ожидание», а конкретный сценарий: условие станет истинным после коммита соседа. Именно поэтому баг плавающий: он зависит от того, успел ли сосед закоммититься до вашей проверки.
Теперь о том, когда всё это вообще включено. В SQL Server 2025 Optimized Locking по умолчанию выключен, его включают на уровне базы, и для этого нужна ускоренная отмена транзакций ADR. Компонент LAQ работает только при включённом READ_COMMITTED_SNAPSHOT. В Azure SQL Database, Fabric и Managed Instance политик 2025 и AUTD функция включена всегда. Практический вывод неприятный: разработчик может тестировать приложение в облачной базе, где LAQ работает с первого дня, а потом получить другое поведение на локальном сервере, или наоборот. Про сам механизм и четыре способа лечения я уже писал в разборе про потерянные задания и LAQ, здесь угол другой: как найти опасный код заранее, а не по жалобам пользователей.
Какой код ломается: три шаблона и один безобидный
За годы я видел три шаблона, которые зависят от прежнего порядка ожиданий. Первый: цепочка статусов. Один процесс переводит сделку в статус 2, другой подхватывает её условием WHERE stage = 2 и переводит в статус 3. Пока первый не закоммитился, второй раньше стоял в очереди, теперь пропускает строку. Второй шаблон: проверка по числу затронутых строк. Код выполняет UPDATE и считает, что нулевой @@ROWCOUNT значит «кто-то уже обработал это за меня». При LAQ ноль получается и тогда, когда никто ничего не обработал. Третий шаблон: условие закрытия, например «закрыть сделку, если сумма оплат достигла суммы договора». Если платёж ещё в незакоммиченной транзакции соседа, закрывающий UPDATE строку не увидит.
Выглядит всё это невинно. Никаких deadlock, таймаутов и ошибок в журнале, мониторинг зелёный, ожиданий на блокировках даже стало меньше, ведь функция работает как задумана. Меня до сих пор веселит формулировка в тикетах: «иногда документы не проводятся, перезапустим службу». Служба ни при чём. Просто код много лет опирался на неписаную гарантию, что второй UPDATE дождётся первого, а гарантии никто не давал. Документация, кстати, сама предупреждает: даже без LAQ при версионных уровнях изоляции приложения не должны считать, что движок гарантирует строгий порядок без подсказок блокировок.
Чтобы не гадать, держите в голове короткую таблицу по условию WHERE. | Условие в UPDATE | Что произойдёт при LAQ | Риск | |---|---|---| | Ключ, который никто не меняет (WHERE deal_id = @id) | Соседи на другие строки не блокируют друг друга | Нет | | Значение подходит по зафиксированной версии | Ждёт и перепроверяет условие по новой версии | Низкий | | Значение станет подходящим после коммита соседа | Строка пропускается без ожидания | Высокий | | Результат читается через @@ROWCOUNT как «уже сделано» | Ноль трактуется неверно | Высокий | Обратите внимание на третью строку. Именно её хочется найти в коде первой.
Есть и ограничения, при которых LAQ вообще не применяется, и это тоже стоит знать. По документации он не используется при конфликтующих подсказках UPDLOCK, READCOMMITTEDLOCK, XLOCK и HOLDLOCK, при любом уровне изоляции, отличном от READ COMMITTED (то есть и при SNAPSHOT, и при READ UNCOMMITTED тоже), при выключенном READ_COMMITTED_SNAPSHOT, при columnstore-индексе в изменяемой таблице, при присвоении переменной в DML, при OUTPUT в табличную переменную или в результирующий набор, при нескольких операторах поиска или сканирования для чтения изменяемых строк и в MERGE. Если ваш опасный UPDATE подпадает под этот список, он безопасен, но лучше знать это из плана, а не надеяться.
Если после этого раздела вы поняли, что в вашем приложении такой код, скорее всего, есть, но искать его некому, скажу прямо: это ровно та работа, которую мы делаем руками. Присылайте структуру базы и список процедур, посмотрим и скажем, что именно придётся править до включения функции.
Как найти зависимость от ожидания блокировки до включения функции
Начинаю всегда с проверки состояния базы. Нужны три флага: ADR, RCSI и сам Optimized Locking. Запрос из документации Microsoft возвращает их одной строкой. Если RCSI выключен, LAQ в этой базе не работает вообще, и половина разговора закрыта. Для баз 1С под MS SQL это отдельный вопрос, и менять уровень изоляции без согласования с вендором конфигурации я не рекомендую.
SELECT database_id,
name,
is_accelerated_database_recovery_on,
is_read_committed_snapshot_on,
is_optimized_locking_on
FROM sys.databases
WHERE name = DB_NAME();Второй шаг: собрать кандидатов. Пока код приложения не просмотрен, быстрее всего вытащить из кэша планов все UPDATE и отсортировать по частоте. Если у вас включено Хранилище запросов, оно покажет то же самое надёжнее, об этом у меня есть разбор про Query Store и планы запросов. Из выборки я вручную отбираю statements с условием по статусу, флагу или суммарному полю и отдельным списком пишу места, где разработчик проверяет @@ROWCOUNT. Это скучная работа на два-три часа, зато без неё остальное бессмысленно.
SELECT TOP (50) qs.execution_count, st.text
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
WHERE st.text LIKE N'%UPDATE%'
ORDER BY qs.execution_count DESC;Третий шаг: двухсессионный тест на копии боевой базы. Берёте найденный UPDATE, в первой сессии открываете транзакцию, которая меняет строку так, как это делает соседний процесс, и держите её открытой через WAITFOR DELAY. Во второй сессии запускаете проверяемый оператор и смотрите @@ROWCOUNT. Без LAQ вторая сессия простоит десять секунд и вернёт 1. С LAQ вернёт 0 сразу. Разница в ответе и в времени и есть ваш диагноз. Прогоните тест дважды: при выключенном и включённом OPTIMIZED_LOCKING, в одной и той же базе.
-- исходно у сделки deal_id = 1 стадия stage = 1
-- сессия 1: имитируем соседний процесс
BEGIN TRANSACTION;
UPDATE dbo.deal SET stage = 2 WHERE deal_id = 1;
WAITFOR DELAY '00:00:10';
COMMIT TRANSACTION;
-- сессия 2: проверяемый оператор
UPDATE dbo.deal SET stage = 3 WHERE deal_id = 1 AND stage = 2;
SELECT @@ROWCOUNT AS rows_changed;Четвёртый шаг: нагрузка вместо единичного прогона. Единичный тест докажет механизм, но не масштаб проблемы. Я пишу простой скрипт, где два рабочих потока гоняют по очереди тысячи реальных сделок, и сверяю итоговые статусы с ожидаемыми. Имейте в виду, что результат нестабилен. У LAQ есть эвристика: если перезапусков операторов слишком много, функция отключается на уровне плана или базы, а данные обратной связи сбрасываются при перезапуске сервера. Поэтому тест на свежезапущенном экземпляре и тест после недели работы могут отличаться. Для наблюдения за этим пригодится расширенное событие lock_after_qual_stmt_abort: оно срабатывает, когда оператор внутри перезапущен без LAQ, а событие locking_stats раз в несколько минут отдаёт агрегаты по каждой базе, в том числе включены ли TID и LAQ, и работает даже при выключенной функции.
Чем чинить: READCOMMITTEDLOCK, строгая изоляция или переписанное условие
Самое простое и самое дешёвое исправление для конкретного оператора: подсказка READCOMMITTEDLOCK на таблицу, которую он меняет. По документации она требует читать данные по правилам READ COMMITTED на блокировках независимо от значения READ_COMMITTED_SNAPSHOT, а Microsoft прямо называет её способом заставить два запроса блокировать друг друга при включённом Optimized Locking. Для таблицы с этой подсказкой LAQ перестаёт применяться в этом операторе (подсказка на одной таблице не отключает оптимизацию для других таблиц запроса), и прежнее ожидание возвращается. Остальные операторы по-прежнему получают выгоду от функции.
UPDATE d
SET d.stage = 3
FROM dbo.deal AS d WITH (READCOMMITTEDLOCK)
WHERE d.deal_id = @id
AND d.stage = 2;Подсказка не волшебная. Она возвращает ожидание, но не гарантирует порядок бизнес-операций: если ваш процесс опирался на то, что событие «задаток принят» всегда обрабатывается раньше «готовим договор», то эту последовательность лучше выразить в данных, а не в особенностях блокировок. Microsoft для приложений, которым нужен строгий порядок под RCSI, рекомендует более строгие уровни изоляции, REPEATABLE READ или SERIALIZABLE. Ценой становится меньше параллелизма, и тут я выбираю по месту: для маленькой таблицы очереди это нормально, для главной таблицы сделок на 21 пользователя обычно перебор.
Сравнение вариантов, как я их раскладываю заказчикам. | Вариант | Область действия | Цена | |---|---|---| | READCOMMITTEDLOCK на таблицу в операторе | Один оператор | Возвращается прежнее ожидание в этом месте | | REPEATABLE READ или SERIALIZABLE | Транзакция или оператор | Меньше параллелизма, больше блокировок | | UPDLOCK в операторе | Один оператор | Блокировки держатся до конца транзакции, подсказки READCOMMITTED и READCOMMITTEDLOCK игнорируются | | Переписать логику: не считать ноль строк признаком «сделано» | Код приложения | Самый надёжный путь, нужен разработчик | | OPTIMIZED_LOCKING = OFF | Вся база | Теряем всю выгоду функции |
Отдельно скажу про лазейки. UPDATE с OUTPUT, возвращающим результирующий набор, по документации тоже не использует LAQ, и соблазн опереться на это очевиден. Не стройте на этом архитектуру: это побочный эффект, а не контракт, и в следующей сборке перечень ограничений может измениться. Если подсказки нужны почти в каждом втором операторе, честнее выключить OPTIMIZED_LOCKING и не носить на себе издержки ADR ради функции, которой вы не пользуетесь. Подсказка нужна там, где без неё ломается логика, а не на всякий случай по всей базе через шаблон ORM.
Кейс: риэлторская компания «Новый адрес», 21 рабочее место
К нам пришла риэлторская компания «Новый адрес»: 21 рабочее место, собственная CRM для сделок на SQL Server и отдельная бухгалтерия. Переезд был с SQL Server 2019 на 2025 Standard, виртуальная машина с 6 vCPU и 24 ГБ памяти, база CRM около 14 ГБ. Причина переезда простая: каждое утро при загрузке выгрузок с порталов объявлений CRM замирала примерно на четыре минуты из-за эскалации блокировок. Подрядчик по CRM предложил «включить Optimized Locking, всё само пройдёт». Включать мы не стали, сначала сделали аудит.
В базе нашлось 58 различных операторов UPDATE. Руками и по кэшу планов мы отобрали те, у кого в условии стоял статус или флаг, который меняет другой процесс. Таких оказалось три. Самый показательный: перевод сделки из стадии «задаток принят» в стадию «подготовка договора». Приложение отправляло событие о принятии задатка во внешнюю очередь сообщений ещё до коммита транзакции с записью платежа и сменой стадии, и фоновый обработчик успевал подхватить его раньше коммита. Он выполнял UPDATE ... WHERE stage = 2 и по нулевому @@ROWCOUNT решал, что договор уже готовит кто-то другой. Раньше он честно ждал, теперь пропускал.
На копии базы мы прогнали нагрузочный скрипт: 2000 сделок, два рабочих потока, транзакция принятия задатка около 300 миллисекунд. При включённых ADR, RCSI и Optimized Locking 61 сделка из 2000 застряла в стадии «задаток принят», то есть три процента. С выключенной функцией застрявших не было. Мы поставили READCOMMITTEDLOCK на три найденных оператора, застрявших стало 0, а общее время прогона выросло примерно на четыре процента. Переписывать код CRM подрядчик согласился позже, как отдельную задачу.
Результат по основной цели получился хорошим: утренняя загрузка перестала замораживать CRM, эскалации блокировок на таблице объявлений ушли. Аудит, тест и подготовка заняли три рабочих дня, включили функцию в субботнее окно, а в понедельник сверили статусы сделок с ожидаемыми. Цифры тут мои, из проекта, а не из документации, и у вас они будут другими, потому что зависят от плана запроса и профиля нагрузки. Но порядок действий переносится без изменений.
Как включать Optimized Locking и что наблюдать после
Порядок включения диктуют зависимости. Сначала ADR, затем при необходимости RCSI, затем сам OPTIMIZED_LOCKING. Для смены каждого из этих параметров нужно, чтобы в базе не было других активных подключений, поэтому у меня это всегда окно, а не обеденный перерыв. Условие WITH ROLLBACK IMMEDIATE отрывает чужие сессии и откатывает их незавершённые транзакции, применяйте его осознанно. И отдельно: RCSI меняет поведение читателей по всей базе, транзакции READ COMMITTED начинают использовать версии строк вместо блокировок, поэтому для чужого приложения включайте его только после согласования с разработчиком.
ALTER DATABASE [crm_deals] SET ACCELERATED_DATABASE_RECOVERY = ON WITH ROLLBACK IMMEDIATE;
ALTER DATABASE [crm_deals] SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;
ALTER DATABASE [crm_deals] SET OPTIMIZED_LOCKING = ON WITH ROLLBACK IMMEDIATE;
SELECT DATABASEPROPERTYEX(DB_NAME(), 'IsOptimizedLockingOn');Откат делается в обратном порядке: сначала OPTIMIZED_LOCKING = OFF, и только потом можно отключать ADR, документация прямо требует этого. После выключения ADR в хранилище версий ещё могут оставаться данные, нужные для логического отката, пока не завершатся активные транзакции. Это не причина бояться, но хороший повод не менять параметры в пятницу в семь вечера.
После включения меняются привычные показатели, и старые правила мониторинга могут замолчать. Ожидания на блокировке TID появляются под именами LCK_M_S_XACT_READ и LCK_M_S_XACT_MODIFY, а LCK_M_S_XACT означает ожидание, у которого намерение определить нельзя. Если в мониторинге вы смотрите только на LCK_M_U и LCK_M_X, график станет подозрительно ровным. В карточку наблюдения я добавляю эти типы ожиданий, как это делается в мониторинге MS SQL через Zabbix, и размер хранилища версий PVS, потому что ADR не бесплатен по диску.
Если у вас учётная система на 1С, помните, что блокировки 1С и блокировки СУБД живут на разных уровнях, и разбираться с ними нужно по порядку, как в моём разборе про блокировки и deadlock в 1С. Код платформы вы менять не можете, значит, перед включением функции нужно проверить регламентные операции, закрытие месяца и пакетное проведение на копии. Если вендор не подтверждает поддержку, оставьте функцию выключенной. Скучно, зато предсказуемо.
Всё это мы делаем за вас. Проверяем, какие базы и режимы изоляции у вас реально включены, находим операторы UPDATE, которые зависят от ожидания, прогоняем двухсессионные тесты и нагрузку на копии, ставим точечные подсказки или готовим список правок для разработчика, затем включаем функцию в согласованное окно и настраиваем наблюдение за ожиданиями. Обычно это от трёх до пяти рабочих дней, на выходе у вас отчёт с найденными операторами, включённая и проверенная функция и понятный откат. Если сервер нужно ещё и вести дальше, обслуживание сервера у нас от 7 000 ₽/мес, а стоимость аудита приложения назову после знакомства с базой.
Частые вопросы
Почему при Optimized Locking конкурентный UPDATE пропускает строку?
Из-за lock after qualification. Условие WHERE проверяется по последней зафиксированной версии строки без блокировки. Если там строка не подходит, она пропускается, даже когда незавершённая транзакция уже изменила её так, что условие стало бы истинным.
Включён ли Optimized Locking в SQL Server 2025 по умолчанию?
Нет. В SQL Server 2025 он выключен по умолчанию и включается на уровне базы командой ALTER DATABASE ... SET OPTIMIZED_LOCKING = ON при включённом ADR. В Azure SQL Database, Fabric SQL database и Managed Instance политик 2025 и AUTD он включён всегда.
Когда LAQ не применяется и ожидание остаётся прежним?
При подсказках UPDLOCK, READCOMMITTEDLOCK, XLOCK, HOLDLOCK, при уровне изоляции, отличном от READ COMMITTED, при выключенном READ_COMMITTED_SNAPSHOT, в MERGE, при присвоении переменной и при OUTPUT в табличную переменную или результирующий набор. Полный список приведён в документации Microsoft.
Как быстро вернуть блокирующее поведение для одного оператора?
Добавьте подсказку READCOMMITTEDLOCK на изменяемую таблицу: FROM dbo.deal AS d WITH (READCOMMITTEDLOCK). Она работает независимо от настройки READ_COMMITTED_SNAPSHOT и применима только к этому оператору.
Как выключить Optimized Locking, если что-то пошло не так?
Выполните ALTER DATABASE ... SET OPTIMIZED_LOCKING = OFF. Для смены состояния в базе не должно быть других активных подключений. Если нужно отключать ADR, делайте это только после выключения Optimized Locking.
Источники
- Microsoft Learn: Optimized locking — Состав функции (TID locking и LAQ), доступность и включение по умолчанию на разных платформах, требования ADR и RCSI, пример с разными итоговыми данными, ограничения LAQ, подсказка READCOMMITTEDLOCK, расширенные события и типы ожиданий. https://learn.microsoft.com/en-us/sql/relational-databases/performance/optimized-locking?view=sql-server-ver17
- Microsoft Learn: ALTER DATABASE SET options — Синтаксис OPTIMIZED_LOCKING = { ON | OFF }, значение по умолчанию OFF, требование об отсутствии других активных подключений, ограничения для READ_COMMITTED_SNAPSHOT. https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-database-transact-sql-set-options?view=sql-server-ver17
- Microsoft Learn: Table hints (Transact-SQL) — Описание READCOMMITTEDLOCK, UPDLOCK (игнорирует READCOMMITTED и READCOMMITTEDLOCK), HOLDLOCK, READPAST, синтаксис WITH (...). https://learn.microsoft.com/en-us/sql/t-sql/queries/hints-transact-sql-table?view=sql-server-ver17
- Microsoft Learn: Manage accelerated database recovery — Команда включения и отключения ADR, необходимость эксклюзивного доступа, предложение WITH ROLLBACK IMMEDIATE, остаточные версии в PVS после отключения. https://learn.microsoft.com/en-us/sql/relational-databases/accelerated-database-recovery-management?view=sql-server-ver17
- Microsoft Learn: sys.dm_os_wait_stats — Типы ожиданий LCK_M_S_XACT, LCK_M_S_XACT_READ, LCK_M_S_XACT_MODIFY при включённом Optimized Locking, а также LCK_M_U и LCK_M_X. https://learn.microsoft.com/en-us/sql/relational-databases/system-dynamic-management-objects/sys-dm-os-wait-stats-transact-sql?view=sql-server-ver17



