Деплой прошёл гладко, тесты зелёные, на стенде всё летает. А в продакшене под пиковой нагрузкой в журнале начинают мелькать строки про обнаруженную блокировку, и часть запросов пользователей отваливается с ошибкой. Дедлок, или взаимоблокировка, это та неприятность, которая почти никогда не всплывает при разработке и обожает прятаться до момента реальной конкуренции за данные. Особенно коварен он тем, что фреймворки работы с базой умеют скрывать порождающую его циклическую зависимость. Разберём, как поймать дедлок через журналирование ожиданий блокировок и, что важнее, как перестроить транзакции, чтобы он больше не возникал.

Что такое дедлок и почему он не транзиентная случайность

Суть взаимоблокировки проста до неприличия. Две транзакции держат по ресурсу и ждут друг друга по кругу. Транзакция А заблокировала строку с одним идентификатором и хочет вторую. Транзакция Б в это же время заблокировала вторую строку и хочет первую. Ни одна не может продвинуться, образовалось кольцо ожидания, из которого нет выхода.

PostgreSQL не оставляет транзакции висеть вечно. Детектор взаимоблокировок периодически проверяет граф ожиданий на наличие циклов и, найдя цикл, выбирает жертву и прерывает её, давая остальным продолжить. Прерванная транзакция получает ошибку с кодом 40P01 и должна быть повторена приложением. Вот как выглядит классическая запись в журнале.

ERROR: deadlock detected
DETAIL: Process 70725 waits for ShareLock on transaction 891717; blocked by process 70713.
        Process 70713 waits for ShareLock on transaction 891718; blocked by process 70725.
HINT: See server log for query details.
CONTEXT: while updating tuple (0,1) in relation "accounts"

Тут важна мысль, которую часто упускают. Дедлок это не транзиентная и не вызванная нагрузкой случайность, которую достаточно переждать. Это симптом плохо структурированной логики. Нагрузка лишь повышает шанс встретить две конфликтующие транзакции одновременно, но сам конфликт заложен в коде, в том, что разные части приложения захватывают одни и те же ресурсы в разном порядке. Поэтому правильное лечение не косметическое, а структурное.

Почему PostgreSQL ждёт, прежде чем вмешаться

Деталь, которая многое объясняет: PostgreSQL не разрывает конфликт мгновенно. При столкновении транзакций он сначала выжидает заданное время и только потом запускает алгоритм обнаружения взаимоблокировки. Управляет этим ожиданием отдельный параметр, по умолчанию равный одной секунде.

-- Посмотреть текущее значение
SHOW deadlock_timeout;

-- Для нагруженной OLTP-системы иногда снижают для более быстрой реакции
SET deadlock_timeout = '500ms';

Зачем вообще ждать? Потому что проверка графа ожиданий стоит ресурсов, а большинство блокировок разрешаются сами собой за доли секунды, когда одна транзакция просто дожидается завершения другой. Гонять дорогую проверку на каждую кратковременную блокировку расточительно. Увеличение этого значения сокращает время, впустую потраченное на ненужные проверки, но замедляет сообщение о настоящих взаимоблокировках. В идеале значение должно превышать типичное время транзакции, чтобы блокировка успела освободиться до того, как ожидающий решит проверять цикл. На тяжело нагруженном сервере его иногда даже поднимают, а для прицельной отладки локально, наоборот, ставят короче.

Как настроить журналирование, чтобы увидеть виновников

Само сообщение об обнаруженной взаимоблокировке появляется в журнале по умолчанию, но для расследования его мало. Хорошая отправная точка это включить журналирование ожиданий блокировок. После этого PostgreSQL начнёт записывать в журнал ситуации, когда транзакция ждёт блокировки дольше порога, заданного тем же таймаутом обнаружения.

# postgresql.conf
log_lock_waits = on
deadlock_timeout = '1s'

При возникновении взаимоблокировки в журнал попадает дополнительная информация о конфликтующих запросах. PostgreSQL даже любезно подсказывает, какая именно строка вызвала конфликт. В контексте сообщения фигурирует физический идентификатор строки в формате номера блока и смещения, и по нему можно понять, за какую конкретно строку шла борьба. Это бесценная зацепка: вместо абстрактного "где-то дедлок" вы получаете точку привязки к данным.

При системном разборе журнал прочёсывают на события обнаруженной взаимоблокировки и код ошибки, вытаскивают графы ожиданий из блоков с деталями и ищут корреляции по времени между типами транзакций. Если взаимоблокировка между оформлением заказа и корректировкой склада возникает только в пиковые часы, это указывает на порог конкурентности, а не просто на один кусок кода.

Как восстановить полную картину блокировок

Тут кроется тонкость, которая сбивает с толку даже опытных инженеров. В сообщении журнала видны не все блокировки, а только те, что замкнули цикл. Реальный разбор требует знать, что транзакции делали до момента взаимоблокировки. В одном показательном случае с фреймворком, блокирующим строки на выборке, инженер недоумевал, откуда дедлок между двумя отдельными строками. Ответ оказался в предыстории: процесс А заранее заблокировал первую строку, процесс Б заблокировал вторую и затем потянулся к первой, а процесс А в это время потянулся ко второй. Четыре действия, из которых в журнале видны не все, но цикл сложился.

Чтобы увидеть всю предысторию, на время отладки включают полное журналирование запросов, воспроизводят проблему, а потом возвращают настройку обратно.

ALTER SYSTEM SET log_statement = 'all';
SELECT pg_reload_conf();

-- воспроизвести взаимоблокировку, затем вернуть как было

ALTER SYSTEM RESET log_statement;
SELECT pg_reload_conf();

После этого в журнале находят идентификаторы процессов, участвовавших в дедлоке, и прослеживают всю цепочку их действий до конфликта. Для разовой ситуации в реальном времени помогают системные представления активности сессий и блокировок, показывающие, кто кого ждёт прямо сейчас.

Главное лекарство это единый порядок захвата блокировок

Истинное, по сути единственное надёжное решение взаимоблокировки это перепроектировать транзакции так, чтобы они захватывали ресурсы в одном и том же порядке. Логика железная: если два параллельных процесса берут одни и те же блокировки в одинаковой последовательности, дедлока не будет, потому что один просто подождёт завершения другого.

Рассмотрим классический антипример с переводом средств между счетами.

-- Транзакция А
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- блокирует строку 1
UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- ждёт строку 2
COMMIT;

-- Транзакция Б, параллельно
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;   -- блокирует строку 2
UPDATE accounts SET balance = balance + 50 WHERE id = 1;   -- ждёт строку 1
COMMIT;

Здесь две транзакции трогают один набор строк, но в обратном порядке, и это прямая дорога к циклу. Исправление состоит в том, чтобы всегда захватывать блокировки в одном порядке, например всегда сначала по меньшему идентификатору. Достаточно перед обработкой отсортировать строки по ключу, и кольцо рассыпается.

-- Обе транзакции трогают строки строго по возрастанию идентификатора
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

Когда строки берутся явной блокировкой на выборке, тот же принцип применяют через сортировку в самом запросе, чтобы блокировки накладывались в предсказуемом порядке.

-- Блокируем сразу пачкой в детерминированном порядке
BEGIN;
SELECT * FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
-- дальше обновления в том же порядке
COMMIT;

Структурное исправление означает найти все пути в коде, которые накладывают блокировки на одни и те же таблицы, и убедиться, что они делают это в согласованном порядке. Это кропотливо, но именно это убирает причину, а не симптом.

Где блокировки прячутся незаметно

Дедлоки коварны тем, что блокировки возникают не только там, где их явно просят. Тяжёлое использование фреймворков работы с базой умеет скрывать циклическую зависимость, порождающую взаимоблокировку, за слоями абстракции. Разработчик пишет вроде бы простой код, а под ним фреймворк накладывает блокировки в порядке, который не виден на поверхности.

Отдельный источник неявных блокировок это внешние ключи и триггеры. Обновление дочерней строки может потребовать блокировки родительской для проверки ссылочной целостности, и если разные транзакции трогают связанные таблицы в разном порядке, дедлок возникает там, где его совсем не ждали. Именно поэтому в реальных кейсах дедлок между двумя, казалось бы, независимыми строками нередко объясняется тем, что под капотом блокируются ещё и связанные родительские записи.

Что делать с прерванной транзакцией и как смягчить ожидание

Раз PostgreSQL гарантированно прерывает одну из транзакций цикла, приложение обязано уметь корректно обрабатывать ошибку взаимоблокировки. Без логики повторов такая ошибка всплывает наверх как сбой, хотя повтор почти наверняка прошёл бы успешно. Стандартный подход это повтор с возрастающей задержкой и разумным верхним пределом числа попыток.

import time, random, psycopg2

MAX_RETRIES = 3
for attempt in range(MAX_RETRIES):
    try:
        # тело транзакции
        connection.commit()
        break
    except psycopg2.errors.DeadlockDetected:
        connection.rollback()
        if attempt == MAX_RETRIES - 1:
            raise
        time.sleep(0.1 * (attempt + 1) + random.uniform(0, 0.1))

Есть и инструменты, чтобы вовсе не доводить до дедлока. Захват строки с указанием немедленного отказа при занятой блокировке позволяет не ждать впустую, а сразу получить ошибку и повторить, не давая циклу сложиться. Похожий приём с пропуском заблокированных строк удобен для очередей, где можно взять следующую свободную запись вместо ожидания занятой.

BEGIN;
SELECT * FROM orders WHERE id = 42 FOR UPDATE NOWAIT;
-- если строка занята: ошибка сразу, приложение повторяет без ожидания
COMMIT;

Какой дисциплины держаться, чтобы дедлоки исчезли

Сводя всё воедино, взаимоблокировки предотвратимы дисциплиной, а не магией. Несколько правил закрывают почти весь риск. Всегда обращаться к таблицам и строкам в согласованном порядке во всех транзакциях, это краеугольный камень. Держать транзакции короткими и быстрыми, фиксируя их как можно раньше, чтобы блокировки жили меньше. Накладывать только необходимые блокировки и применять явную блокировку строк лишь там, где она действительно нужна. Выбирать подходящий уровень изоляции, помня, что более строгие уровни повышают шанс ошибок сериализации. Включить журналирование ожиданий блокировок и периодически просматривать журнал. И обязательно обернуть подверженные дедлокам операции в логику повторов.

Дедлок перестаёт быть страшилкой, когда понимаешь, что он не нападает случайно, а вырастает из несогласованного порядка захвата ресурсов. Журнал ожиданий блокировок показывает, кто с кем столкнулся, идентификатор строки указывает на спорные данные, а единый порядок блокировок и аккуратные короткие транзакции убирают саму возможность цикла. Тот, кто полагается лишь на повторы, борется с симптомом и платит за это сорванными запросами в пик. А тот, кто навёл порядок в захвате блокировок, получает систему, в которой кольцу ожидания просто негде замкнуться.