Модуль: Базы данных · Уровень: 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 не проверяет циклы постоянно — это дорого. Вместо этого:

  1. Транзакция запрашивает блокировку, которая занята, и встаёт в очередь ожидания.
  2. Заводится таймер на deadlock_timeout (по умолчанию 1s).
  3. Если за это время блокировка не получена, запускается deadlock detector — он строит граф ожидания (wait-for graph) и ищет цикл.
  4. Если цикла нет — транзакция продолжает ждать (обычное contention).
  5. Если цикл есть — выбирается жертва, её транзакция откатывается с ошибкой 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 SHARESELECTтолько с ACCESS EXCLUSIVE
ROW SHARESELECT ... FOR UPDATE/SHAREEXCLUSIVE, ACCESS EXCLUSIVE
ROW EXCLUSIVEINSERT, UPDATE, DELETESHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
SHARE UPDATE EXCLUSIVEVACUUM (не FULL), ANALYZE, CREATE INDEX CONCURRENTLYсам с собой и более сильными
SHARECREATE INDEX (не CONCURRENTLY)ROW EXCLUSIVE и сильнее
SHARE ROW EXCLUSIVECREATE TRIGGER, некоторые ALTER TABLEпочти со всеми
EXCLUSIVEREFRESH MATERIALIZED VIEW CONCURRENTLYвсё, кроме ACCESS SHARE
ACCESS EXCLUSIVEDROP 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/uniqueFOR UPDATE, FOR NO KEY UPDATE, FOR SHARE
FOR UPDATEсильнейшая; полная блокировка строки на изменение/удалениесо всеми четырьмя

Совместимость (X — конфликт):

KEY SHARESHARENO KEY UPDATEUPDATE
KEY SHAREX
SHAREXX
NO KEY UPDATEXXX
UPDATEXXXX

Почему это важно: разделение 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 TABLE

4. 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 приводит к повторному столкновению тех же транзакций. Нужен джиттер.
  • На SERIALIZABLE deadlock может прийти как 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).