22.1 Тупик из двух транзакций
Возьмём перевод денег между двумя бронированиями. T1 списывает с первого и зачисляет на второе; T2 делает то же, но в обратном порядке.
-- T1
BEGIN;
UPDATE bookings SET total = total - 10 WHERE book_ref = '000001'; -- захватил строку 1
-- ... пауза ...
UPDATE bookings SET total = total + 10 WHERE book_ref = '000002'; -- хочет строку 2
-- T2 (в это же время)
BEGIN;
UPDATE bookings SET total = total - 10 WHERE book_ref = '000002'; -- захватил строку 2
-- ... пауза ...
UPDATE bookings SET total = total + 10 WHERE book_ref = '000001'; -- хочет строку 1
После первых двух UPDATE каждая транзакция держит «свою» строку.
Дальше T1 ждёт строку 2 (её держит T2), а T2 ждёт строку 1 (её
держит T1). Ни один UPDATE не пройдёт: каждый ждёт того, кто
ждёт его. Это и есть тупик.
22.2 Граф ожидания и цикл в нём
Формально блокировки описываются графом ожидания (wait-for
graph): вершины - транзакции, ребро A → B значит «A ждёт
блокировку, которую держит B».
нормальная очередь: C → B → A (A держит, ждут B и C)
взаимоблокировка: T1 → T2 → T1 (цикл!)
Пока граф - это цепочки и деревья без циклов, всё в порядке: корень рано или поздно завершится, и ожидание поедет дальше. Цикл в графе означает, что ждать бесконечно. Обнаружение deadlock - это ровно поиск цикла в графе ожидания.
Цикл может быть длиннее двух: T1 → T2 → T3 → T1. Механизм тот же - замкнутая цепочка «каждый ждёт следующего».
22.3 Как PostgreSQL находит цикл
Строить граф и искать в нём цикл на каждую блокировку дорого, а 99% ожиданий - это нормальная очередь, которая разрешится сама. Поэтому PostgreSQL не ищет deadlock сразу. Он ждёт.
Когда транзакция встаёт в очередь за блокировкой, взводится
таймер deadlock_timeout (по умолчанию 1 секунда). Если за это
время блокировку выдали - отлично, никто не тратил силы на анализ.
Если таймер сработал, а блокировки всё нет - запускается детектор:
он строит граф ожидания и ищет цикл.
Нашёл цикл - выбирает жертву и откатывает её. Не нашёл (просто
долгая очередь) - продолжает ждать. Отсюда практическое следствие:
между возникновением deadlock и его разрывом проходит примерно
deadlock_timeout. Уменьшать его ниже секунды обычно вредно -
детектор начнёт дёргаться на каждой чуть подзадержавшейся очереди.
22.4 Сообщение в логе
Когда детектор разрывает цикл, проигравшая транзакция получает ошибку, а сервер пишет в лог подробности - это главный источник для расследования.
ERROR: deadlock detected
DETAIL: Process 412 waits for ShareLock on transaction 1033;
blocked by process 410.
Process 410 waits for ShareLock on transaction 1034;
blocked by process 412.
HINT: See server log for query details.
CONTEXT: while updating tuple (0,5) in relation "bookings"
Читается как описание цикла: процесс 412 ждёт транзакцию, которую
держит 410, а 410 ждёт ту, что держит 412. CONTEXT называет
конкретный кортеж и таблицу, на которых всё сошлось. По этим двум
запросам почти всегда видно, что они брали одни и те же строки в
разном порядке.
Код ошибки - SQLSTATE 40P01. Приложение должно его ловить:
deadlock не баг данных, а штатная ситуация при конкуренции, и
правильная реакция - повторить транзакцию.
22.4.1 Подводный камень: deadlock без второго UPDATE
Не нужно двух явных UPDATE, чтобы получить цикл. Одного
UPDATE, задевающего несколько строк, достаточно, если две
транзакции обходят строки в разном физическом порядке:
-- T1
UPDATE bookings SET total = total + 1 WHERE book_ref IN ('000001','000002');-- T2
UPDATE bookings SET total = total + 1 WHERE book_ref IN ('000002','000001');Внутри одного UPDATE строки блокируются по мере обхода. Если T1
пошёл по плану от строки 1 к 2, а T2 - от 2 к 1, получается тот же
цикл. Порядок обхода задаёт план запроса, а не порядок в IN,
поэтому управлять им вручную трудно.
Ещё один источник - внешние ключи: вставка в дочернюю таблицу берёт FOR KEY SHARE на родителя, и две вставки, ссылающиеся на разных родителей крест-накрест, способны сойтись в цикл. Deadlock редко выглядит как очевидный «два UPDATE наоборот».
22.5 Кто становится жертвой
Детектор разрывает цикл, откатывая одну транзакцию. Обычно это
та, чьё ожидание замкнуло цикл - то есть процесс, на чьём таймере
deadlock_timeout сработал детектор. Гарантировать конкретную
жертву нельзя: это деталь реализации, а не контракт.
Жертва получает полный ROLLBACK - откатываются все её изменения
в этой транзакции, не только последний запрос. Вторая транзакция
продолжает как ни в чём не бывало: блокировки жертвы спали, её
UPDATE проходит.
Счётчик pg_stat_database.deadlocks увеличивается на единицу при
каждом разрыве. Это удобный индикатор: если он растёт - в
приложении есть конкурирующий доступ с несогласованным порядком
блокировок, даже если в логах это пока не разбирали.
22.6 Как писать код без взаимоблокировок
Deadlock нельзя «выключить», но его почти всегда можно спроектировать прочь. Три приёма, по убыванию важности.
Первое - единый порядок блокировок. Если все транзакции, трогающие
набор строк, берут их в одном и том же порядке (например, по
возрастанию первичного ключа), цикл невозможен в принципе: граф
ожидания становится ациклическим. Для UPDATE нескольких строк
это ORDER BY в подзапросе или явная блокировка через
SELECT ... FOR UPDATE ... ORDER BY id.
Второе - короткие транзакции. Чем меньше времени строка под замком, тем уже окно для пересечения. «Подержать» строку заблокированной, пока приложение ходит во внешний сервис, - верный способ собрать и deadlock, и каскад.
Третье - ретрай. Поскольку deadlock штатен, оберни транзакцию в
повтор: поймал 40P01 - подожди немного и выполни заново. Без
ретраев даже редкий deadlock превращается в видимую пользователю
ошибку.
22.6.1 Копнуть глубже: deadlock_timeout против log_lock_waits
Тот же таймер deadlock_timeout управляет и логированием долгих
ожиданий. Если включён log_lock_waits = on, то по истечении
deadlock_timeout сервер пишет в лог не только найденные циклы,
но и факт «процесс N ждёт блокировку дольше секунды» - даже когда
это обычная очередь, а не deadlock.
Это полезно держать включённым: в логе оседают и реальные тупики,
и просто медленные ожидания, по которым видно проблемные места
ещё до того, как они станут циклом. В учебном образе
log_lock_waits уже включён, поэтому в выводе сервера будут видны
обе категории.
Уроки в sandbox
lab-22.1. Воспроизвести deadlock и прочитать его в логе
Соберём классический цикл из двух транзакций. Перед последним
шагом предскажи, какая из них станет жертвой и что покажет
счётчик pg_stat_database.deadlocks.
Сессия A:
BEGIN; UPDATE bookings SET total = total - 10 WHERE book_ref = '000001';. Строка 1 теперь за A.Сессия B:
BEGIN; UPDATE bookings SET total = total - 10 WHERE book_ref = '000002';. Строка 2 теперь за B.Сессия A:
UPDATE bookings SET total = total + 10 WHERE book_ref = '000002';. Команда повиснет - A ждёт строку 2.Сессия B:
UPDATE bookings SET total = total + 10 WHERE book_ref = '000001';. Через ~1 секунду (deadlock_timeout) одна из транзакций получитERROR: deadlock detected.Прочитай лог сервера и найди блок
DETAILс описанием цикла иCONTEXTс номером кортежа. Сопоставь PID в сообщении с сессиями A и B.Проверь счётчик:
SELECT deadlocks FROM pg_stat_database WHERE datname = current_database();- он должен быть больше нуля. Закоммить выжившую транзакцию.
sandbox с автопроверкой - открыть в песочнице
Резюме
- Взаимоблокировка - цикл в графе ожидания: каждая транзакция ждёт ту, что ждёт её. Сама по себе она не разрешается.
- PostgreSQL не ищет цикл сразу: он ждёт deadlock_timeout (по умолчанию 1 с), и только потом запускает детектор. Так нормальные очереди не тратят анализ.
- Найдя цикл, детектор откатывает одну транзакцию с ошибкой 40P01 `deadlock detected`; вторая продолжает. Откатывается вся транзакция-жертва, а не один запрос.
- Лог сервера описывает цикл в DETAIL (кто кого ждёт по PID) и называет конкретный кортеж в CONTEXT - по этому почти всегда видно несогласованный порядок блокировок.
- Цикл не требует двух явных UPDATE: его дают один UPDATE на несколько строк в разном порядке обхода и перекрёстные вставки по внешнему ключу.
- Главная профилактика - единый порядок блокировки строк (например, по возрастанию ключа). Тогда граф ожидания ацикличен и deadlock невозможен.
- Deadlock штатен при конкуренции: приложение обязано ловить 40P01 и повторять транзакцию. Рост pg_stat_database.deadlocks - сигнал о несогласованном порядке.
Контрольные вопросы
Почему между возникновением deadlock и его разрывом проходит около секунды?
Показать ответ
Потому что PostgreSQL не строит граф ожидания на каждую блокировку - это дорого, а почти все ожидания разрешаются сами. Когда транзакция встаёт в очередь, взводится таймер
deadlock_timeout(по умолчанию 1 с). Только если за это время блокировку так и не выдали, запускается детектор циклов. Поэтому реальный тупик живёт примерноdeadlock_timeout, прежде чем сервер его заметит и разорвёт.Можно ли поставить `deadlock_timeout` в 10 мс, чтобы тупики ловились мгновенно?
Показать ответ
Технически да, но это вредно. Детектор будет запускаться на каждой очереди, которая задержалась дольше 10 мс, хотя в подавляющем большинстве случаев это не deadlock, а нормальное ожидание, которое вот-вот разрешится. Анализ графа на каждый чих съест процессор. Цель
deadlock_timeout- отсечь короткие штатные ожидания от настоящих тупиков, и секунда для этого обычно хороша.Две транзакции делают `UPDATE` без всякого FOR UPDATE и без второго запроса. Откуда deadlock?
Показать ответ
UPDATEблокирует каждую затронутую строку по мере обхода. Если запрос задевает несколько строк, а две транзакции обходят их в разном физическом порядке (порядок задаёт план, не порядок вIN), одна успеет взять строку X и пойдёт за Y, а другая - наоборот. Получается тот же цикл, что и при двух явных UPDATE наоборот. Поэтому массовые UPDATE стоит упорядочивать по ключу.Транзакция-жертва откатила только последний `UPDATE` или всё?
Показать ответ
Всё. Разрыв deadlock - это полный
ROLLBACKтранзакции-жертвы: откатываются все её изменения с начала транзакции, а соединение получает ошибку 40P01. Поэтому нельзя рассчитывать, что «не прошёл только последний шаг»: после deadlock транзакцию нужно выполнять заново целиком, что и делает правильно написанный ретрай.Какой самый надёжный способ вообще исключить взаимоблокировки на наборе строк?
Показать ответ
Брать строки во всех транзакциях в одном и том же порядке - например, всегда по возрастанию первичного ключа. Тогда граф ожидания не может содержать цикл: если все идут в одном направлении, замкнуться не на чем. На практике это
ORDER BY idвSELECT ... FOR UPDATEили в подзапросе перед массовым UPDATE. Дополняют это короткие транзакции и ретрай на 40P01.