open atlas
↑ К треку
SQL и PostgreSQL вглубь SQL · 07 · 05

Deadlock'и

Deadlock — это цикл блокировок: две транзакции держат строку, нужную другой. Postgres обнаруживает цикл, убивает жертву ошибкой 40P01, и ты повторяешь — но консистентный порядок блокировок предотвращает его полностью.

SQL Senior ◷ 15 min
Уровень
ОсновыJuniorMiddleSenior

Платёжный сервис ловил deadlock примерно сорок раз в час на пике — каждый это транзакция, убитая на лету с ERROR: deadlock detected. Переводы по отдельности были корректны; баг был в том, что transfer(A, B) блокировал счёт A, затем B, а transfer(B, A) блокировал B, затем A. Когда они шли одновременно, каждый хватал по одной строке и затем вечно ждал чужую. Postgres ломал тупик, расстреливая одну транзакцию. Постоянный фикс изменил одну строку: всегда блокировать меньший id счёта первым. С тех пор ноль deadlock’ов.

Анатомия deadlock’а

Когда видишь в продакшн-логах ERROR: deadlock detected, инстинкт — подкрутить deadlock_timeout или добавить retry и двигаться дальше. Сопротивляйся. Каждый deadlock указывает на инверсию порядка, которую однострочный фикс устраняет навсегда.

Deadlock’у нужен ровно один ингредиент: две транзакции, захватывающие одни блокировки в противоположном порядке. Каждая берёт первую блокировку, затем просит вторую — которую уже держит другая — и ни одна не отпустит, пока не получит то, на чём заблокирована. Это цикл в графе ожиданий, и никакое ожидание его не разрешит.

-- Транзакция 1                            -- Транзакция 2
BEGIN;                                     BEGIN;
UPDATE accounts SET balance = balance - 100
  WHERE id = 1;       -- блокирует строку 1
                                           UPDATE accounts SET balance = balance - 50
                                             WHERE id = 2;     -- блокирует строку 2
UPDATE accounts SET balance = balance + 100
  WHERE id = 2;       -- ЖДЁТ строку 2 (держит Tx2)
                                           UPDATE accounts SET balance = balance + 50
                                             WHERE id = 1;     -- ЖДЁТ строку 1 (держит Tx1)
-- теперь обе ждут друг друга вечно -> deadlock

Как Postgres его обнаруживает и разрешает

Postgres не проверяет deadlock на каждом ожидании блокировки — это было бы дорого. Вместо этого, когда транзакция блокируется на блокировке, она ждёт deadlock_timeout (по умолчанию 1 секунда) и только затем запускает детектор deadlock’ов, который обходит граф ожиданий в поисках цикла. Найдя его, он выбирает жертву и прерывает её:

ERROR:  deadlock detected
DETAIL:  Process 1234 waits for ShareLock on transaction 5678; blocked by process 4321.
         Process 4321 waits for ShareLock on transaction 8765; blocked by process 1234.
HINT:    See server log for query details.
SQLSTATE: 40P01

Транзакция жертвы откатывается целиком; выживший идёт дальше. deadlock_timeout в 1 секунду означает, что реальный deadlock стоит как минимум ~1с задержки до разрешения, так что частые deadlock’и — и запах корректности, и проблема задержки. Снижение timeout ускоряет обнаружение, но добавляет CPU на каждом обычном ожидании блокировки — не крути его, пока не починишь порядок.

Профилактика лучше обнаружения

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

-- Перевод без deadlock: блокируем обе строки одним оператором, меньший id первым.
BEGIN;
SELECT id FROM accounts
WHERE id IN (:from_id, :to_id)
ORDER BY id           -- магия: детерминированный порядок, оба направления
FOR UPDATE;
UPDATE accounts SET balance = balance - :amt WHERE id = :from_id;
UPDATE accounts SET balance = balance + :amt WHERE id = :to_id;
COMMIT;

Поскольку transfer(1, 2) и transfer(2, 1) теперь оба блокируют id 1 раньше id 2, один полностью захватывает до старта другого — цикла нет. Остальные рычаги по приоритету: держи транзакции короткими (меньше времени с блокировками = меньше окно столкновений), трогай строки в стабильном порядке по всему приложению и лишь затем рассматривай NOWAIT, чтобы падать быстро, а не выжидать timeout.

Граничные случаи

Deadlock’и — не только твои явные UPDATE. Foreign key тоже берут блокировки: вставка дочерней строки блокирует родителя FOR KEY SHARE, и две транзакции, вставляющие детей двух родителей и заодно обновляющие этих родителей, могут словить deadlock неочевидно. Конкуренция по индексам и unique-ограничениям тоже может породить циклы, когда конкурентные вставки трогают одни страницы индекса или конфликтуют на одном ключе. Очередь jobs из прошлого урока — рабочий пример: воркеры с FOR UPDATE SKIP LOCKED никогда не словят deadlock друг на друге, потому что никто не ждёт — они пропускают — ещё одна причина, почему SKIP LOCKED верный примитив очереди. Когда deadlock всё же случился, читай DETAIL в логе сервера: он называет два процесса и блокировки в цикле, чего обычно достаточно, чтобы заметить инверсию порядка. Всегда заставляй вызывающего жертвы повторять всю транзакцию с ограниченным backoff; повторённый перевод заново блокирует в верном порядке и проходит.

Викторина

Две транзакции перевода, transfer(1,2) и transfer(2,1), идут одновременно, и каждая блокирует свой исходный счёт первым. Что произойдёт и каков надёжный фикс?

Викторина

Твоё приложение поймало SQLSTATE 40P01 (deadlock). Какова верная реакция в коде приложения?

Расставь шаги по порядку

Упорядочи жизненный цикл deadlock'а от образования к разрешению и постоянному фиксу:

  1. 1 Две транзакции блокируют одни строки в противоположном порядке, каждая держит одну и хочет другую
  2. 2 Заблокированная транзакция выжидает deadlock_timeout (по умолчанию ~1с)
  3. 3 Детектор deadlock'ов находит цикл и прерывает одну жертву с SQLSTATE 40P01
  4. 4 Вызывающий жертвы повторяет всю транзакцию с backoff
  5. 5 Ты меняешь код, чтобы блокировать строки в консистентном порядке (например, ORDER BY id), чтобы цикл больше не мог образоваться
Вспомните перед уходом
  1. 01
    Что именно такое deadlock и какое единственное условие вызывает классический случай двух строк?
  2. 02
    Как Postgres обнаруживает и разрешает deadlock и какова цена?
  3. 03
    Каков надёжный фикс deadlock'ов и что код приложения всё равно обязан делать?
Итог

Deadlock — единственный отказ конкурентности, который Postgres разрешает за тебя — убивая транзакцию. Он образуется, когда две транзакции захватывают одни блокировки в противоположном порядке: каждая держит один ресурс и вечно блокируется на другом, цикл в графе ожиданий. Postgres не опрашивает это на каждом ожидании; заблокированная транзакция сидит deadlock_timeout (по умолчанию ~1 секунда), затем запускается детектор, находит цикл и прерывает жертву с ERROR: deadlock detected, SQLSTATE 40P01, откатывая её работу целиком, пока выживший коммитится. Поскольку транзакция жертвы исчезла, приложение обязано повторить её с ограниченным backoff. Но повтор — пластырь; лекарство — профилактика. Блокируй несколько строк в консистентном глобальном порядке — каноничный трюк ORDER BY id ... FOR UPDATE, чтобы каждое направление перевода блокировало меньший id первым — держи транзакции короткими, чтобы минимизировать окно, и помни, что конкуренция по foreign key и индексам тоже может дать deadlock. Очередь FOR UPDATE SKIP LOCKED из прошлого урока обходит deadlock’и, никогда не ожидая. Читай DETAIL в логе, чтобы найти инвертированный порядок, исправь порядок раз — и deadlock’и прекращаются.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.