Модуль: Базы данных · Уровень: Senior
TL;DR#
- Deadlock — циклическое ожидание блокировок: транзакция A держит ресурс 1 и ждёт ресурс 2, транзакция B держит ресурс 2 и ждёт ресурс 1. Никто не может продвинуться.
- PostgreSQL автоматически обнаруживает deadlock через
deadlock detector, который запускается, если транзакция ждёт блокировку дольшеdeadlock_timeout(по умолчанию 1 секунда). - Детектор выбирает одну транзакцию-жертву (victim), откатывает её и возвращает ошибку SQLSTATE
40P01(deadlock_detected). Остальные продолжают работу. - Главная причина — разный порядок захвата блокировок в разных транзакциях. Главная профилактика — единый порядок (например, всегда блокировать строки
ORDER BY id). - Инструменты: единый порядок блокировок, короткие транзакции,
SELECT ... FOR UPDATEсORDER BY,lock_timeout,NOWAIT,SKIP LOCKED. - Диагностика:
pg_locks(кто что держит и кто чего ждёт),pg_stat_activity(что выполняет бэкенд,wait_event_type = 'Lock'), а также сообщение в логе PostgreSQL с деталями цикла. - В приложении на Go deadlock — ожидаемая ситуация при конкуренции; правильная реакция — детектировать
40P01и повторить транзакцию (retry с backoff), а не падать.
Теория#
Что такое deadlock и почему он возникает#
Deadlock (взаимоблокировка) — ситуация, когда две или более транзакций образуют цикл ожидания: каждая держит блокировку, которая нужна другой, и ни одна не может завершиться. Это фундаментальное свойство любой системы с блокировками, а не баг PostgreSQL.
Классический сценарий — разный порядок захвата:
T1: UPDATE accounts SET ... WHERE id = 1; -- захватил ROW EXCLUSIVE на строке 1
T2: UPDATE accounts SET ... WHERE id = 2; -- захватил блокировку на строке 2
T1: UPDATE accounts SET ... WHERE id = 2; -- ждёт строку 2 (её держит T2)
T2: UPDATE accounts SET ... WHERE id = 1; -- ждёт строку 1 (её держит T1) -> циклОбразовался цикл T1 -> T2 -> T1. Один из них будет убит детектором.
Важно: deadlock — это не таймаут. Это именно обнаруженный цикл. Бесконечное ожидание одной блокировки без цикла (lock contention) — это не deadlock, и детектор его не тронет (его убивает только lock_timeout, если задан).
Типичные причины#
| Причина | Описание |
|---|---|
| Разный порядок захвата строк | Самая частая. Две транзакции трогают одни и те же строки в обратном порядке. |
| Эскалация SHARE -> EXCLUSIVE | Две транзакции читают строку с FOR SHARE/FOR KEY SHARE, затем обе пытаются обновить. Каждая ждёт, пока другая отпустит share-lock. |
| Внешние ключи (FK) | INSERT/UPDATE дочерней строки берёт FOR KEY SHARE на родителе. При конкурентных вставках в обратном порядке может возникнуть deadlock. |
| Индексы / unique constraints | Конкурентные INSERT с одинаковым уникальным ключом ждут друг друга на B-tree странице. |
| Триггеры / каскады | Скрытые блокировки внутри триггеров меняют фактический порядок захвата. |
| Долгие транзакции | Чем дольше держится блокировка, тем выше шанс пересечения с другой транзакцией. |
Обнаружение deadlock#
PostgreSQL не проверяет циклы постоянно — это дорого. Вместо этого:
- Транзакция запрашивает блокировку, которая занята, и встаёт в очередь ожидания.
- Заводится таймер на
deadlock_timeout(по умолчанию1s). - Если за это время блокировка не получена, запускается deadlock detector — он строит граф ожидания (wait-for graph) и ищет цикл.
- Если цикла нет — транзакция продолжает ждать (обычное contention).
- Если цикл есть — выбирается жертва, её транзакция откатывается с ошибкой
40P01.
-- Текущее значение
SHOW deadlock_timeout; -- 1s по умолчанию
-- Не делайте его слишком маленьким: на загруженной БД детектор
-- будет запускаться слишком часто и жечь CPU. 1s — разумный дефолт.Логика выбора жертвы. PostgreSQL не гарантирует строгих правил, но на практике жертвой становится транзакция, отмена которой разрывает цикл (та, чей запрос как раз обнаружил deadlock — то есть «последняя» в цикле инициировавшая проверку). Полагаться на конкретную транзакцию как на жертву нельзя — приложение должно быть готово к откату любой из них.
Сообщение в логе PostgreSQL даёт полную картину:
ERROR: deadlock detected
DETAIL: Process 1234 waits for ShareLock on transaction 5678; blocked by process 5679.
Process 5679 waits for ShareLock on transaction 5680; blocked by process 1234.
Process 1234: UPDATE accounts SET balance = balance - 100 WHERE id = 2;
Process 5679: UPDATE accounts SET balance = balance - 100 WHERE id = 1;
HINT: See server log for query details.
CONTEXT: while updating tuple (0,5) in relation "accounts"Это лучший источник для разбора: видны оба процесса, оба запроса и конкретные tuple.
Блокировки на уровне таблицы (table-level lock modes)#
PostgreSQL имеет 8 режимов блокировок таблицы. Конфликты определяются матрицей совместимости. Большинство команд берут их автоматически.
| Режим | Кто берёт (типично) | Конфликтует с |
|---|---|---|
ACCESS SHARE | SELECT | только с ACCESS EXCLUSIVE |
ROW SHARE | SELECT ... FOR UPDATE/SHARE | EXCLUSIVE, ACCESS EXCLUSIVE |
ROW EXCLUSIVE | INSERT, UPDATE, DELETE | SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE |
SHARE UPDATE EXCLUSIVE | VACUUM (не FULL), ANALYZE, CREATE INDEX CONCURRENTLY | сам с собой и более сильными |
SHARE | CREATE INDEX (не CONCURRENTLY) | ROW EXCLUSIVE и сильнее |
SHARE ROW EXCLUSIVE | CREATE TRIGGER, некоторые ALTER TABLE | почти со всеми |
EXCLUSIVE | REFRESH MATERIALIZED VIEW CONCURRENTLY | всё, кроме ACCESS SHARE |
ACCESS EXCLUSIVE | DROP TABLE, TRUNCATE, VACUUM FULL, большинство ALTER TABLE, LOCK TABLE по умолчанию | со всеми, включая SELECT |
Ключевые наблюдения для senior:
- Обычные
SELECTиINSERT/UPDATE/DELETEне конфликтуют между собой на уровне таблицы (ACCESS SHAREиROW EXCLUSIVEсовместимы). Конкуренция за данные идёт на уровне строк. ACCESS EXCLUSIVEблокирует абсолютно всё, включая чтения. Поэтому миграции (ALTER TABLE) на проде опасны — длинная миграция держит таблицу, и за ней выстраивается очередь даже изSELECT. Отсюда требованиеlock_timeoutдля миграций.
-- Явная блокировка таблицы (редко нужна; режим по умолчанию — ACCESS EXCLUSIVE)
LOCK TABLE accounts IN SHARE ROW EXCLUSIVE MODE;Блокировки на уровне строк (row-level locks)#
Запрашиваются через SELECT ... FOR .... Матрица их конфликтов:
| Режим | Назначение | Конфликтует с |
|---|---|---|
FOR KEY SHARE | слабейшая; берётся FK при ссылке на строку | только с FOR UPDATE |
FOR SHARE | разделяемая блокировка строки | FOR UPDATE, FOR NO KEY UPDATE |
FOR NO KEY UPDATE | как FOR UPDATE, но не трогает ключевые столбцы; берётся обычным UPDATE без изменения PK/unique | FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE |
FOR UPDATE | сильнейшая; полная блокировка строки на изменение/удаление | со всеми четырьмя |
Совместимость (X — конфликт):
| KEY SHARE | SHARE | NO KEY UPDATE | UPDATE | |
|---|---|---|---|---|
| KEY SHARE | X | |||
| SHARE | X | X | ||
| NO KEY UPDATE | X | X | X | |
| UPDATE | X | X | X | X |
Почему это важно: разделение FOR UPDATE и FOR NO KEY UPDATE (появилось в 9.3) уменьшает конфликты с FK. Раньше любой UPDATE блокировал вставки в дочерние таблицы. Теперь обычный UPDATE берёт FOR NO KEY UPDATE, а FK-проверка — FOR KEY SHARE, и они совместимы — меньше deadlock’ов на FK.
Важная семантика блокировок строк: даже при обычном UPDATE row-lock держится до конца транзакции и виден в pg_locks как tuple lock + xid-блокировка через pg_xact.
SELECT … FOR UPDATE подробно#
SELECT ... FOR UPDATE блокирует выбранные строки так, будто их собираются обновить. Другие транзакции, пытающиеся UPDATE/DELETE/SELECT FOR UPDATE эти же строки, будут ждать до конца текущей транзакции (или коммита/роллбэка).
BEGIN;
-- Заблокировали строку; другие FOR UPDATE/UPDATE по этой строке ждут
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- ... читаем, считаем, валидируем ...
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;Что блокирует FOR UPDATE:
- Блокирует строки, а не таблицу (на уровне таблицы берёт лишь
ROW SHARE). - Блокирует только строки, реально вернувшиеся запросом (после применения
WHERE,JOIN). - Не блокирует чистые
SELECT(читатели безFOR ...видят старую версию через MVCC).
NOWAIT — не ждать, если строка уже заблокирована: сразу ошибка 55P03 (lock_not_available).
SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;
-- ERROR: could not obtain lock on row in relation "accounts" (SQLSTATE 55P03)SKIP LOCKED — пропустить уже заблокированные строки. Идеально для очередей задач: каждый воркер забирает свободную задачу, не конкурируя за одну и ту же.
-- Паттерн очереди: атомарно взять одну свободную задачу
WITH job AS (
SELECT id
FROM jobs
WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE jobs SET status = 'processing', picked_at = now()
FROM job
WHERE jobs.id = job.id
RETURNING jobs.*;SKIP LOCKED практически исключает deadlock в очередях, т.к. воркеры не ждут друг друга. Платой является то, что результат не детерминирован (не видишь пропущенные строки), но для очередей это и нужно.
Advisory locks (рекомендательные блокировки)#
Это блокировки «по соглашению» — PostgreSQL не привязывает их к строкам/таблицам, смысл задаёт приложение. Полезны для распределённой координации (например, гарантировать, что только один процесс выполняет джоб).
-- На уровне сессии (нужно явно отпускать)
SELECT pg_advisory_lock(42); -- блокирует, ждёт
SELECT pg_try_advisory_lock(42); -- не ждёт, возвращает bool
SELECT pg_advisory_unlock(42);
-- На уровне транзакции (отпускается автоматически на COMMIT/ROLLBACK)
SELECT pg_advisory_xact_lock(42);
SELECT pg_try_advisory_xact_lock(42);Особенности:
- Ключ — это
bigintили пара(int, int). Часто используютhashtext('my-key'). - Session-level advisory lock не освобождается на коммите — легко получить утечку при использовании пула соединений (соединение вернулось в пул с висящей блокировкой). На пулах предпочитайте
pg_advisory_xact_lock. - Advisory locks тоже участвуют в deadlock detector — два процесса, берущие два advisory-ключа в разном порядке, получат
40P01.
Профилактика deadlock#
1. Единый порядок захвата. Самое мощное средство. Если все транзакции блокируют строки в одном порядке (например, по возрастанию id), цикл невозможен.
-- Перевод между счетами: всегда блокируем по возрастанию id
BEGIN;
SELECT * FROM accounts
WHERE id IN (1, 2)
ORDER BY id -- критично: фиксирует порядок захвата
FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;ORDER BY в SELECT ... FOR UPDATE гарантирует порядок блокировки строк, и любой другой перевод (даже 2 -> 1) возьмёт те же строки в том же порядке.
2. Короткие транзакции. Не держите блокировки во время сетевых вызовов, ожиданий пользователя, тяжёлых вычислений. Сначала посчитайте, потом — короткая транзакция на запись.
3. lock_timeout. Ограничивает, сколько транзакция ждёт любую блокировку. Не предотвращает deadlock, но не даёт зависнуть надолго и превращает зависание в управляемую ошибку (55P03).
SET lock_timeout = '2s'; -- на сессию/транзакцию
-- особенно важно для миграций ALTER TABLE4. NOWAIT / SKIP LOCKED там, где можно не ждать (очереди, опциональные блокировки).
5. Снижать уровень изоляции там, где не нужен SERIALIZABLE. На SERIALIZABLE чаще ловятся 40001 (serialization_failure) — формально не deadlock, но обрабатывается тем же retry-механизмом.
6. Retry в приложении. Deadlock при конкуренции неизбежен; правильный паттерн — поймать 40P01 и повторить транзакцию.
Диагностика: pg_locks и pg_stat_activity#
pg_locks показывает все текущие блокировки и ожидания (granted = false означает «ждёт»).
-- Кто кого блокирует (классический запрос «blocking tree»)
SELECT
blocked.pid AS blocked_pid,
blocked_act.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking_act.query AS blocking_query,
blocked_act.wait_event_type,
blocked_act.wait_event
FROM pg_locks blocked
JOIN pg_stat_activity blocked_act ON blocked.pid = blocked_act.pid
JOIN pg_locks blocking
ON blocking.locktype = blocked.locktype
AND blocking.database IS NOT DISTINCT FROM blocked.database
AND blocking.relation IS NOT DISTINCT FROM blocked.relation
AND blocking.transactionid IS NOT DISTINCT FROM blocked.transactionid
AND blocking.pid <> blocked.pid
AND blocking.granted
JOIN pg_stat_activity blocking_act ON blocking.pid = blocking_act.pid
WHERE NOT blocked.granted;Начиная с PG 9.6 есть удобная функция:
-- Возвращает PID'ы, которые блокируют данный PID
SELECT pg_blocking_pids(1234);pg_stat_activity показывает, чем заняты бэкенды:
SELECT pid, state, wait_event_type, wait_event,
now() - xact_start AS xact_age, query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock' -- висят на блокировке
OR state = 'idle in transaction' -- опасное состояние: держит блокировки, ничего не делает
ORDER BY xact_age DESC;idle in transaction — частый виновник: транзакция открыта, держит блокировки, но приложение не шлёт ни запроса, ни коммита. Ограничивайте idle_in_transaction_session_timeout.
Полный пример deadlock на двух транзакциях#
Воспроизведение в двух сессиях psql:
-- Подготовка
CREATE TABLE accounts (id int PRIMARY KEY, balance numeric);
INSERT INTO accounts VALUES (1, 1000), (2, 1000);-- Сессия A -- Сессия B
BEGIN; BEGIN;
UPDATE accounts UPDATE accounts
SET balance = balance - 100 SET balance = balance - 100
WHERE id = 1; WHERE id = 2;
-- A держит строку 1 -- B держит строку 2
UPDATE accounts -- (B ещё не выполнял вторую команду)
SET balance = balance + 100
WHERE id = 2;
-- A ЖДЁТ строку 2 (её держит B)
UPDATE accounts
SET balance = balance + 100
WHERE id = 1;
-- B ждёт строку 1 -> ЦИКЛ
-- через ~deadlock_timeout детектор находит цикл и убивает одну из транзакций:
-- ERROR: deadlock detected (SQLSTATE 40P01)Та сессия, что стала жертвой, получит 40P01 и должна повторить весь блок. Другая успешно завершит и закоммитит.
Исправление — единый порядок:
-- ОБЕ сессии блокируют сначала меньший id, потом больший
SELECT * FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
-- дальше любые UPDATE безопасны — цикла быть не можетОбработка deadlock в Go#
Ключевой приём — распознать 40P01 и сделать retry с экспоненциальным backoff и джиттером.
С pgx (через pgconn.PgError):
package db
import (
"context"
"errors"
"math/rand"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// isRetryable: deadlock (40P01) и serialization_failure (40001) — безопасно повторять.
func isRetryable(err error) bool {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code == "40P01" || pgErr.Code == "40001"
}
return false
}
// WithRetry выполняет fn в транзакции, повторяя при deadlock/serialization failure.
func WithRetry(ctx context.Context, pool *pgx.Conn, fn func(pgx.Tx) error) error {
const maxAttempts = 5
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
tx, err := pool.Begin(ctx)
if err != nil {
return err
}
err = fn(tx)
if err != nil {
_ = tx.Rollback(ctx) // важно: откатить перед повтором
if isRetryable(err) {
lastErr = err
// backoff с джиттером, чтобы транзакции «разъехались» по времени
backoff := time.Duration(1<<attempt) * 10 * time.Millisecond
jitter := time.Duration(rand.Int63n(int64(backoff)))
select {
case <-time.After(backoff + jitter):
continue
case <-ctx.Done():
return ctx.Err()
}
}
return err // не-retryable ошибка
}
if err := tx.Commit(ctx); err != nil {
if isRetryable(err) { // deadlock может всплыть и на COMMIT (SERIALIZABLE)
lastErr = err
continue
}
return err
}
return nil // успех
}
return lastErr
}С database/sql (через github.com/lib/pq):
import "github.com/lib/pq"
func isDeadlock(err error) bool {
var pqErr *pq.Error
if errors.As(err, &pqErr) {
return pqErr.Code == "40P01" // deadlock_detected
}
return false
}Пример безопасной транзакции с фиксированным порядком блокировки:
func Transfer(ctx context.Context, db *sql.DB, from, to int, amount int64) error {
// from/to могут прийти в любом порядке — нормализуем порядок блокировки
lo, hi := from, to
if lo > hi {
lo, hi = hi, lo
}
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return err
}
defer tx.Rollback() //nolint: safe no-op after commit
// Единый порядок захвата -> deadlock невозможен
if _, err = tx.ExecContext(ctx,
`SELECT 1 FROM accounts WHERE id IN ($1, $2) ORDER BY id FOR UPDATE`,
lo, hi); err != nil {
return err
}
if _, err = tx.ExecContext(ctx,
`UPDATE accounts SET balance = balance - $1 WHERE id = $2`, amount, from); err != nil {
return err
}
if _, err = tx.ExecContext(ctx,
`UPDATE accounts SET balance = balance + $1 WHERE id = $2`, amount, to); err != nil {
return err
}
return tx.Commit()
}Подводные камни / gotchas#
- Deadlock — не баг, а штатная ситуация. Если в системе есть конкуренция за одни ресурсы, retry обязателен. Падение приложения на
40P01— ошибка проектирования. SELECT FOR UPDATEбезORDER BYсам по себе НЕ спасает от deadlock. Порядок блокировки строк не определён без явногоORDER BY(особенно приIN (...)или join). Нужен именно детерминированный порядок.deadlock_timeout != lock_timeout. Первый — задержка перед запуском детектора (защита от цикла), второй — максимум ожидания любой блокировки (защита от долгого зависания). Это разные настройки с разными целями.- Жертву нельзя предсказать. Не пишите логику в расчёте на то, что убьют «другую» транзакцию.
- Deadlock на FK незаметен. Конкурентные
INSERTв дочернюю таблицу берутFOR KEY SHAREна родителе; при определённых паттернах это даёт deadlock, хотя в коде нет явныхFOR UPDATE. - Уникальные индексы создают deadlock при конкурентных вставках. Два
INSERT ... ON CONFLICTс пересекающимися ключами в разном порядке могут зайти в цикл. - Session-level advisory locks текут на пулах соединений. Соединение возвращается в пул с непогашенной блокировкой. Используйте
pg_advisory_xact_lockили явно отпускайте. idle in transactionдержит блокировки. Долго открытая транзакция = бомба замедленного действия для блокировок и для VACUUM (раздувание). Ставьтеidle_in_transaction_session_timeout.SKIP LOCKEDмолча пропускает строки. Не используйте его там, где нужна полнота выборки — только для очередей/распределения работы.- Retry без backoff усугубляет проблему. Повтор сразу после deadlock приводит к повторному столкновению тех же транзакций. Нужен джиттер.
- На
SERIALIZABLEdeadlock может прийти как40001, в том числе наCOMMIT. Retry-логика должна ловить и его, и проверять ошибку коммита тоже. ALTER TABLEберётACCESS EXCLUSIVEи блокирует дажеSELECT. Длинная миграция выстраивает очередь и сама может попасть в deadlock с рабочими запросами. ВсегдаSET lock_timeoutдля DDL.
Вопросы на собеседовании#
В: Что такое deadlock в PostgreSQL и чем он отличается от обычного ожидания блокировки (lock contention)?
О: Deadlock — это циклическое ожидание: набор транзакций, где каждая держит блокировку, нужную другой, образуя цикл, который не разрешится сам. Обычное contention — это ожидание одной блокировки без цикла; оно разрешится, как только держатель закоммитит. PostgreSQL автоматически обнаруживает только циклы (через deadlock detector) и убивает одну транзакцию с 40P01. Простое ожидание не убивается (если не задан lock_timeout).
В: Как PostgreSQL обнаруживает deadlock и кто становится жертвой?
О: Когда транзакция ждёт блокировку дольше deadlock_timeout (по умолчанию 1с), запускается deadlock detector. Он строит wait-for граф и ищет цикл. Если цикл найден — выбирается жертва (обычно та транзакция, чья проверка обнаружила цикл, то есть отмена которой разрывает его), она откатывается с SQLSTATE 40P01. Какая именно транзакция станет жертвой — не гарантируется, приложение должно быть готово к откату любой.
В: Чем deadlock_timeout отличается от lock_timeout?
О: deadlock_timeout — задержка перед запуском детектора циклов; он не отменяет транзакцию по таймауту, а лишь решает, когда проверять граф. lock_timeout — максимальное время ожидания любой блокировки, по истечении которого транзакция падает с 55P03 (lock_not_available). Первый защищает от вечного цикла, второй — от долгого ожидания вообще.
В: Как предотвратить deadlock при переводе денег между двумя счетами?
О: Захватывать строки в едином, детерминированном порядке — например SELECT ... WHERE id IN (a,b) ORDER BY id FOR UPDATE. Тогда любой перевод блокирует строки в одном порядке (по возрастанию id), и цикл невозможен. Дополнительно: короткие транзакции, retry на 40P01, нормализация порядка id в коде.
В: Что именно блокирует SELECT ... FOR UPDATE и зачем нужны NOWAIT и SKIP LOCKED?
О: Блокирует выбранные запросом строки (а не таблицу — на таблице берёт лишь ROW SHARE), так что конкурентные UPDATE/DELETE/FOR UPDATE по этим строкам ждут до конца транзакции. Чистые SELECT не блокируются (MVCC). NOWAIT — не ждать, сразу ошибка 55P03, если строка занята. SKIP LOCKED — пропустить занятые строки; используется для очередей задач, где воркеры разбирают разные строки без конкуренции.
В: В чём разница между FOR UPDATE и FOR NO KEY UPDATE?
О: FOR NO KEY UPDATE — более слабая блокировка, которую берёт обычный UPDATE, не меняющий ключевые столбцы (PK/unique). Она совместима с FOR KEY SHARE, который берут проверки внешних ключей. Это позволяет конкурентно обновлять родительскую строку и вставлять дочерние без блокировки друг друга, что снижает количество deadlock’ов на FK. FOR UPDATE — сильнейшая, конфликтует со всеми режимами row-level блокировок.
В: Как продиагностировать, кто кого блокирует прямо сейчас?
О: Через pg_locks (granted = false = ждёт) в join с pg_stat_activity, чтобы увидеть запросы обеих сторон; либо проще — pg_blocking_pids(pid) (PG 9.6+). В pg_stat_activity смотрю wait_event_type = 'Lock' и состояние idle in transaction. Для разбора уже случившегося deadlock читаю лог PostgreSQL — там есть оба процесса, оба запроса и tuple.
В: Как правильно обрабатывать deadlock в Go-приложении?
О: Ловить ошибку с SQLSTATE 40P01 (через pgconn.PgError в pgx или pq.Error в lib/pq), откатывать транзакцию и повторять её целиком с экспоненциальным backoff и джиттером (чтобы конкурирующие транзакции «разъехались» во времени). Стоит также ретраить 40001 (serialization_failure) и проверять ошибку на Commit(), так как на SERIALIZABLE ошибка может прийти именно там. Число попыток ограничено.
В: Что такое advisory locks и какие с ними есть подводные камни?
О: Это рекомендательные блокировки по произвольному ключу (bigint), смысл которых задаёт приложение — удобно для распределённой координации (один процесс на джоб). Бывают session-level (нужно явно отпускать) и xact-level (отпускаются на коммите). Главный подвох — session-level блокировки текут при работе через пул соединений, поэтому на пулах используют pg_advisory_xact_lock. Они тоже участвуют в deadlock detector.
На что копают на senior+#
- Знание матрицы конфликтов блокировок на уровне таблиц и строк наизусть хотя бы качественно: что
SELECTиUPDATEне конфликтуют на таблице, чтоACCESS EXCLUSIVEблокирует всё, какFOR KEY SHARE/FOR NO KEY UPDATEснижают FK-конфликты. - Понимание, что deadlock неустраним полностью в конкурентной системе и архитектурно правильный ответ — идемпотентность + retry, а не «убрать все deadlock’и».
- Тонкости retry: джиттер, ограничение попыток, ретрай и
40P01, и40001, проверка ошибки наCommit, идемпотентность операции (важно, если в транзакции были сайд-эффекты вне БД). - Скрытые источники блокировок: FK, уникальные индексы, триггеры, каскады,
ON CONFLICT. Кандидат должен уметь объяснить deadlock там, где в коде нет явногоFOR UPDATE. - Влияние уровня изоляции: на
REPEATABLE READ/SERIALIZABLEпоявляются serialization failures, меняется поведениеFOR UPDATE(на RR строка перечитывается с проверкой, можно получить ошибку «could not serialize»). - Операционные риски: длинные транзакции и
idle in transactionкак причина и блокировок, и раздувания таблиц (блокируют VACUUM);lock_timeoutиidle_in_transaction_session_timeoutкак защитные настройки; опасностьALTER TABLEпод нагрузкой. - Очереди на SQL: умение спроектировать надёжную очередь на
FOR UPDATE SKIP LOCKED(CTE +UPDATE ... RETURNING), понимание её ограничений против выделенных брокеров (Kafka/RabbitMQ). - Диагностика на бою: уверенное использование
pg_locks,pg_blocking_pids,pg_stat_activity, чтениеDETAILиз лога deadlock, настройкаlog_lock_waits. - Распределённые блокировки: advisory locks как примитив для leader election / single-runner, их семантика на пулах, сравнение с внешними координаторами (etcd, Redis, Zookeeper).