Deadlock виникає, коли транзакція A тримає блокування, потрібне B, а B тримає блокування, потрібне A: у графі очікувань зʼявляється цикл, який не розірветься сам ніколи. Тому бази не чекають, а виявляють його. InnoDB перевіряє граф щоразу, коли транзакція стає в чергу за блокуванням (innodb_deadlock_detect увімкнено за замовчуванням), і відкочує жертву — транзакцію, яка змінила найменше рядків, тобто найдешевшу для скасування. PostgreSQL робить це ліниво: сесія, яка прочекала довше за deadlock_timeout (1 секунда), запускає перевірку, і якщо цикл знайдено, саме вона отримує помилку 40P01 deadlock detected із DETAIL про те, який процес на що чекав.
Це принципово інша подія, ніж lock wait timeout, і плутанина тут коштує найдорожче. Lock wait timeout exceeded (MySQL 1205) означає лінійне очікування без циклу, яке впʼялося в innodb_lock_wait_timeout — 50 секунд за замовчуванням; при дефолтному innodb_rollback_on_timeout=OFF відкочується лише останній оператор, і транзакція лишається відкритою з половиною змін, тож застосунок мусить явно зробити ROLLBACK. Deadlock натомість відкочує транзакцію-жертву повністю: повторювати треба весь блок від BEGIN, а не оператор, що впав. Звідси вимога до архітектури — блок транзакції має бути ідемпотентним, а листи, платіжні виклики та HTTP до зовнішніх систем живуть за межами COMMIT.
Друга поширена хиба — вважати, що без явного FOR UPDATE deadlock неможливий. Блокування бере кожен UPDATE, DELETE та INSERT, і причини найчастіше буденні: два сценарії оновлюють ті самі рядки в різному порядку; пакетний upsert з несортованим набором ключів; INSERT, який на конфлікті унікального індексу спершу бере shared-лок на існуючому записі; вставка в дочірню таблицю, що тримає лок на батьківському рядку за зовнішнім ключем. Окремо варто памʼятати, що блокування беруться по індексних записах: UPDATE ... WHERE status = 'pending' без індексу на status заблокує все, що просканував, а в MySQL на REPEATABLE READ ще й проміжки між записами через gap- і next-key-локи.
Діагностика зводиться до трьох кроків. У MySQL: SHOW ENGINE INNODB STATUS показує секцію LATEST DETECTED DEADLOCK з обома операторами, іменами індексів і режимами блокувань — але лише останній випадок, тому на проді вмикають innodb_print_all_deadlocks, і всі події падають в error log; поточні очікування видно в performance_schema.data_lock_waits і data_locks. У PostgreSQL текст помилки вже містить обидва процеси, а живу картину дають pg_blocking_pids() разом із pg_stat_activity, плюс log_lock_waits для очікувань довших за deadlock_timeout. Мета читання — знайти два місця в коді, які беруть ті самі обʼєкти в різному порядку.
Профілактика працює в такому порядку. Єдиний порядок блокування (найпростіше — завжди за зростанням первинного ключа: ORDER BY id ... FOR UPDATE, сортований і дедуплікований масив у пакетних операціях). Коротші транзакції: усе, що можна порахувати до BEGIN, рахується до нього, мережеві виклики виносяться назовні. Індекси під умови UPDATE, щоб площа блокування збігалася з набором рядків, які справді змінюються. Там, де вистачає одного оператора, блокування не потрібне взагалі — UPDATE ... WHERE balance >= :amount із перевіркою affected rows атомарний за визначенням, а для черг є FOR UPDATE SKIP LOCKED. І межа чесності: повністю прибрати deadlock-и в конкурентному застосунку неможливо, тому ретрай із невеликим backoff лишається обовʼязковим — правильна ціль не «нуль deadlock-ів», а рідкісні й непомітні для користувача.
-- Класичний цикл: сесії беруть ті самі рядки у зворотному порядку
-- Сесія A | Сесія B
BEGIN; -- | BEGIN;
UPDATE accounts SET balance = balance - 10
WHERE id = 1; -- X-лок на рядку 1 -- | UPDATE ... WHERE id = 2; -- X-лок на 2
UPDATE accounts SET balance = balance + 10
WHERE id = 2; -- чекає на B -- | UPDATE ... WHERE id = 1; -- чекає на A
-- MySQL: ERROR 1213 (40001) Deadlock found; вся транзакція-жертва відкочена
-- PostgreSQL: ERROR 40P01 deadlock detected (детектор спрацював за deadlock_timeout)
-- Виправлення: єдиний порядок блокування в усіх сценаріях
BEGIN;
SELECT id FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE; -- завжди за зростанням
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
UPDATE accounts SET balance = balance + 10 WHERE id = 2;
COMMIT;
-- Діагностика MySQL 8.0
SHOW ENGINE INNODB STATUS; -- секція LATEST DETECTED DEADLOCK, лише останній
SET GLOBAL innodb_print_all_deadlocks = ON; -- усі випадки — в error log
SELECT * FROM performance_schema.data_lock_waits; -- хто кого чекає прямо зараз
-- Діагностика PostgreSQL
SELECT pid, pg_blocking_pids(pid) AS blocked_by, wait_event_type, left(query, 60)
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;
-- ALTER SYSTEM SET log_lock_waits = on; -- очікування довші за deadlock_timeout (1 с) — у лог
-- Часто блокувань узагалі не треба: один атомарний оператор замість read-modify-write
UPDATE accounts SET balance = balance - 10 WHERE id = 1 AND balance >= 10;
Скажіть, що deadlock — штатна подія розподіленого доступу, а не аварія: правильна архітектура це «повторюваний блок транзакції + єдиний порядок блокування». І одразу назвіть різницю 1213 і 1205 — вона показує, що ви бачили ці помилки в проді, а не читали про них.