Как рождается дедлок и по какому принципу база выбирает, кого убить
Приложение падает с ошибкой deadlock detected, хотя ни один запрос по отдельности не завис — просто одна из двух параллельных транзакций внезапно получила отказ и откатилась. Первая реакция — искать баг в коде: где-то забыли COMMIT, где-то бесконечный цикл. На деле это не баг, а штатная работа базы: она обнаружила, что две транзакции никогда не смогут закончиться сами, и принудительно освободила одну из них. Разберём, как возникает такой тупик, как СУБД его ловит и почему один из участников обязан быть принесён в жертву.
Содержание
Циклическое ожидание: откуда берётся дедлок
Дедлок — это не зависание и не медленный запрос. Это ситуация циклического ожидания: транзакция A держит блокировку на ресурсе X и хочет получить ресурс Y; в это же время транзакция B держит Y и хочет получить X. Обе ждут друг друга, и обе правы в том, что не отпускают то, что уже держат — до конца транзакции блокировки не снимаются, это часть контракта ACID. Если никто не вмешается, обе транзакции будут ждать вечно: ни одна из них физически не может получить недостающий ресурс, потому что он занят той самой транзакцией, которая ждёт её же ресурс.
Ключевое отличие от обычной блокировки: при обычном ожидании достаточно подождать — держатель блокировки рано или поздно закоммитится или откатится, и ресурс освободится. При дедлоке ждать бессмысленно: цикл замкнут, естественного выхода нет. Единственный способ разорвать его — извне прервать одного из участников и отдать его ресурс остальным.
Классический пример на двух счетах:
-- Сессия 1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- держит блокировку строки id=1
-- Сессия 2 (параллельно)
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
-- держит блокировку строки id=2
-- Сессия 1 продолжает
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- ждёт: строка id=2 занята сессией 2
-- Сессия 2 продолжает
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
-- ждёт: строка id=1 занята сессией 1 -- цикл замкнулся
Обе транзакции легитимны по отдельности — это классический перевод денег между счетами. Проблема не в логике каждой из них, а в том, что они трогают одни и те же две строки в разном порядке. Приведите порядок обращения к строкам в обеих сессиях к одному и тому же — и дедлок в этом сценарии исчезнет. Но это работает, только пока вы контролируете порядок обращений во всём приложении, а не в одном месте кода.
Как транзакция берёт и держит блокировку
Чтобы понять, откуда берётся цикл, полезно вспомнить, что именно блокирует СУБД. При UPDATE, DELETE и SELECT ... FOR UPDATE база берёт блокировку строки (row-level lock) на время транзакции — не на время выполнения одного запроса, а именно до COMMIT или ROLLBACK. Это касается PostgreSQL, MySQL с InnoDB и большинства других реляционных СУБД: пока транзакция открыта, она держит всё, что успела заблокировать, сколько бы запросов внутри неё ни выполнялось дальше.
Блокировки бывают разного масштаба: строка, диапазон (gap lock в InnoDB, нужен для защиты от фантомных вставок), таблица целиком (например, при ALTER TABLE). Чем крупнее заблокированный объект, тем больше транзакций одновременно могут в него упереться и тем выше вероятность цикла. Миграция, блокирующая всю таблицу на добавление колонки, — частый источник дедлоков в проде именно поэтому: она конкурирует не с одной строкой, а сразу со всеми, кто таблицу трогает.
Важный нюанс: обычный SELECT без FOR UPDATE в PostgreSQL благодаря MVCC блокировок вообще не берёт — он читает снапшот и не мешает записи (подробнее — в статье про MVCC и версии строк). Дедлоки возникают там, где транзакции реально меняют или явно блокируют одни и те же строки: UPDATE, DELETE, INSERT с уникальными ограничениями, SELECT ... FOR UPDATE, а также блокировки, связанные с внешними ключами — вставка дочерней записи может требовать разделяемую блокировку родительской строки, и об этом легко забыть при проектировании схемы.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверГраф ожиданий: как СУБД ловит цикл, который сам не разрешится
СУБД не умеет заранее предсказать, что транзакция приведёт к дедлоку — она узнаёт об этом только когда цикл ожидания уже фактически образовался. Внутренний механизм у большинства баз общий по идее, хотя детали реализации различаются: каждая транзакция, ожидающая освобождения ресурса, добавляет ребро в граф ожиданий (wait-for graph) — от себя к транзакции, которая держит нужный ей ресурс. Пока граф остаётся деревом или произвольным ациклическим набором рёбер, ничего страшного не происходит: кто-то просто ждёт своей очереди, и рано или поздно дождётся.
Дедлок с точки зрения графа — это появление цикла: A ждёт B, B ждёт C, C ждёт A. Обнаружить цикл — задача обхода графа (по сути поиск в глубину от вершины, которая только что встала в очередь ожидания): если, следуя по рёбрам «кто кого ждёт», можно вернуться в исходную вершину, значит цикл есть и сам он не разрешится никогда.
Здесь СУБД расходятся в реализации:
- PostgreSQL не проверяет граф ожиданий постоянно — это было бы дорого при высокой конкурентности. Вместо этого, когда транзакция встаёт в очередь ожидания блокировки, она сначала просто ждёт
deadlock_timeout(по умолчанию 1 секунда). Если за это время блокировка не освободилась, процесс запускает полную проверку графа ожиданий на цикл. Если цикла нет — блокировка была просто занята дольше обычного, ожидание продолжается на общих основаниях. Если цикл найден — база сразу же завершает работу с ошибкойdeadlock detectedдля процесса, который проводил проверку. - MySQL/InnoDB устроен иначе: у него нет отдельного таймера-детектора. Граф ожиданий проверяется практически сразу, в момент, когда новый запрос на блокировку создал бы цикл — InnoDB активно строит граф при каждой попытке заблокировать ресурс, который уже занят. Поэтому дедлоки в InnoDB обычно обнаруживаются намного быстрее, чем истекает
innodb_lock_wait_timeout(который вообще про другое — про обычное долгое ожидание без цикла, а не про дедлок).
Важное следствие: deadlock_timeout в PostgreSQL — это не задержка перед откатом «на всякий случай», а стоимость самой проверки графа. Гонять полный обход графа ожиданий на каждую попытку блокировки было бы слишком дорого при тысячах активных сессий, поэтому Postgres откладывает эту проверку и запускает её только тогда, когда обычное ожидание уже выглядит подозрительно долгим.
Кого база выбирает жертвой и почему
Когда цикл найден, кто-то из его участников должен быть принудительно откачен — иначе цикл останется замкнутым навсегда. Здесь у разных СУБД разная философия, и честно сказать: она не всегда «умная» в том смысле, в каком хотелось бы.
В PostgreSQL жертвой почти всегда становится процесс, который непосредственно обнаружил цикл — тот, чьё ожидание последним истекло по deadlock_timeout и запустило проверку графа. Postgres не оценивает заранее, у какой транзакции «дешевле» откат по объёму проделанной работы — он смотрит на топологию графа, и если единственный выход — чья-то жертва, ей становится обнаруживший цикл процесс. Это следствие момента обнаружения, а не осознанный расчёт стоимости.
В MySQL/InnoDB подход декларативно ближе к идее «дешевле откатить меньшую работу»: документация InnoDB описывает, что при выборе жертвы движок ориентируется на объём уже выполненной работы, в первую очередь на число изменённых строк, и стремится откатить транзакцию, которая изменила меньше строк. Это эвристика, а не жёсткая гарантия — в пограничных случаях InnoDB может выбрать иначе.
Вывод, который стоит унести из обеих реализаций: нельзя полагаться на то, что дедлок убьёт именно «неважную» транзакцию. Ни один движок не гарантирует, что в жертву выберут ту, которую вам удобнее потерять — проигравшей может оказаться как раз та, что почти дошла до COMMIT. Единственный надёжный способ не потерять данные — писать код так, чтобы откат любой из конкурирующих транзакций был безопасен и требовал только повтора.
Как дедлок выглядит в логах PostgreSQL и MySQL
В PostgreSQL проигравшая транзакция получает SQLSTATE 40P01 и сообщение вида:
ERROR: deadlock detected
DETAIL: Process 18624 waits for ShareLock on transaction 741; blocked by process 18625.
Process 18625 waits for ShareLock on transaction 740; blocked by process 18624.
HINT: See server log for query details.
CONTEXT: while updating tuple (0,3) in relation "accounts"
Секция DETAIL — это, по сути, распечатанный кусок графа ожиданий на момент обнаружения цикла: видно, кто кого ждёт и по какому виду блокировки. Если включено логирование операторов, в серверном логе рядом появятся и сами запросы, которые привели к конфликту — это основной источник для разбора постфактум, потому что к моменту, когда ошибка дошла до приложения, обе сессии уже, скорее всего, закрыты.
В MySQL последний обнаруженный дедлок смотрят через SHOW ENGINE INNODB STATUS\G. В выводе есть секция LATEST DETECTED DEADLOCK с описанием обеих транзакций-участниц и явной пометкой WE ROLL BACK TRANSACTION рядом с той, что стала жертвой. Приложение при этом получает ошибку 1213 (ER_LOCK_DEADLOCK) с текстом Deadlock found when trying to get lock; try restarting transaction — последняя часть сообщения не декоративная, это прямая инструкция, как обрабатывать ошибку.
Полезно заранее видеть, кто кого блокирует, ещё до того как процесс упрётся в таймаут. В PostgreSQL для этого есть представление pg_locks в связке с pg_stat_activity — запрос, сопоставляющий незагруженные (NOT granted) блокировки с теми же ресурсами, уже занятыми (granted) другими сессиями, покажет, кто конкретно кого держит. Это не заменяет встроенный детектор дедлоков, но помогает увидеть цепочку ожиданий в реальном времени — например, когда транзакция просто долго висит, а не циклически заблокирована, и разбираться нужно совсем в другую сторону (см. отдельный разбор долгих блокировок — кто кого ждёт в базе).
Как писать код, готовый к дедлоку
Из механизма выше следует практический вывод, который часто недооценивают: дедлок — это ожидаемая штатная ситуация, а не авария, и приложение обязано уметь её пережить. Откат по дедлоку полностью безопасен для данных — проигравшая транзакция откатывается целиком, ни одно из её изменений не применяется, база остаётся в согласованном состоянии. Опасность не в самом дедлоке, а в том, что приложение получает ошибку и падает или молча теряет операцию пользователя, вместо того чтобы повторить попытку.
Минимальный набор практик:
- Ловите конкретный код ошибки и повторяйте транзакцию. В PostgreSQL это SQLSTATE
40P01, в MySQL — код1213. Оборачивайте бизнес-транзакцию в цикл повтора с небольшим количеством попыток (обычно достаточно 3-5) и small backoff между ними, чтобы не воспроизводить конфликт немедленно ещё раз теми же двумя процессами. - Держите транзакции короткими. Чем дольше транзакция держит блокировки, тем выше шанс, что кто-то другой успеет встать в очередь за теми же ресурсами в другом порядке. Не делайте сетевых вызовов, ожидания пользовательского ввода или тяжёлых вычислений внутри открытой транзакции.
- Соблюдайте единый порядок обращения к ресурсам. Если в одном месте кода вы обновляете строки в порядке возрастания
id, а в другом — в порядке, в котором они пришли в запросе, вы рано или поздно получите классический цикл A→B→A. Сортировка ключей перед сериейUPDATEв пределах одной транзакции — дешёвый и надёжный способ снизить (не убрать полностью) частоту дедлоков. - Не полагайтесь на то, что «выживет нужная» транзакция. Как показано выше, ни один из движков не гарантирует, что в жертву выберут менее важную для вас операцию. Код должен одинаково корректно обрабатывать откат любой из конкурирующих сторон.
- Проверяйте идемпотентность повтора. Если транзакция отправляет письмо, дёргает внешний API или пишет в очередь до
COMMITв базе — при повторе после дедлока эти побочные эффекты могут задвоиться. Побочные эффекты, зависящие от результата транзакции, стоит выносить за её пределы и выполнять только после успешногоCOMMIT.
Пример простого повтора на уровне приложения (псевдокод, независимый от языка):
for attempt in 1..5:
try:
begin_transaction()
... бизнес-логика ...
commit()
break
except DeadlockError:
rollback()
if attempt == 5:
raise
sleep(random_backoff(attempt))
Такой цикл — это не костыль, а стандартная практика для любой системы с конкурентным доступом к общим строкам: дедлоки не исчезнут полностью даже при идеальном порядке блокировок, потому что порядок часто определяется данными запроса (WHERE id IN (...) со списком, пришедшим от пользователя), а не только кодом.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Дедлок — это баг в приложении или нормальная ситуация?
Сам по себе единичный дедлок — нормальная ситуация при конкурентном доступе к одним и тем же строкам, а не обязательно баг. Тревожный сигнал — это высокая частота дедлоков на одном и том же наборе запросов: значит, порядок обращения к ресурсам в коде действительно рассогласован, и стоит найти конкретные транзакции, участвующие в цикле, по логам.
Может ли дедлок привести к потере или порче данных?
Нет. Проигравшая транзакция откатывается целиком СУБД, её изменения не применяются, и база остаётся в согласованном состоянии. Риск потери данных возникает не из-за дедлока, а из-за того, что приложение не обработало ошибку и не повторило операцию.
Чем дедлок отличается от обычного долгого ожидания блокировки?
Обычное ожидание рано или поздно закончится само — держатель блокировки закоммитится или откатится, и ждущая транзакция получит ресурс. Дедлок — это цикл, который сам не разрешится никогда: если не вмешаться, обе (или больше) транзакции будут ждать друг друга бесконечно.
Можно ли полностью избавиться от дедлоков в приложении?
Снизить частоту — да: короткие транзакции, единый порядок блокировки ресурсов, минимум явных FOR UPDATE без необходимости. Гарантированно исключить — обычно нет, особенно если порядок операций внутри транзакции зависит от пользовательского ввода. Поэтому обработка ошибки дедлока с повтором — это не опциональная доработка, а обязательная часть кода, работающего с конкурентным доступом.
Почему увеличение deadlock_timeout в PostgreSQL не решает проблему?
Этот параметр управляет только тем, как быстро СУБД запускает проверку графа ожиданий, а не тем, возникает ли цикл. Увеличение таймаута лишь отложит момент обнаружения уже существующего дедлока — обе транзакции всё это время будут просто висеть, занимая соединения и блокировки, прежде чем одна из них всё равно будет откачена.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →