Перейти к содержанию
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 из 5