Learning Platform
Урок 11.03 · 22 мин
Продвинутый
Deadlockwait-for graphdeadlock_timeoutLock orderingRetry

В предыдущих уроках мы говорили о том, как блокировки конфликтуют друг с другом, и про FIFO-очередь. Но есть ситуация, которую очередь сама по себе не решает: deadlock. Две транзакции взяли блокировки в разном порядке и теперь циклически ждут друг друга. Если ничего не делать — обе будут ждать вечно.

Postgres решает это по-простому и жёстко: раз в deadlock_timeout (default = 1 секунда) запускается детектор. Если он находит цикл в wait-for graph — выбирает «жертву» и убивает её.

Канонический пример deadlock

Самый простой сценарий — две сессии обновляют две строки в разном порядке:

Deadlock из учебника

T1 берёт строку A, T2 берёт строку B. T1 хочет B (ждёт T2), T2 хочет A (ждёт T1). Цикл в wait-for graph. Без вмешательства обе ждут вечно.

T1t0: BEGIN
t1: UPDATE accounts WHERE id=1 (берёт row-lock A)
t2: UPDATE accounts WHERE id=2 (ждёт row-lock B)
статусждёт T2, держит A
T2t0: BEGIN
t1: UPDATE accounts WHERE id=2 (берёт row-lock B)
t2: UPDATE accounts WHERE id=1 (ждёт row-lock A)
статусждёт T1, держит B
итогцикл: T1 ждёт T2, T2 ждёт T1. Через deadlock_timeout Postgres увидит цикл и убьёт одну.

Это базовый сценарий. Но deadlock бывает на любых блокировках, не только row-level:

  • Две сессии берут LOCK TABLE на разные таблицы в разном порядке.
  • Сессия держит advisory lock, ждёт row lock, который держит другая, которая ждёт твой advisory lock.
  • Большая транзакция с UPDATE на много таблиц + триггер, который тоже что-то лочит.

Алгоритм детекции: wait-for graph

Postgres строит направленный граф «кто кого ждёт». Узлы — транзакции. Ребро T1 → T2 означает «T1 ждёт T2». Deadlock = цикл в этом графе.

Wait-for graph и поиск цикла

Постгрес обходит граф из ждущей транзакции по рёбрам. Если возвращается обратно — нашёл цикл. В цикле выбирается жертва — обычно самая 'молодая' транзакция (с наибольшим transaction id).

T1 (xid=100)ждёт T2
ребро
T2 (xid=105)ждёт T3
ребро
T3 (xid=110)ждёт T1
цикл найденT1 → T2 → T3 → T1. Жертва — T3 (самый большой xid, самая молодая).

Важные детали алгоритма:

  1. Детектор запускается лениво. Когда транзакция начинает ждать lock — она засыпает на deadlock_timeout. Если за это время lock пришёл — всё хорошо, никаких детекторов. Если нет — просыпается и тогда запускает поиск цикла. Это экономия CPU: в нормальных условиях deadlock редок.

  2. deadlock_timeout — это default 1 секунда. Если у тебя нагрузка с частыми короткими блокировками — увеличь до 3-5s, чтобы не тратить CPU на ложные срабатывания. Если редко, но критично быстро — уменьши до 200ms.

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

  4. Выбор жертвы — 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:

  1. Откатывает всю транзакцию-жертву (ROLLBACK).
  2. Снимает все её блокировки.
  3. Другая сторона цикла просыпается, получает свой 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 внутри одной сессии.

PostgreSQL

transactionid lock в pg_locks — это и есть «механизм», через который другая транзакция узнала бы о нашем row-lock’е. Когда вторая сессия пытается обновить ту же строку, она читает xmax нашего кортежа, видит наш transactionid, и встаёт ждать lock на эту транзакцию. Если в этот момент она же держит ресурс, который ждём мы — Postgres найдёт цикл.

Демонстрация настройки deadlock_timeout. В реальной БД это меняется через ALTER SYSTEM или postgresql.conf. В pglite — для одного запроса через SET LOCAL.

PostgreSQL

Чек-лист

  • 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.
Грабли потоков — deadlock, livelock, priority inversion, false sharing Поиск циклов: DFS с трёхцветной раскраской
Проверка знанийKnowledge check
У тебя на проде куча 'deadlock_detected' ошибок при banking-операциях перевода денег. Из логов видно, что транзакции вида UPDATE balance в accounts. Какие три шага ты предпримешь — и в каком порядке?
ОтветAnswer
Шаг 1 (немедленно): добавить application-level retry с exponential backoff на код, который ловит SQLSTATE 40P01. Это уберёт user-visible ошибки за минуты, не требует выкатки изменений в SQL. Шаг 2 (в этот же день): найти источник deadlock'а. Посмотри в pg_log — там полные query-тексты обеих сторон цикла. Скорее всего ты увидишь, что разные code paths лочат accounts в разном порядке (transfer(A→B) лочит сначала A, потом B; transfer(B→A) — наоборот). Исправь на consistent ordering: всегда лочи по возрастанию id (SELECT ... WHERE id IN (a,b) ORDER BY id FOR UPDATE). Шаг 3 (позже): измерь, насколько часто стало случаться. Если всё ещё чаще раза в минуту — посмотри, нет ли там foreign key locks (UPDATE может неявно взять lock на parent-row через FK), длинных триггеров или unique-constraint races. Возможно, имеет смысл переосмыслить схему (отдельная таблица journal вместо хранения balance как изменяемого поля).

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Как и когда Postgres проверяет наличие deadlock?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5