Перейти к содержимому

Транзакции и блокировки

Как база сохраняет данные целыми при параллельной работе: что гарантирует транзакция, чем отличаются уровни изоляции, как устроены блокировки и версии строк в SQL Server и PostgreSQL и как писать код, который переживает дедлоки и конфликты. Для нагруженных OLTP-систем это одна из главных тем собеседования.

Перед этой темой: SQL и T-SQL, Индексы и планы запросов.

Junior+
Ответ

Что это

Транзакция — последовательность операций, которую база выполняет как одну логическую единицу работы. Классический пример — перевод: списать 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 — признак слишком частых коммитов или медленного диска под журналом.

Итог: долговечность покупается синхронной записью журнала при коммите, поэтому размер транзакции — компромисс между числом сбросов лога и временем удержания блокировок.

T-SQL
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: нарушение CHECK
COMMIT; -- зафиксирует половину перевода: на счёте 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 TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK; -- перевод откатан целиком
THROW; -- вернуть ошибку клиенту
END CATCH;
Middle+

Какие бывают уровни изоляции и от каких аномалий они защищают?

Заголовок раздела «Какие бывают уровни изоляции и от каких аномалий они защищают?»
Ответ

Что это

Уровень изоляции — настройка транзакции, которая определяет, насколько ей мешают параллельные транзакции. Задаётся командой 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 это время жизни блокировок чтения.

T-SQL
-- База без RCSI. Сессия 1
SET 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-блокировку уже сняли
-- Сессия 1
SELECT Qty FROM dbo.Stock WHERE ProductId = 1; -- 5: неповторяемое чтение
COMMIT;
-- С REPEATABLE READ сессия 2 ждала бы COMMIT сессии 1,
-- и оба SELECT вернули бы 10
Middle+

Что такое 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 одной строки по-прежнему ждут друг друга.

Как устроено

  1. При включённой опции каждое изменение копирует прежнюю версию строки в хранилище версий и помечает её номером транзакции.
  2. RCSI берёт снимок на начало оператора. SNAPSHOT — на первое обращение к данным после BEGIN TRAN, а не на сам BEGIN TRAN.
  3. Читатели ставят только Sch-S на таблицу, без блокировок строк и страниц.
  4. 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 — самые старые транзакции, которые удерживают версии.

Итог: версии меняют блокировки чтения на место и обнаружение конфликтов, а главный враг обеих схем — длинные транзакции, которые не дают чистить старые версии.

T-SQL
ALTER DATABASE Shop SET ALLOW_SNAPSHOT_ISOLATION ON;
-- RCSI: ALTER DATABASE Shop SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;
-- Сессия 1
SET 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
-- Сессия 1
SELECT 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...
-- Транзакция откатана, её нужно повторить целиком
Middle+

Как устроены блокировки и дедлоки в SQL Server и как их избегать?

Заголовок раздела «Как устроены блокировки и дедлоки в SQL Server и как их избегать?»
Ответ

Что это

Блокировка — отметка, что транзакция работает с ресурсом. Транзакция с несовместимой блокировкой ждёт.

Блокировка Кто ставит С чем совместима
S, разделяемая чтение с другими S
X, эксклюзивная INSERT, UPDATE, DELETE ни с чем

Как возникает дедлок

  1. Транзакция A изменила заказ и держит X на его строке.
  2. Транзакция B изменила остаток товара и держит X на нём.
  3. 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.

Итог: блокировки — конечный общий ресурс; дедлоки диагностируют по графу и устраняют в коде и индексах, а повтор лишь страхует.

T-SQL
-- Сессия 1
BEGIN TRAN;
UPDATE dbo.Orders SET Status = 2 WHERE OrderId = 1; -- X на строку заказа
-- Сессия 2
BEGIN TRAN;
UPDATE dbo.Stock SET Qty = Qty - 1 WHERE ProductId = 1; -- X на строку остатка
UPDATE dbo.Orders SET Status = 3 WHERE OrderId = 1; -- ждёт сессию 1
-- Сессия 1
UPDATE 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
T-SQL: диагностика · Senior
-- Кто кого блокирует прямо сейчас
SELECT r.session_id, r.blocking_session_id, r.wait_type, r.wait_time, t.text
FROM sys.dm_exec_requests AS r
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) AS t
WHERE r.blocking_session_id <> 0;
-- Последние дедлоки из кольцевого буфера system_health
SELECT x.ev.value('@timestamp', 'datetime2') AS deadlock_time,
x.ev.query('.') AS deadlock_report
FROM (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 d
CROSS APPLY d.target_data.nodes('RingBufferTarget/event[@name="xml_deadlock_report"]') AS x(ev)
ORDER BY deadlock_time DESC;
Senior+

Оптимистическая или пессимистическая конкуренция: как защитить данные и повторять транзакции из .NET?

Заголовок раздела «Оптимистическая или пессимистическая конкуренция: как защитить данные и повторять транзакции из .NET?»
Ответ

Что это

  • Пессимистическая — «конфликт вероятен»: заблокировать строку, изменить, зафиксировать. Остальные ждут.
  • Оптимистическая — «конфликт редок»: прочитать без блокировок, а при записи проверить, что строка не изменилась.

Как пользоваться

  1. Добавьте в таблицу столбец RowVer rowversion — SQL Server меняет его при каждом изменении строки.
  2. Прочитайте строку вместе с RowVer.
  3. Обновляйте с условием WHERE Id = @id AND RowVer = @rowVer.
  4. Изменилось 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 с настройками по умолчанию.

Итог: выбирают между ожиданием и повтором, а повтор надёжен, только когда повторяется вся транзакция и операция идемпотентна.

T-SQL
-- @id, @price, @rowVer, @n — параметры процедуры
-- Оптимистически: обновить, только если строку никто не менял
UPDATE dbo.Products SET Price = @price
WHERE 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;
C#: повтор при дедлоке
Microsoft.Data.SqlClient
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); // пауза с джиттером
}
}
}
C#: TransactionScope
Microsoft.Data.SqlClient
using System.Transactions;
using Microsoft.Data.SqlClient;
var cs = "Server=.;Database=Shop;Integrated Security=true;TrustServerCertificate=true";
// Без опций: SERIALIZABLE и таймаут 1 минута; без AsyncFlow транзакция теряется после await
using (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
}