Дедлоки и advisory-локи
Два процесса Brew обрабатывают возвраты. Первый блокирует строку заказа, потом тянется к строке товара. Второй — наоборот: сначала товар, потом заказ. По отдельности код безупречен. Но если они стартуют одновременно, случается худшее: первый держит заказ и ждёт товар, второй держит товар и ждёт заказ. Каждый ждёт того, что держит другой. Никто не отпустит первым — они застряли навечно. Это дедлок (deadlock), взаимная блокировка.
Хорошая новость: Postgres не зависнет. Он обнаруживает цикл блокировок сам (по таймеру deadlock_timeout, по умолчанию 1 секунда) и разрывает его — выбирает «жертву» и завершает её транзакцию ошибкой 40P01 (deadlock_detected), вторая проходит. Этот юнит — про две вещи: про 40P01 (что это и откуда) и про pg_advisory_lock — прикладную блокировку, которой дедлоки чаще всего предотвращают.
Это escape-hatch-юнит (как 05-02): дедлок конкурентен по природе, ведём урок psql-скриптами.
Дедлок и его профилактика
Дедлоку нужен «крест»: две транзакции захватывают одни и те же ресурсы в противоположном порядке. Отсюда и главное лекарство — единый порядок блокировок. Если оба процесса возвратов всегда берут сначала заказ, потом товар (например, по возрастанию id), цикл не образуется: тот, кто первым взял заказ, спокойно возьмёт и товар, второй подождёт его и пойдёт следом. Дедлоки в приложениях на 90% лечатся дисциплиной «блокируй ресурсы в одном и том же порядке».
Транзакция A ──держит──▶ строка #1
│ ▲
хочет хочет
▼ │
строка #2 ◀──держит── Транзакция BA держит #1 и хочет #2; B держит #2 и хочет #1 — стрелки «хочет» идут навстречу, это и есть дедлок-«крест». Каждый ждёт того, что держит другой, цикл замкнут → 40P01. Возьми обе транзакции ресурсы в одном порядке (сначала #1, потом #2) — и стрелки «хочет» больше не пересекаются: цикла нет.
40P01 — транзиентная ошибка, как и 40001 из 05-04/05-05: жертве достаточно повторить транзакцию (та же ретрай-петля). Но в отличие от serialization failure, дедлок — это почти всегда сигнал кривого порядка блокировок, а не неизбежность; повтор спасает здесь и сейчас, но чинить надо порядок.
Advisory-лок: блокировка не для данных, а для логики
Иногда нужно сериализовать не строку, а операцию: «пусть пересчёт остатков кофейни #7 выполняет только один воркер за раз», даже если он трогает десятки строк. Городить для этого блокировку какой-то одной «сторожевой» строки — хрупко. Postgres даёт честный инструмент — advisory-лок: именованную защёлку по произвольному 64-битному ключу, смысл которого знает только приложение.
SELECT pg_try_advisory_lock(42); -- t — взяли, f — занято (без ожидания)
-- ... критическая секция ...
SELECT pg_advisory_unlock(42); -- отпустилиAdvisory-локи бывают двух видов по времени жизни: session-level (pg_advisory_lock/pg_try_advisory_lock) живёт до явного unlock или конца коннекта, и транзакционный (pg_advisory_xact_lock) — освобождается сам на COMMIT/ROLLBACK. Второй безопаснее: забыть отпустить нельзя. Именно одним advisory-локом как «воротами» можно и предотвратить наш дедлок: если обе транзакции сперва берут общий pg_advisory_xact_lock(returns_key), они выстраиваются в очередь и в принципе не схлёстываются на строках.
Три функции advisory-API — по ожиданию и времени жизни:
| Функция | Ждёт, если занято? | Время жизни | Снимается |
|---|---|---|---|
pg_advisory_lock(key) | да, блокируется | session-level | явным pg_advisory_unlock или концом коннекта |
pg_try_advisory_lock(key) | нет — сразу t/f | session-level | то же |
pg_advisory_xact_lock(key) | да, блокируется | транзакция | сам на COMMIT/ROLLBACK |
Что показывает наш код
demo.sql (цель run) — детерминированный тур по API advisory-локов на одной сессии: взять/повторно взять (реентрабельность), отпустить нужное число раз (лишний unlock → f + WARNING), и транзакционный лок, который снимается сам на COMMIT.
session-a.sql / session-b.sql — живой дедлок в двух терминалах: A берёт строку #1 потом тянется к #2, B берёт #2 потом тянется к #1. Цикл замыкается — и Postgres завершает одну из транзакций с 40P01.
Запуск
Подними песочницу (из корня репозитория) и накати схему Brew:
docker compose up -d
make lecture L=05-transactions-and-mvcc/05-06-deadlocks-and-advisory-locks T=db-resetДетерминированное демо API advisory-локов:
make lecture L=05-transactions-and-mvcc/05-06-deadlocks-and-advisory-locks── Берём session-level advisory-лок по ключу 42 ──
got_42
--------
t
(1 row)
── Та же сессия берёт ключ 42 повторно (реентрабельно) → счётчик = 2 ──
got_42_again
--------------
t
(1 row)
── Отпускаем дважды (t, t); третий unlock → f (лок уже не наш; +WARNING в stderr) ──
unlock_1
----------
t
(1 row)
unlock_2
----------
t
(1 row)
unlock_3
----------
f
(1 row)
── Транзакционный лок по ключу 7: живёт до COMMIT, освобождается сам ──
pg_advisory_xact_lock
-----------------------
(1 row)
held_now
----------
1
(1 row)
held_after_commit
-------------------
0
(1 row)
→ held_now=1 (внутри tx), held_after_commit=0 (COMMIT снял лок автоматически).Теперь живой дедлок. В первом терминале запусти session-a: A возьмёт строку #1 и остановится у подсказки. Во втором терминале прогони session-b до его подсказки — B возьмёт строку #2. Вернись в A, нажми Enter — A потянется за #2 и зависнет (её держит B). Вернись в B, нажми Enter — B потянется за #1, цикл замкнётся, и Postgres разорвёт дедлок:
A2) Тянемся за строкой #2 (её держит B) → A ЗАВИСАЕТ в ожидании.
Как только B потянется за строкой #1, цикл замкнётся, и Postgres разорвёт дедлок:
ERROR: deadlock detected
DETAIL: Process 1863 waits for ShareLock on transaction 832; blocked by process 1864.
Process 1864 waits for ShareLock on transaction 831; blocked by process 1863.
HINT: See server log for query details.
CONTEXT: while updating tuple (0,2) in relation "lock_lab"Postgres сам выбрал жертву (здесь — A) и откатил её транзакцию; B при этом прошла и закоммитила. (Номера процессов/транзакций в DETAIL будут свои, и жертвой может оказаться B — выбор за планировщиком.) После демо накати схему Brew заново: make ... T=db-reset.
Заборчик
Порядок шагов в сессиях держат \prompt — в реальной гонке всё происходит за миллисекунды, и какую транзакцию назначить жертвой, решает Postgres (как правило — ту, что откатить «дешевле»). А вот что стоит помнить:
- Дедлок возможен не только на строках. Внешние ключи без индекса, эскалация на уровне
ALTER TABLE, те же advisory-локи, взятые крест-накрест, — везде, где есть блокировки. - Бороться лучше профилактикой, не ретраем. Единый порядок захвата, короткие транзакции, индексы под FK (см. 06). Ретрай на
40P01— страховка, а не стратегия. 40001и40P01— разной природы.40001(05-04/05-05) — про логические конфликты сериализации, порядком блокировок его не предотвратить.40P01— про физический цикл ожидания, и вот он порядком как раз лечится.- Advisory-локи тоже не бесплатны. Session-level лок легко «утечёт» (забыли unlock, или коннект вернулся в пул с висящим локом) — поэтому в приложениях почти всегда берут
pg_advisory_xact_lock, который снимается сам.
Что забрать с собой
- Дедлок — две транзакции ждут ресурсы, захваченные друг другом во встречном порядке; никто не продолжит.
- Postgres обнаруживает дедлок сам (по
deadlock_timeout) и разрывает: жертве —40P01(deadlock_detected), вторая проходит. - Лекарство-профилактика — единый порядок блокировок (например, по возрастанию id); ретрай на
40P01— страховка, а не замена дисциплине. - Advisory-лок (
pg_advisory_lock/pg_advisory_xact_lock) — прикладная блокировка по числовому ключу, не привязанная к строкам: сериализует операцию. Транзакционный вариант освобождается сам наCOMMIT— предпочтителен. 40P01(физический цикл ожидания) лечится порядком блокировок;40001(логический конфликт сериализации) — нет. Оба транзиентны и оба ретраятся.
Это последний юнит модуля 05. Дальше — модуль 06 «Индексы и EXPLAIN»: блокировки и снимки разобраны, пора понять, как Postgres находит строки, и научиться читать план запроса (EXPLAIN ANALYZE с буферами — в PG18 по умолчанию).