В предыдущих уроках мы говорили о том, как блокировки конфликтуют друг с другом, и про FIFO-очередь. Но есть ситуация, которую очередь сама по себе не решает: deadlock. Две транзакции взяли блокировки в разном порядке и теперь циклически ждут друг друга. Если ничего не делать — обе будут ждать вечно.
Postgres решает это по-простому и жёстко: раз в deadlock_timeout (default = 1 секунда) запускается детектор. Если он находит цикл в wait-for graph — выбирает «жертву» и убивает её.
Канонический пример deadlock
Самый простой сценарий — две сессии обновляют две строки в разном порядке:
T1 берёт строку A, T2 берёт строку B. T1 хочет B (ждёт T2), T2 хочет A (ждёт T1). Цикл в wait-for graph. Без вмешательства обе ждут вечно.
Это базовый сценарий. Но deadlock бывает на любых блокировках, не только row-level:
- Две сессии берут
LOCK TABLEна разные таблицы в разном порядке. - Сессия держит advisory lock, ждёт row lock, который держит другая, которая ждёт твой advisory lock.
- Большая транзакция с UPDATE на много таблиц + триггер, который тоже что-то лочит.
Алгоритм детекции: wait-for graph
Postgres строит направленный граф «кто кого ждёт». Узлы — транзакции. Ребро T1 → T2 означает «T1 ждёт T2». Deadlock = цикл в этом графе.
Постгрес обходит граф из ждущей транзакции по рёбрам. Если возвращается обратно — нашёл цикл. В цикле выбирается жертва — обычно самая 'молодая' транзакция (с наибольшим transaction id).
Важные детали алгоритма:
-
Детектор запускается лениво. Когда транзакция начинает ждать lock — она засыпает на
deadlock_timeout. Если за это время lock пришёл — всё хорошо, никаких детекторов. Если нет — просыпается и тогда запускает поиск цикла. Это экономия CPU: в нормальных условиях deadlock редок. -
deadlock_timeout— это default 1 секунда. Если у тебя нагрузка с частыми короткими блокировками — увеличь до 3-5s, чтобы не тратить CPU на ложные срабатывания. Если редко, но критично быстро — уменьши до 200ms. -
Если у двух транзакций нет рёбер в графе (одна ждёт другую, но не наоборот) — это не deadlock, это просто очередь. Никаких действий не предпринимается.
-
Выбор жертвы — deterministic: Postgres берёт ту, которая инициировала ожидание (то есть проснулась и обнаружила цикл). На практике это часто самая молодая транзакция, потому что более старые транзакции уже взяли свои блокировки и стоят первыми.
Что видит жертва
Убитая транзакция получает:
ERROR: deadlock detected
DETAIL: Process 12345 waits for ShareLock on transaction 678; blocked by process 67890.
Process 67890 waits for ShareLock on transaction 901; blocked by process 12345.
HINT: See server log for query details.
CONTEXT: while updating tuple (0,5) in relation "accounts"
Postgres:
- Откатывает всю транзакцию-жертву (ROLLBACK).
- Снимает все её блокировки.
- Другая сторона цикла просыпается, получает свой lock, продолжает работу.
С точки зрения приложения — это просто SQLSTATE 40P01 (deadlock_detected). Хорошие ORM/драйверы это понимают и могут авто-retry’ить.
Стратегии избегания
1. Consistent lock ordering
Главное правило: всегда брать блокировки в одном и том же порядке. Если приложение блокирует ресурсы по ID — всегда сортируй ID перед обработкой.
Пример: перевод денег между счетами.
-- Плохо: разные сессии лочат по-разному
BEGIN;
SELECT * FROM accounts WHERE id = :from_id FOR UPDATE;
SELECT * FROM accounts WHERE id = :to_id FOR UPDATE;
...
COMMIT;
Если две сессии одновременно делают transfer(1 → 2) и transfer(2 → 1) — гарантированный deadlock через 100 ms нагрузки.
Решение:
-- Хорошо: всегда сортируй ID по возрастанию
BEGIN;
-- Лочим оба счёта сразу в одном SELECT по возрастанию id
SELECT * FROM accounts WHERE id IN (:from_id, :to_id) ORDER BY id FOR UPDATE;
...
COMMIT;
Теперь обе сессии лочат id=1 сначала, id=2 потом. Никакого цикла.
2. Application-level retry
Даже с правильным ordering deadlock иногда случается — например, из-за триггеров, foreign keys или внутренних блокировок Postgres на уровне индексов. Поэтому в продакшен-коде:
for attempt in range(3):
try:
with conn.begin():
do_transfer(from_id, to_id, amount)
break
except DeadlockDetected:
if attempt == 2:
raise
sleep(0.05 * (2 ** attempt)) # exponential backoff
Retry с экспоненциальным backoff’ом — стандартная практика. Без неё deadlock = ошибка наружу, с ней — почти всегда успех со второй попытки.
3. Lock’и снаружи короче
Чем дольше транзакция держит lock — тем больше шанс deadlock. Стратегия:
- Не делать сетевые запросы внутри транзакции.
- Не вычислять тяжёлые агрегаты под FOR UPDATE.
- Декомпозировать большие транзакции на несколько маленьких, где возможно.
Демо в одной сессии
В pglite у нас одна сессия, поэтому реальный deadlock мы не воспроизведём — нужно две независимых backend-процесса. Но мы можем посмотреть, как Postgres проверяет наличие row-lock’а на уже занятой строке.
Берём row-lock через SELECT FOR UPDATE, потом тут же UPDATE другой строки — конфликта нет, row-locks не пересекаются. Это нормальный workflow внутри одной сессии.
transactionid lock в pg_locks — это и есть «механизм», через который другая транзакция узнала бы о нашем row-lock’е. Когда вторая сессия пытается обновить ту же строку, она читает xmax нашего кортежа, видит наш transactionid, и встаёт ждать lock на эту транзакцию. Если в этот момент она же держит ресурс, который ждём мы — Postgres найдёт цикл.
Демонстрация настройки deadlock_timeout. В реальной БД это меняется через ALTER SYSTEM или postgresql.conf. В pglite — для одного запроса через SET LOCAL.
Чек-лист
- Deadlock — цикл в wait-for graph. Postgres строит этот граф лениво, раз в
deadlock_timeout(default 1s). - Детектор просыпается у транзакции, которая ждёт lock дольше deadlock_timeout. Не у всех сразу — а только у того, кто начал ждать.
- Жертва — обычно та, кто инициировал поиск (та, что только что проснулась). Postgres откатывает её транзакцию.
- Ошибка в приложении:
SQLSTATE 40P01/deadlock_detected— это transient error, всегда retry’ить с backoff’ом. - Главная стратегия избегания — consistent lock ordering: лочи ресурсы в одном порядке (например, по возрастанию ID).
- Чем короче транзакция, тем меньше шанс deadlock. Не делай долгих вычислений или сетевых вызовов под
FOR UPDATE. deadlock_timeout— параметр. Слишком маленький = CPU тратится на ложные детекторы. Слишком большой = задержки в реакции на deadlock.