.NET Tutorial
Middle6 мин чтенияВопрос 10 из 17

Deadlocks — почему возникают и как избежать?

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

T2: перевод 2 → 1Счёт 2Счёт 1T1: перевод 1 → 2цикл ожидания — deadlockСУБД выбираетжертву: T2откатывается(ошибка 1205 /40P01)UPDATE счёт 1 —X-блокировкаUPDATE счёт 2 —X-блокировкаUPDATE счёт 2 — ЖДЁТ T2UPDATE счёт 1 — ЖДЁТ T1получает блокировку, продолжает, COMMIT
T2: перевод 2 → 1Счёт 2Счёт 1T1: перевод 1 → 2цикл ожидания — deadlockСУБД выбираетжертву: T2откатывается(ошибка 1205 /40P01)UPDATE счёт 1 —X-блокировкаUPDATE счёт 2 —X-блокировкаUPDATE счёт 2 — ЖДЁТ T2UPDATE счёт 1 — ЖДЁТ T1получает блокировку, продолжает, COMMIT
Классический deadlock при переводах

ждёт ресурс,
которым владеет

ждёт ресурс,
которым владеет

Транзакция 1

Транзакция 2

ждёт ресурс,
которым владеет

ждёт ресурс,
которым владеет

Транзакция 1

Транзакция 2

Граф ожиданий содержит цикл

Детектор deadlock-ов периодически (в PostgreSQL — после deadlock_timeout, по умолчанию 1 с ожидания; в SQL Server — каждые ~5 с, чаще при обнаружениях) строит граф ожиданий и ищет циклы.

Типичные сценарии

1. Разный порядок доступа

Перевод 1 → 2 и перевод 2 → 1 одновременно (пример выше). То же — с таблицами: один код обновляет orders, потом stock, другой — stock, потом orders.

Решение — единый порядок, например по возрастанию id:

C#
var (first, second) = fromId < toId ? (fromId, toId) : (toId, fromId);
await using var tx = await db.Database.BeginTransactionAsync(ct);
await db.Database.ExecuteSqlAsync($"SELECT id FROM accounts WHERE id IN ({first}, {second}) ORDER BY id FOR UPDATE", ct);
// теперь обе строки заблокированы в едином порядке — можно безопасно менять

2. Конверсия блокировок: прочитать, затем изменить

T2Строка товараT1deadlockSELECT (S-блокировка приRepeatable Read /Serializable)SELECT (S-блокировка —совместима)UPDATE — нужна X, ждётснятия S у T2UPDATE — нужна X, ждётснятия S у T1
T2Строка товараT1deadlockSELECT (S-блокировка приRepeatable Read /Serializable)SELECT (S-блокировка —совместима)UPDATE — нужна X, ждётснятия S у T2UPDATE — нужна X, ждётснятия S у T1
Два читателя хотят стать писателями

Решение: сразу брать блокировку на изменение при чтении — SELECT ... FOR UPDATE (PostgreSQL), WITH (UPDLOCK) (SQL Server). Или вообще не читать: атомарный UPDATE stock SET qty = qty - 1 WHERE id = @id AND qty > 0.

3. Отсутствие индекса

UPDATE orders SET status = 'x' WHERE customer_id = 5 без индекса по customer_id сканирует таблицу и (в блокировочных СУБД) блокирует гораздо больше строк/страниц, чем нужно, увеличивая шанс пересечения с другими транзакциями. Индексы на FK и на столбцы в WHERE изменений уменьшают «площадь» блокировок.

4. Внешние ключи и каскады

Вставка дочерней строки берёт разделяемую блокировку на родителя (проверка FK). Параллельное удаление/обновление родителя в другом порядке → deadlock. Каскадное удаление блокирует множество строк в разных таблицах.

5. Длинные транзакции

Чем дольше транзакция держит блокировки (HTTP-вызов внутри транзакции, пользовательский ввод), тем выше вероятность пересечения.

Как расследовать

SQL
-- SQL Server: встроенная сессия Extended Events system_health хранит deadlock graph
SELECT xed.value('@timestamp', 'datetime2') AS time, xed.query('.') AS deadlock_graph
FROM (SELECT CAST(target_data AS xml) AS td
      FROM sys.dm_xe_session_targets t JOIN sys.dm_xe_sessions s ON s.address = t.event_session_address
      WHERE s.name = 'system_health' AND t.target_name = 'ring_buffer') x
CROSS APPLY td.nodes('RingBufferTarget/event[@name="xml_deadlock_report"]') AS q(xed);
PostgreSQL: сообщение в логе (log_lock_waits = on)
ERROR:  deadlock detected
DETAIL:  Process 4121 waits for ShareLock on transaction 9054; blocked by process 4135.
         Process 4135 waits for ShareLock on transaction 9053; blocked by process 4121.
         Process 4121: UPDATE accounts SET balance = balance + 500 WHERE id = 2
         Process 4135: UPDATE accounts SET balance = balance + 500 WHERE id = 1

Deadlock graph показывает участников, их запросы, удерживаемые и ожидаемые ресурсы — по нему восстанавливается порядок доступа.

Retry в .NET

C#
// EF Core: встроенная стратегия повторяет транзиентные ошибки, включая deadlock
builder.Services.AddDbContext<ShopDbContext>(o => o.UseSqlServer(conn, sql => sql.EnableRetryOnFailure(
    maxRetryCount: 3, maxRetryDelay: TimeSpan.FromSeconds(2), errorNumbersToAdd: [1205])));

// Polly для произвольного кода (Dapper, ADO.NET)
var pipeline = new ResiliencePipelineBuilder()
    .AddRetry(new RetryStrategyOptions
    {
        ShouldHandle = new PredicateBuilder()
            .Handle<SqlException>(e => e.Number == 1205)
            .Handle<PostgresException>(e => e.SqlState == PostgresErrorCodes.DeadlockDetected),
        MaxRetryAttempts = 3,
        Delay = TimeSpan.FromMilliseconds(100),
        BackoffType = DelayBackoffType.Exponential,
        UseJitter = true
    })
    .Build();

await pipeline.ExecuteAsync(async ct => await TransferAsync(from, to, amount, ct), ct);

Повторять нужно всю транзакцию целиком (вместе с чтениями), а не только упавший оператор.

Чек-лист профилактики

Единый порядок доступа
к таблицам и строкам

Короткие транзакции:
без I/O и ожиданий внутри

Индексы под WHERE изменений
и на внешних ключах

FOR UPDATE / UPDLOCK
при чтении перед изменением

Атомарные UPDATE вместо
прочитать → изменить

MVCC-чтение: RCSI / snapshot —
читатели не блокируют писателей

Retry транзакции
при 1205 / 40P01

Единый порядок доступа
к таблицам и строкам

Короткие транзакции:
без I/O и ожиданий внутри

Индексы под WHERE изменений
и на внешних ключах

FOR UPDATE / UPDLOCK
при чтении перед изменением

Атомарные UPDATE вместо
прочитать → изменить

MVCC-чтение: RCSI / snapshot —
читатели не блокируют писателей

Retry транзакции
при 1205 / 40P01

Как снизить вероятность deadlock-ов

Deadlock не только в БД

  • lock в C# при захвате двух объектов в разном порядке.
  • Sync-over-async: .Result в UI-потоке (см. вопрос про async/await).
  • Распределённые блокировки (Redis) без TTL.

Принципы те же: единый порядок, короткие критические секции, таймауты.

Вопросы на засыпку

Чем deadlock отличается от обычной блокировки (blocking)?

Blocking — одна транзакция ждёт другую, которая рано или поздно завершится и освободит ресурс. Deadlock — круговое ожидание, которое само не разрешится; его разрывает только СУБД, убивая жертву.

Как СУБД выбирает жертву?

SQL Server — по умолчанию транзакцию с наименьшей стоимостью отката (можно влиять SET DEADLOCK_PRIORITY). PostgreSQL — ту, в процессе которой был обнаружен цикл (кто первым проверил после deadlock_timeout).

Помогает ли NOLOCK от deadlock-ов?

Он убирает разделяемые блокировки чтения, но ценой грязного и неконсистентного чтения (пропущенные и дублированные строки). Правильное решение для SQL Server — READ_COMMITTED_SNAPSHOT: читатели работают с версиями строк и не блокируют писателей.

Возможен ли deadlock в одной таблице и одной строке?

Да — при конверсии блокировок (оба держат S и хотят X) или при взаимодействии индексов: одна транзакция идёт по некластерному индексу к строке, другая — по кластерному к индексу, блокируя ключи в разном порядке.

Итог

Deadlock — круговое ожидание блокировок, которое СУБД разрывает, откатывая одну транзакцию. Снижайте вероятность единым порядком доступа, короткими транзакциями, индексами и захватом блокировки на изменение сразу при чтении; а в коде всегда предусматривайте повтор транзакции — под нагрузкой deadlock-и неизбежны.