Блокировки строк, FOR UPDATE, deadlock

О лекции

MVCC не отменяет локи писателей. FOR UPDATE берёт row lock без изменения данных. При тупике PostgreSQL прерывает одну транзакцию. Не держите лок, пока ходите в сеть.

PostgreSQL postgres собеседование

Читатели snapshot не блокируют писателей: в этом смысл MVCC. Два UPDATE одной строки — уже другая история. Второй ждёт commit или rollback первого. Это row exclusive lock (исключительная блокировка строки). Он живёт до конца транзакции, не до конца statement.

Явный SELECT … FOR UPDATE берёт тот же класс лока, ещё не меняя данные: «эта строка сейчас моя, я собираюсь её обновить». Так сериализуют «прочитал баланс — сейчас спишу», чтобы сосед не вклинился между SELECT и UPDATE. Это не уровень изоляции и не замена UNIQUE.

Пример. Баланс 100. Два запроса почти одновременно делают SELECT, потом UPDATE. Без FOR UPDATE оба могут увидеть 100 и оба списать. С FOR UPDATE второй ждёт, пока первый закоммитит, и уже видит новый баланс.

В воркерах очереди задач локи становятся паттерном конкурентного потребления: несколько процессов выбирают «свободные» работы и не хотят ждать чужой commit. Для этого есть SKIP LOCKED.

Виды row lock

  • FOR UPDATE — исключительный лок строки, как у UPDATE.
  • FOR SHARE / FOR KEY SHARE — разделяемый; мешает UPDATE/DELETE, но несколько читателей могут держать его вместе. Нужен реже, в основном вокруг FK.
  • SKIP LOCKED — не ждать занятые строки, взять только свободные. Паттерн воркера.
  • NOWAIT — если строка занята, сразу ошибка, без очереди.

Лок живёт до COMMIT или ROLLBACK. Поэтому BEGIN, затем HTTP, затем COMMIT — это держать чужие UPDATE в очереди на время сети. Отсюда таймауты, deadlock и bloat одновременно.

Очередь задач и SKIP LOCKED

Несколько воркеров делают SELECT … FROM jobs WHERE status = 'new' FOR UPDATE SKIP LOCKED LIMIT n. Кто первый залочил строку — тот её обрабатывает. Остальные не стоят в очереди на эти id, а берут следующие свободные. Без SKIP LOCKED второй воркер спит на первой же занятой строке, даже если в таблице полно работы.

BEGIN;
SELECT id, payload
FROM jobs
WHERE status = 'new'
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 10;
-- обработка в той же транзакции или смена status, затем COMMIT
UPDATE jobs SET status = 'taken' WHERE id = ANY($1);
COMMIT;

Паттерн воркера: взять пачку свободных задач, не блокируясь на строках, которые уже обрабатывает сосед.

Deadlock

Тупик: T1 лочит строку A, T2 лочит B, затем T1 хочет B, T2 хочет A. Детектор PostgreSQL прерывает одну транзакцию ошибкой deadlock detected. Лечение — стабильный порядок локов (всегда меньший id первым), короткие транзакции, не лочить родителя и детей в разном порядке из разных кусков кода.

Пример. Заказ 10 и заказ 20. Сервис A лочит 10, потом 20. Сервис B лочит 20, потом 10. Оба ждут друг друга. Postgres убивает одну транзакцию. Клиент должен уметь повторить запрос.

Table lock от DDL, LOCK TABLE или миграции с ACCESS EXCLUSIVE блокирует всех. Advisory locks — ручные, их легко взять и забыть снять, особенно при panic без defer. Их не видно в «обычной» модели строки, пока не посмотрите pg_locks.

Типичная ошибка

Путать lock и MVCC snapshot. Repeatable Read без FOR UPDATE не запрещает соседу сделать UPDATE: вы просто не увидите его результат в своём снимке, а сосед строку изменит. FOR UPDATE на Read Committed не защищает от фантома: двух INSERT «единственного» слота, которых ещё нет в таблице.

На собеседовании

SKIP LOCKED — паттерн воркера, его стоит назвать вслух. Лок живёт до конца транзакции, поэтому BEGIN + HTTP + COMMIT — путь и к deadlock, и к bloat. При deadlock Postgres убивает одну транзакцию, клиент должен быть готов к ошибке и ретраю.