Транзакции и блокировки
Как база сохраняет данные целыми при параллельной работе: что гарантирует транзакция, чем отличаются уровни изоляции, как устроены блокировки и версии строк в SQL Server и PostgreSQL и как писать код, который переживает дедлоки и конфликты. Для нагруженных OLTP-систем это одна из главных тем собеседования.
Перед этой темой: SQL и T-SQL, Индексы и планы запросов.
Что такое транзакция и что означает ACID?
Заголовок раздела «Что такое транзакция и что означает ACID?»Что это
Транзакция — последовательность операций, которую база выполняет как одну логическую единицу работы. Классический пример — перевод: списать 100 со счёта A и зачислить на счёт B.
| Свойство | Что гарантирует |
|---|---|
| Atomicity, атомарность | выполняется всё или ничего |
| Consistency, согласованность | после транзакции соблюдены ключи, CHECK, внешние ключи |
| Isolation, изоляция | параллельные транзакции не видят промежуточных состояний друг друга |
| Durability, долговечность | зафиксированное переживёт сбой сервера |
Как пользоваться
BEGIN TRAN открывает транзакцию, COMMIT фиксирует изменения, ROLLBACK отменяет их. Без BEGIN TRAN каждый оператор — отдельная транзакция.
Частая ошибка
Считать, что любая ошибка откатывает транзакцию целиком. В SQL Server нарушение CHECK по умолчанию отменяет только один оператор, и следующий COMMIT зафиксирует остальное.
Как устроено
- Режимы. Автокоммит — по умолчанию в SQL Server и SqlClient. Явная транзакция —
BEGIN TRAN…COMMIT. Неявная —SET IMPLICIT_TRANSACTIONS ON: транзакция открывается сама, а закрывать её нужно вручную. - Вложенность. Настоящих вложенных транзакций нет. Внутренний
BEGIN TRANувеличивает@@TRANCOUNT, внутреннийCOMMITтолько уменьшает его, аROLLBACKоткатывает всё до внешнегоBEGIN TRAN. Частичный откат — к точке сохраненияSAVE TRAN. - Ошибки. Без
XACT_ABORTнарушение ключа илиCHECKпрерывает только оператор, транзакция остаётся открытой. СXACT_ABORT ONлюбая ошибка выполнения откатывает всю транзакцию. В триггерах он включён по умолчанию.
Ловушки
- Таймаут команды. Клиент прерывает батч, но транзакция остаётся открытой и держит блокировки, пока соединение не закроют или не возьмут из пула заново.
XACT_ABORT ONзакрывает и этот случай. ROLLBACKв процедуре. Внутри чужой транзакции он обнуляет@@TRANCOUNT, и вызывающий получает ошибку 266 о несовпадении счётчика.
Правило
Процедура с транзакцией начинается с SET XACT_ABORT ON, тело — в BEGIN TRY, в CATCH — IF @@TRANCOUNT > 0 ROLLBACK и THROW.
Под капотом
- Write-ahead logging. Изменение сначала попадает в журнал, потом в страницу данных в памяти. При
COMMITсервер ждёт записи лога на диск (ожиданиеWRITELOG), а грязные страницы пишут позже checkpoint и lazy writer. - Восстановление. После сбоя сервер читает журнал: повторяет зафиксированное (redo) и откатывает незафиксированное (undo). Так обеспечиваются A и D.
- ADR. С SQL Server 2019, выключен по умолчанию. Версионирует изменения в persistent version store, поэтому откат и восстановление почти мгновенные даже после длинной транзакции, а журнал усекается, несмотря на неё.
Цена
- Каждый коммит — синхронная запись в лог. Миллион одиночных
INSERTв автокоммите — миллион сбросов журнала; пачки по несколько тысяч строк в одной транзакции заметно быстрее. - Длинная транзакция держит блокировки, не даёт усечь журнал (
log_reuse_wait_desc = ACTIVE_TRANSACTION) и без ADR долго откатывается.
Компромиссы
- Delayed durability (SQL Server 2014+) подтверждает коммит до записи лога на диск: меньше задержка, но при сбое теряются последние транзакции. В PostgreSQL похожий режим —
synchronous_commit = off. - Буква I в ACID — шкала, а не абсолют: уровень изоляции по умолчанию допускает часть аномалий ради параллельности.
Как проверить в проде
DBCC OPENTRANиsys.dm_tran_database_transactionsпоказывают самую старую открытую транзакцию.- Высокая доля
WRITELOGвsys.dm_os_wait_stats— признак слишком частых коммитов или медленного диска под журналом.
Итог: долговечность покупается синхронной записью журнала при коммите, поэтому размер транзакции — компромисс между числом сбросов лога и временем удержания блокировок.
CREATE TABLE dbo.Accounts (Id int PRIMARY KEY, Balance money CHECK (Balance >= 0));INSERT dbo.Accounts VALUES (1, 50), (2, 0);
-- Ловушка: без XACT_ABORT ошибка отменяет только свой операторBEGIN TRAN;UPDATE dbo.Accounts SET Balance = Balance + 100 WHERE Id = 2; -- выполнитсяUPDATE dbo.Accounts SET Balance = Balance - 100 WHERE Id = 1; -- Msg 547: нарушение CHECKCOMMIT; -- зафиксирует половину перевода: на счёте 2 уже 100
-- Правильно: любая ошибка откатывает всю транзакциюSET XACT_ABORT ON;BEGIN TRY BEGIN TRAN; UPDATE dbo.Accounts SET Balance = Balance - 100 WHERE Id = 1; UPDATE dbo.Accounts SET Balance = Balance + 100 WHERE Id = 2; COMMIT;END TRYBEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK; -- перевод откатан целиком THROW; -- вернуть ошибку клиентуEND CATCH;- Что будет, если в транзакции упадёт второй
UPDATEиз трёх? — БезXACT_ABORTотменится только он; транзакция открыта, иCOMMITзафиксирует остальное. - Есть ли в SQL Server вложенные транзакции? — Нет: внутренний
COMMITуменьшает@@TRANCOUNT,ROLLBACKоткатывает всё; частичный откат — черезSAVE TRAN. - Почему коммит не пишет страницы данных на диск? — Достаточно журнала: по нему страницы восстановятся после сбоя.
- Почему массовая вставка в автокоммите медленная? — Каждый оператор — отдельный коммит и отдельный синхронный сброс журнала.
- Что такое delayed durability? — Коммит без ожидания записи лога: быстрее, но последние транзакции могут потеряться при сбое.
Какие бывают уровни изоляции и от каких аномалий они защищают?
Заголовок раздела «Какие бывают уровни изоляции и от каких аномалий они защищают?»Что это
Уровень изоляции — настройка транзакции, которая определяет, насколько ей мешают параллельные транзакции. Задаётся командой SET TRANSACTION ISOLATION LEVEL.
Аномалии
- Грязное чтение — видны изменения, которые другая транзакция ещё не зафиксировала и может откатить.
- Неповторяемое чтение — повторное чтение строки даёт другое значение: её изменили.
- Фантом — повторный запрос с тем же условием находит новые строки: их вставили.
- Потерянное обновление — две транзакции прочитали значение, изменили и записали; запись первой пропала.
Как пользоваться
В SQL Server по умолчанию READ COMMITTED: грязного чтения нет, остальные аномалии возможны.
Частая ошибка
Ставить WITH (NOLOCK) «для скорости». Это READ UNCOMMITTED: запрос читает данные, которые потом могут откатить.
Как устроено
В SQL Server без версий строк уровни различаются блокировками чтения (S). Блокировки записи (X) на любом уровне живут до конца транзакции.
- READ UNCOMMITTED — чтение без S-блокировок.
- READ COMMITTED — S-блокировка снимается сразу после чтения строки.
- REPEATABLE READ — S-блокировки прочитанных строк держатся до конца транзакции.
- SERIALIZABLE — плюс диапазонные блокировки ключей: в прочитанный диапазон нельзя вставить строку.
Когда что выбирать
| Уровень | Защищает от | Остаётся |
|---|---|---|
| READ UNCOMMITTED | — | всё |
| READ COMMITTED | грязного чтения | неповторяемое чтение, фантомы, потерянное обновление |
| REPEATABLE READ | + неповторяемого чтения | фантомы; потерянное обновление становится дедлоком |
| SERIALIZABLE | + фантомов | ничего, но больше всего блокировок |
Ловушки
- Lost update на READ COMMITTED.
SELECT Qty, расчёт в приложении иUPDATE SET Qty = @newтеряют параллельные изменения. АтомарныйUPDATE ... SET Qty = Qty - @nбезопасен. - Дедлок конверсии. На REPEATABLE READ обе транзакции держат S на строке и ждут X друг друга.
- Версии строк. Кроме четырёх уровней стандарта, в SQL Server есть RCSI и SNAPSHOT — о них следующий вопрос.
Правило
Оставляйте READ COMMITTED, а защиту от конкретной аномалии добавляйте точечно: атомарным оператором, хинтом на одну таблицу или проверкой версии строки.
Под капотом
- Стандарт SQL определяет уровни через запрещённые аномалии, а не через механизм. SQL Server по умолчанию реализует их блокировками, PostgreSQL — снимками данных, поэтому одинаковые названия ведут себя по-разному: REPEATABLE READ в PostgreSQL не допускает и фантомов.
- Диапазонная блокировка ставится на ключи индекса. Без подходящего индекса SERIALIZABLE блокирует весь просканированный диапазон, по сути таблицу.
- Блокирующий READ COMMITTED не гарантирует согласованности даже одного оператора: если при сканировании у строки меняется ключ индекса, её можно прочитать дважды или пропустить.
Цена
- Чем дольше живут S-блокировки, тем больше ожиданий
LCK_M_*и дедлоков конверсии. - SERIALIZABLE платит диапазонными блокировками и откатами жертв дедлока.
Компромиссы
- Уровень поднимают для одной таблицы хинтом:
WITH (HOLDLOCK)— это SERIALIZABLE только для неё. SET TRANSACTION ISOLATION LEVELвнутри процедуры действует до её выхода, потом возвращается уровень вызывающего.- Уровень сессии задаёт и клиент:
TransactionScopeв .NET по умолчанию открывает SERIALIZABLE.
Как проверить в проде
sys.dm_exec_sessions.transaction_isolation_levelпоказывает уровень каждой сессии: 2 — READ COMMITTED, 4 — SERIALIZABLE.- В графе дедлока у процесса есть атрибут
isolationlevel. Неожиданныйserializable (4)обычно ведёт кTransactionScopeс настройками по умолчанию.
Итог: название уровня — контракт про аномалии, а его цена зависит от реализации; в SQL Server это время жизни блокировок чтения.
-- База без RCSI. Сессия 1SET TRANSACTION ISOLATION LEVEL READ COMMITTED;BEGIN TRAN;SELECT Qty FROM dbo.Stock WHERE ProductId = 1; -- 10
-- Сессия 2 (автокоммит)UPDATE dbo.Stock SET Qty = 5 WHERE ProductId = 1; -- не ждёт: S-блокировку уже сняли
-- Сессия 1SELECT Qty FROM dbo.Stock WHERE ProductId = 1; -- 5: неповторяемое чтениеCOMMIT;
-- С REPEATABLE READ сессия 2 ждала бы COMMIT сессии 1,-- и оба SELECT вернули бы 10- Какой уровень по умолчанию в SQL Server и в Azure SQL Database? — READ COMMITTED; в Azure SQL Database он по умолчанию работает на версиях строк (RCSI).
- Защищает ли REPEATABLE READ от фантомов? — В SQL Server нет, нужен SERIALIZABLE; в PostgreSQL — да.
- Как избежать lost update, не поднимая уровень? — Атомарный
UPDATE ... SET x = x - @n,UPDLOCKпри чтении или проверкаrowversion. - Ускоряет ли
NOLOCKзапись? — Нет: блокировки записи держатся до конца транзакции на любом уровне. - Можно ли поднять уровень для одной таблицы? — Да, хинтами
REPEATABLEREADилиSERIALIZABLE(HOLDLOCK).
Что такое RCSI и SNAPSHOT в SQL Server и как они соотносятся с MVCC в PostgreSQL?
Заголовок раздела «Что такое RCSI и SNAPSHOT в SQL Server и как они соотносятся с MVCC в PostgreSQL?»Что это
Версионирование строк: при изменении строки SQL Server сохраняет её прежнюю зафиксированную версию. Читатель берёт эту версию, а не ждёт снятия блокировки.
| Режим | Что видит чтение |
|---|---|
RCSI, опция READ_COMMITTED_SNAPSHOT |
данные, зафиксированные к началу оператора |
SNAPSHOT, опция ALLOW_SNAPSHOT_ISOLATION |
данные, зафиксированные к началу транзакции |
Зачем
Без версий SELECT на READ COMMITTED ждёт, пока пишущая транзакция завершится. Отчёт по продажам не должен стоять в очереди за оформлением заказов.
Как пользоваться
- RCSI включают для всей базы, код менять не нужно.
- SNAPSHOT разрешают для базы, а транзакция запрашивает его явно:
SET TRANSACTION ISOLATION LEVEL SNAPSHOT.
Частая ошибка
Думать, что версии убирают все блокировки. Два UPDATE одной строки по-прежнему ждут друг друга.
Как устроено
- При включённой опции каждое изменение копирует прежнюю версию строки в хранилище версий и помечает её номером транзакции.
- RCSI берёт снимок на начало оператора. SNAPSHOT — на первое обращение к данным после
BEGIN TRAN, а не на самBEGIN TRAN. - Читатели ставят только
Sch-Sна таблицу, без блокировок строк и страниц. UPDATEна RCSI ищет строки с U-блокировками, как обычный READ COMMITTED, и конфликтов не даёт. На SNAPSHOT он проверяет, не изменил ли строку кто-то после начала снимка, и при конфликте падает с ошибкой 3960.
Когда что выбирать
| Признак | RCSI | SNAPSHOT |
|---|---|---|
| Снимок | на оператор | на транзакцию |
| Конфликт записи | нет, писатель ждёт | ошибка 3960, нужен повтор |
| Изменение кода | не нужно | SET TRANSACTION ISOLATION LEVEL SNAPSHOT |
Ловушки
- Код полагался на блокирующее чтение. «Прочитать остаток и решить» раньше ждало незавершённую запись, а теперь видит старую версию. Такие места защищают хинтом
READCOMMITTEDLOCKилиUPDLOCK. - Включение RCSI требует, чтобы в базе не было других соединений: обычно
WITH ROLLBACK IMMEDIATEв окно обслуживания. - DDL под SNAPSHOT. Метаданные не версионируются: изменение схемы другой сессией даёт ошибку 3961.
Правило
RCSI — разумное умолчание для OLTP. SNAPSHOT — для длинных согласованных чтений вроде отчётов или там, где готовы повторять транзакцию при 3960.
Под капотом
- Версионирование добавляет до 14 байт к строке при изменении, отсюда разбиения страниц и рост таблицы.
- Без ADR версии лежат в tempdb, с ADR (SQL Server 2019+) — в persistent version store (PVS) самой базы.
- Версии живут, пока нужны самой старой активной транзакции. Одна забытая открытая транзакция раздувает tempdb или PVS.
SQL Server и PostgreSQL
| Задача | SQL Server | PostgreSQL |
|---|---|---|
| Снимок на оператор | RCSI, опция базы | READ COMMITTED, по умолчанию |
| Снимок на транзакцию | SNAPSHOT, конфликт — 3960 | REPEATABLE READ, конфликт — 40001 |
| Полная сериализуемость | SERIALIZABLE на блокировках | SERIALIZABLE (SSI), ошибка 40001 |
| Старые версии | tempdb или PVS | в самой таблице, чистит VACUUM |
Компромиссы
- Write skew. Снимок не ловит конфликт, когда транзакции по одному условию меняют разные строки. Два дежурных врача проверяют «на смене не меньше двух» и оба уходят — на смене никого. В SQL Server нужен SERIALIZABLE или
UPDLOCKна прочитанные строки, в PostgreSQL — SERIALIZABLE. - Чтение по длинной цепочке версий медленнее чтения текущей строки.
Как проверить в проде
sys.dm_tran_version_store_space_usage— объём версий в tempdb по базам,sys.dm_tran_persistent_version_store_stats— PVS при ADR.sys.dm_tran_active_snapshot_database_transactions— самые старые транзакции, которые удерживают версии.
Итог: версии меняют блокировки чтения на место и обнаружение конфликтов, а главный враг обеих схем — длинные транзакции, которые не дают чистить старые версии.
ALTER DATABASE Shop SET ALLOW_SNAPSHOT_ISOLATION ON;-- RCSI: ALTER DATABASE Shop SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;
-- Сессия 1SET TRANSACTION ISOLATION LEVEL SNAPSHOT;BEGIN TRAN;SELECT Qty FROM dbo.Stock WHERE ProductId = 1; -- 10: снимок взят здесь
-- Сессия 2 (автокоммит)UPDATE dbo.Stock SET Qty = 7 WHERE ProductId = 1; -- не ждёт сессию 1
-- Сессия 1SELECT Qty FROM dbo.Stock WHERE ProductId = 1; -- 10: версия из снимкаUPDATE dbo.Stock SET Qty = Qty - 1 WHERE ProductId = 1;-- Msg 3960: Snapshot isolation transaction aborted due to update conflict...-- Транзакция откатана, её нужно повторить целиком- Блокируют ли друг друга писатели при RCSI? — Да,
UPDATEберёт U- и X-блокировки как обычно; версии убирают только конфликт чтения и записи. - Когда начинается снимок SNAPSHOT-транзакции? — При первом обращении к данным после
BEGIN TRAN, а не в моментBEGIN TRAN. - Что такое write skew и ловит ли его SNAPSHOT? — Две транзакции по одному прочитанному условию меняют разные строки; SNAPSHOT это не ловит.
- Где хранятся версии при включённом ADR? — В persistent version store внутри самой базы, а не в tempdb.
- Чем REPEATABLE READ в PostgreSQL похож на SNAPSHOT? — Снимок на транзакцию и ошибка сериализации 40001 при конфликте записи.
Как устроены блокировки и дедлоки в SQL Server и как их избегать?
Заголовок раздела «Как устроены блокировки и дедлоки в SQL Server и как их избегать?»Что это
Блокировка — отметка, что транзакция работает с ресурсом. Транзакция с несовместимой блокировкой ждёт.
| Блокировка | Кто ставит | С чем совместима |
|---|---|---|
| S, разделяемая | чтение | с другими S |
| X, эксклюзивная | INSERT, UPDATE, DELETE |
ни с чем |
Как возникает дедлок
- Транзакция A изменила заказ и держит X на его строке.
- Транзакция B изменила остаток товара и держит X на нём.
- A хочет остаток, B — заказ: обе ждут вечно.
SQL Server находит цикл, откатывает транзакцию-жертву и возвращает ей ошибку 1205.
Как избегать
- Обращайтесь к таблицам в одном порядке.
- Держите транзакции короткими, без ожидания пользователя.
Частая ошибка
Не обрабатывать 1205. Транзакция-жертва уже откатана, и без повтора операция теряется.
Как устроено
- Гранулярность. Блокируются ключ индекса или строка, страница, таблица. Перед X на строке сервер ставит IX на страницу и таблицу, поэтому запросу на блокировку таблицы не нужно проверять каждую строку.
- Режимы. S — чтение, U — чтение с намерением изменить, X — изменение, IS и IX — намеренные, Sch-S и Sch-M — стабильность и изменение схемы.
- Совместимость. S совместима с S и U, U — с S, но не с U, X — ни с чем.
- Дедлок. Монитор блокировок находит цикл ожиданий за секунды и откатывает жертву с ошибкой 1205.
Ловушки
- Дедлок конверсии. Две транзакции на REPEATABLE READ прочитали строку (S) и обе хотят X.
UPDLOCKпри чтении заставляет вторую ждать сразу. - Дедлок по двум индексам.
SELECTдержит S на ключе некластерного индекса и идёт в кластерный, аUPDATEдержит X на кластерном и меняет некластерный. Помогает покрывающий индекс или RCSI. NOLOCK. Это READ UNCOMMITTED: незафиксированные данные, пропущенные или задвоенные строки при сканировании, ошибка 601 при перемещении данных. Дедлоки между писателями он не убирает.
Правило
Один порядок доступа к таблицам, короткие транзакции без внешних вызовов, индексы под условия UPDATE и DELETE, повтор транзакции при 1205.
Под капотом
- Эскалация срабатывает, когда оператор набирает не меньше 5000 блокировок на одной ссылке на таблицу или блокировки занимают слишком много памяти. Строки и страницы заменяются блокировкой таблицы, а при
LOCK_ESCALATION = AUTO— партиции. - Монитор ищет циклы раз в 5 секунд, а после найденного дедлока — чаще, вплоть до 100 мс. Жертва — сессия с меньшим
DEADLOCK_PRIORITY, при равенстве — транзакция, которую дешевле откатить. - Сессия
system_healthвключена по умолчанию и пишетxml_deadlock_report: процессы, ресурсы, режимы, тексты запросов, уровни изоляции.
Цена
- Эскалация до таблицы останавливает остальных писателей. Массовые изменения режут на пачки по несколько тысяч строк.
NOLOCKне снимает конфликтов писателей и даёт неверные отчёты, а RCSI убирает конфликт читателей и писателей без грязных данных.
Компромиссы
- Optimized locking (SQL Server 2025, в Azure SQL Database всегда включена) снимает блокировку строки сразу после изменения и держит до конца транзакции одну блокировку её идентификатора. Нужен ADR, полную пользу даёт с RCSI, эскалация почти исчезает.
LOCK_ESCALATION = DISABLEи флаги трассировки 1211 и 1224 убирают эскалацию ценой памяти, вплоть до ошибки 1204.
Как проверить в проде
- Кто кого блокирует —
blocking_session_idиwait_typeвидаLCK_M_Xвsys.dm_exec_requests, детали — вsys.dm_tran_locks. - Долгие ожидания — blocked process report (
blocked process threshold) в Extended Events. - Дедлоки — граф из
system_health, эскалации — событиеlock_escalation.
Итог: блокировки — конечный общий ресурс; дедлоки диагностируют по графу и устраняют в коде и индексах, а повтор лишь страхует.
-- Сессия 1BEGIN TRAN;UPDATE dbo.Orders SET Status = 2 WHERE OrderId = 1; -- X на строку заказа
-- Сессия 2BEGIN TRAN;UPDATE dbo.Stock SET Qty = Qty - 1 WHERE ProductId = 1; -- X на строку остаткаUPDATE dbo.Orders SET Status = 3 WHERE OrderId = 1; -- ждёт сессию 1
-- Сессия 1UPDATE dbo.Stock SET Qty = Qty - 1 WHERE ProductId = 1; -- ждёт сессию 2: цикл-- Через несколько секунд одна из сессий получает Msg 1205:-- Transaction (Process ID 53) was deadlocked on lock resources with another process-- and has been chosen as the deadlock victim. Rerun the transaction.-- Исправление: в обеих транзакциях сначала Stock, потом Orders-- Кто кого блокирует прямо сейчасSELECT r.session_id, r.blocking_session_id, r.wait_type, r.wait_time, t.textFROM sys.dm_exec_requests AS rCROSS APPLY sys.dm_exec_sql_text(r.sql_handle) AS tWHERE r.blocking_session_id <> 0;
-- Последние дедлоки из кольцевого буфера system_healthSELECT x.ev.value('@timestamp', 'datetime2') AS deadlock_time, x.ev.query('.') AS deadlock_reportFROM (SELECT CAST(t.target_data AS xml) AS target_data FROM sys.dm_xe_session_targets AS t JOIN sys.dm_xe_sessions AS s ON s.address = t.event_session_address WHERE s.name = N'system_health' AND t.target_name = N'ring_buffer') AS dCROSS APPLY d.target_data.nodes('RingBufferTarget/event[@name="xml_deadlock_report"]') AS x(ev)ORDER BY deadlock_time DESC;- Что такое U-блокировка и зачем она? — Чтение с намерением изменить: совместима с S, но не с U, поэтому два «читателя-писателя» не уходят в дедлок конверсии.
- Как SQL Server выбирает жертву дедлока? — По
DEADLOCK_PRIORITY, при равном приоритете — транзакцию, которую дешевле откатить. - Когда случается эскалация блокировок? — От 5000 блокировок одного оператора на одной ссылке на таблицу или при нехватке памяти под блокировки.
- Где взять граф дедлока, если ничего не настраивали? — В сессии
system_health: событиеxml_deadlock_reportпишется по умолчанию. - Почему
NOLOCK— не решение? — Он меняет ожидание на неверные данные и не помогает при конфликтах писателей; для читателей есть RCSI.
Оптимистическая или пессимистическая конкуренция: как защитить данные и повторять транзакции из .NET?
Заголовок раздела «Оптимистическая или пессимистическая конкуренция: как защитить данные и повторять транзакции из .NET?»Что это
- Пессимистическая — «конфликт вероятен»: заблокировать строку, изменить, зафиксировать. Остальные ждут.
- Оптимистическая — «конфликт редок»: прочитать без блокировок, а при записи проверить, что строка не изменилась.
Как пользоваться
- Добавьте в таблицу столбец
RowVer rowversion— SQL Server меняет его при каждом изменении строки. - Прочитайте строку вместе с
RowVer. - Обновляйте с условием
WHERE Id = @id AND RowVer = @rowVer. - Изменилось 0 строк — кто-то успел раньше: перечитайте данные и сообщите пользователю.
Частая ошибка
Держать транзакцию открытой, пока пользователь редактирует форму. Блокировки висят минутами, хотя здесь нужен оптимистический подход.
Как устроено
rowversion— 8 байт из счётчика базы, растёт при каждомINSERTиUPDATEстроки. Синонимtimestampустарел. В EF Core такой столбец — concurrency token, конфликт приходит какDbUpdateConcurrencyException; подробности — в теме EF Core.UPDLOCK— U-блокировка при чтении до конца транзакции: второй такой же читатель ждёт, обычный — нет.UPDLOCK, HOLDLOCK— плюс диапазонная блокировка: в проверенный диапазон никто не вставит строку. Так делают «если нет — вставить» без дублей.- Атомарный оператор часто лучше обоих:
UPDATE ... SET Qty = Qty - @n WHERE Id = @id AND Qty >= @nи проверка@@ROWCOUNT.
Когда что выбирать
| Ситуация | Подход |
|---|---|
| Пользователь редактирует минутами | оптимистический, rowversion |
| Горячая строка: остаток, счётчик | атомарный UPDATE или UPDLOCK |
| Очередь задач в таблице | UPDLOCK, READPAST; в PostgreSQL FOR UPDATE SKIP LOCKED |
Ловушки
- Ошибки 1205 и 3960 уже откатили транзакцию — повторяют её целиком.
- Таймаут блокировки 1222 отменяет только оператор, а транзакция остаётся открытой: её откатывают явно.
Под капотом
| Признак | DbTransaction |
TransactionScope |
|---|---|---|
| Подключение | передаётся в каждую команду | соединение входит само при Open |
| Уровень по умолчанию | уровень сессии, обычно READ COMMITTED | SERIALIZABLE, таймаут 1 минута |
await внутри |
работает | нужен TransactionScopeAsyncFlowOption.Enabled |
| Два соединения | нельзя | эскалация в распределённую транзакцию |
Распределённая транзакция идёт через MSDTC. В .NET 7+ она работает только на Windows и после TransactionManager.ImplicitDistributedTransactions = true, иначе — исключение.
Компромиссы
- Повтор. Повторяют всю единицу работы: новое соединение, новую транзакцию, заново прочитанные данные. Число попыток ограничивают, паузу дают с джиттером, иначе жертвы столкнутся снова.
EnableRetryOnFailureв EF Core считает 1205, 3960 и 1222 временными ошибками. - Побочные эффекты. HTTP-вызов или сообщение внутри повторяемого блока уйдут дважды. Транзакция БД не охватывает внешние системы, для них есть outbox и саги — тема интеграций.
- Неизвестный исход коммита. Если связь оборвалась во время
COMMIT, клиент не знает, зафиксирована ли транзакция. Повтор безопасен для идемпотентной операции, например с уникальным ключомRequestIdв таблице операций.
Как проверить в проде
- Метрики повторов и ошибок 1205 и 3960: их рост — повод искать причину в графе дедлока, а не добавлять попытки.
RangeS-Sвsys.dm_tran_locksиserializableв графах дедлоков — следTransactionScopeс настройками по умолчанию.
Итог: выбирают между ожиданием и повтором, а повтор надёжен, только когда повторяется вся транзакция и операция идемпотентна.
-- @id, @price, @rowVer, @n — параметры процедуры-- Оптимистически: обновить, только если строку никто не менялUPDATE dbo.Products SET Price = @priceWHERE ProductId = @id AND RowVer = @rowVer;IF @@ROWCOUNT = 0 THROW 50001, 'Concurrency conflict: reload and retry.', 1;
-- Пессимистически: «если нет — вставить» без дублейSET XACT_ABORT ON;BEGIN TRAN;IF EXISTS (SELECT 1 FROM dbo.Stock WITH (UPDLOCK, HOLDLOCK) WHERE ProductId = @id) UPDATE dbo.Stock SET Qty = Qty + @n WHERE ProductId = @id;ELSE INSERT dbo.Stock (ProductId, Qty) VALUES (@id, @n); -- диапазон заблокирован: дубль не вставит никтоCOMMIT;using System.Data;using Microsoft.Data.SqlClient;
var cs = "Server=.;Database=Shop;Integrated Security=true;TrustServerCertificate=true";
// Повторяется вся транзакция: новое соединение, новая транзакцияawait WithRetryAsync(async () =>{ await using var conn = new SqlConnection(cs); await conn.OpenAsync(); await using var tx = (SqlTransaction)await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted); await using var cmd = new SqlCommand( "UPDATE dbo.Stock SET Qty = Qty - 1 WHERE ProductId = @id AND Qty > 0", conn, tx); cmd.Parameters.AddWithValue("@id", 1); await cmd.ExecuteNonQueryAsync(); await tx.CommitAsync();});
static async Task WithRetryAsync(Func<Task> action, int attempts = 3){ for (var i = 1; ; i++) { try { await action(); return; } // 1205 — жертва дедлока, 3960 — конфликт SNAPSHOT: сервер уже откатил транзакцию catch (SqlException ex) when (ex.Number is 1205 or 3960 && i < attempts) { await Task.Delay(Random.Shared.Next(50, 200) * i); // пауза с джиттером } }}using System.Transactions;using Microsoft.Data.SqlClient;
var cs = "Server=.;Database=Shop;Integrated Security=true;TrustServerCertificate=true";
// Без опций: SERIALIZABLE и таймаут 1 минута; без AsyncFlow транзакция теряется после awaitusing (var scope = new TransactionScope( TransactionScopeOption.Required, new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted, Timeout = TimeSpan.FromSeconds(30) }, TransactionScopeAsyncFlowOption.Enabled)){ await using var conn = new SqlConnection(cs); await conn.OpenAsync(); // соединение само входит в ambient-транзакцию await using var cmd = new SqlCommand("UPDATE dbo.Stock SET Qty = Qty - 1 WHERE ProductId = 1", conn); await cmd.ExecuteNonQueryAsync();
// Второе одновременно открытое соединение здесь вызвало бы эскалацию в распределённую транзакцию scope.Complete(); // без Complete — откат при Dispose}- Чем
UPDLOCKотличается отXLOCK? — U совместима с S, поэтому обычные читатели не ждут; X блокирует и их. - Почему
IF NOT EXISTS ... INSERTбез хинтов даёт дубли? — Между проверкой и вставкой другая сессия успевает вставить ту же строку;UPDLOCK, HOLDLOCKзакрывают диапазон. - Какой уровень изоляции у
new TransactionScope()? — SERIALIZABLE и таймаут 1 минута; задавайтеTransactionOptionsявно. - Что повторять при 1205: оператор или транзакцию? — Всю транзакцию: сервер уже откатил её целиком.
- Почему 1222 коварнее 1205? — Транзакция после него остаётся открытой, и без явного отката следующие операторы выполнятся в ней.