Как исправить ошибку «deadlock detected» в PostgreSQL

Ошибка deadlock detected в PostgreSQL означает что транзакции не могут закончить своё выполнение из-за того, что заблокировали друг друга.

На уровне базы данных это выглядит так: транзакции ставят блокировки на строки или таблицы. Если порядок получения этих блокировок различается, возникает цикл ожиданий. PostgreSQL отслеживает такие ситуации и, когда понимает, что транзакции больше не смогут продвинуться, принудительно завершает одну из них. В приложении это проявляется как ошибка deadlock detected.

На практике тупиковые блокировки чаще всего возникают в нескольких случаях:

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

Самая частая причина — именно разный порядок обновления данных. Возьмём для примера интернет-магазин.

Клиент 1 положил в корзину два ноутбука и одну мышь и одну клавиатуру.

Клиент 2 положил в корзину одну клавиатуру и одну мышь.

По стечению обстоятельств они почти одновременно нажмут оформить заказ. Может произойти следующее:

Стартует транзакция первого клиента:

BEGIN;
SELECT * FROM good_stock gs
    INNER JOIN goods g on g.id = gs.id_good
WHERE g.name = 'ноутбук' FOR UPDATE;
UPDATE good_stock gs
    SET count = count -1
FROM goods g
WHERE g.id = gs.id_good AND g.name = 'ноутбук';

SELECT * FROM good_stock gs
    INNER JOIN goods g on g.id = gs.id_good
WHERE g.name = 'мышь' FOR UPDATE;
UPDATE good_stock gs
    SET count = count -1
FROM goods g
WHERE g.id = gs.id_good AND g.name = 'мышь';

SELECT * FROM good_stock gs
    INNER JOIN goods g on g.id = gs.id_good
WHERE g.name = 'клавиатура' FOR UPDATE;
UPDATE good_stock gs
    SET count = count -1
FROM goods g
WHERE g.id = gs.id_good AND g.name = 'клавиатура';

INSERT INTO orders (id_customer, goods, address)
VALUES(1, {'ноутбук', 'мышь', 'клавиатура'}), 'адрес 1 клиента';
COMMIT;

Стартует транзакция второго клиента:

BEGIN;
SELECT * FROM good_stock gs
    INNER JOIN goods g on g.id = gs.id_good
WHERE g.name = 'клавиатура' FOR UPDATE;
UPDATE good_stock gs
    SET count = count -1
FROM goods g
WHERE g.id = gs.id_good AND g.name = 'клавиатура';

SELECT * FROM good_stock gs
    INNER JOIN goods g on g.id = gs.id_good
WHERE g.name = 'мышь' FOR UPDATE;
UPDATE good_stock gs
    SET count = count -1
FROM goods g
WHERE g.id = gs.id_good AND g.name = 'мышь';

INSERT INTO orders (id_customer, goods, address)
VALUES(2, {'клавиатура', 'мышь'}), 'адрес 2 клиента';
COMMIT;

Мы получим что первая транзакция сделала select for update для остатков 'клавиатуры' и дальше ей нужно заблокировать (select for update) остатки для товара 'мышь'. Но она не может это сделать т.к. вторая транзакция уже заблокировала остатки для товара 'мышь' и теперь ей нужно заблокировать остатки для товара 'клавиатура', но она не может это сделать т.к. эти остатки заблокированы первой транзакцией.

Таким образом, чтобы завершиться, первой транзакции нужно дождаться завершения второй транзакции, а второй транзакции нужно дождаться завершения первой - это и есть deadlock. PostgreSQL обнаруживает этот цикл ожиданий и отменяет одну из транзакций.

В большинстве случаев для решения проблемы достаточно просто повторить транзакцию. PostgreSQL отменяет только одну из них, вторая продолжает выполняться. Нужно чтобы приложение умело в случае такой шибки сделать retry.

Но, если deadlock повторяется регулярно, то стоит:

  1. Посмотреть логи базы.

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

  2. Проверить длительность транзакций.

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

  3. Посмотреть порядок работы с данными.

    Очень часто проблема решается если все транзакции будут брать ресурсы в одинаковой последовательности.

Полностью исключить deadlok-и в системе с высокой нагрузкой невозможно, но можно сократить количество таких ситуаций:

Держите транзакции короткими.

Чем меньше времени транзакция существует, тем меньше шанс, что кто-то упрётся в её блокировки. Часто помогает банальная оптимизация запросов или перенос лишней логики из транзакции наружу.

Используйте единый порядок обновления данных.

Если код в разных местах обновляет одни и те же строки, важно делать это одинаково. Например, можно всегда работать сначала с меньшим id, потом с большим. Это простое правило резко снижает вероятность циклических блокировок.

Не усложняйте уровень изоляции без необходимости.

В PostgreSQL по умолчанию используется READ COMMITTED, и для большинства приложений этого достаточно. Переход на SERIALIZABLE даёт более строгую модель согласованности, но количество конфликтов и откатов при этом возрастает.

Аккуратнее с явными блокировками.

Команды вроде LOCK TABLE иногда нужны, но их легко превратить в источник проблем. Они держатся до конца транзакции и могут заблокировать большое количество запросов. Если есть возможность, лучше ограничиться строковыми блокировками (SELECT ... FOR UPDATE).

Следите за параметром deadlock_timeout.

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

Важно отметить: deadlock detected — это не столько ошибка базы, сколько сигнал о конфликте в логике работы с данными. В подавляющем большинстве случаев корень проблемы — разные транзакции берут одни и те же ресурсы в разном порядке. Когда порядок становится единым, такие ситуации почти исчезают.