В предыдущих уроках row-level locks упоминались как «механизм, который защищает строку от двух одновременных UPDATE». Но SELECT ... FOR UPDATE — это не одна команда, а целое семейство: четыре силы блокировки и два модификатора, которые определяют поведение под конфликтом.
Если ты пишешь только обычные INSERT/UPDATE/DELETE — этот семейство тебе не нужно, всё работает само. Но как только появляется требование «прочитать строку, поработать с ней в памяти, потом записать назад» — приходишь к FOR UPDATE. А SKIP LOCKED — это вообще секретный соус Postgres, позволяющий реализовать job queue без отдельной очереди типа Redis/RabbitMQ.
Четыре уровня row-lock
Слева направо: от самого лёгкого (FOR KEY SHARE — защищает только PK от удаления) до самого сильного (FOR UPDATE — защищает строку от любого изменения). FOR NO KEY UPDATE добавлен в 9.3 для FK-related кейсов.
Матрица совместимости row-level locks (X = конфликт):
| взятый ↓ / новый → | FOR KEY SHARE | FOR SHARE | FOR NO KEY UPDATE | FOR UPDATE |
|---|---|---|---|---|
| FOR KEY SHARE | . | . | . | X |
| FOR SHARE | . | . | X | X |
| FOR NO KEY UPDATE | . | X | X | X |
| FOR UPDATE | X | X | X | X |
Когда что использовать
FOR UPDATE — наш default, когда читаем строку с намерением её обновить. Гарантирует, что между нашим SELECT и UPDATE никто строку не изменит и не удалит.
BEGIN;
SELECT total_cents FROM orders WHERE id = 42 FOR UPDATE;
-- ... вычисляем что-то ...
UPDATE orders SET total_cents = 600 WHERE id = 42;
COMMIT;
FOR NO KEY UPDATE — то же самое, но «обещаем не менять primary key». Тогда параллельный foreign-key check на эту строку (берущий
FOR KEY SHAREFOR SHARE — «я читаю, не дай никому перезаписать пока я не закоммичу». Используется редко, в основном для consistency reads, когда нужно прочитать, что-то посчитать, и не хочется чтобы между двумя SELECT-ами строки изменились. Несколько FOR SHARE совместимы между собой.
FOR KEY SHARE — Postgres берёт сам при FK-checks. Руками его пишут совсем редко.
NOWAIT: «не жди, отвались»
По умолчанию FOR UPDATE ждёт, пока конфликтующая транзакция отпустит строку. Это может быть бесконечно долго (или до statement_timeout, если он выставлен).
NOWAIT меняет поведение: если строка занята — немедленный error, не ждём:
FOR UPDATE NOWAIT на свободной строке — работает обычно. Если бы строка была залочена другой транзакцией, мы получили бы ERROR: could not obtain lock on row in relation 'customers'.
Применение: когда задача может быть пропущена, если занята другим воркером. Например, в interactive-сценарии: пользователь жмёт «забронировать столик», мы делаем SELECT FOR UPDATE NOWAIT. Если строка занята другим бронирующим в эту же секунду — мгновенно отвечаем «извините, в этот момент берут, попробуйте ещё раз». Лучше, чем держать пользователя в ожидании 30 секунд.
SKIP LOCKED: пропусти занятое, бери свободное
SKIP LOCKED — самый мощный модификатор. Если бы Postgres дал тебе одну фичу из этого урока, выбирай это. Поведение:
- Если строка залочена другой транзакцией — просто пропусти её, как будто её нет в результате.
- Никакого error, никакого ожидания.
Это превращает обычный SELECT в idempotent job queue:
-- worker picks up next available job
BEGIN;
SELECT id, payload
FROM jobs
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;
-- ... обрабатываем работу ...
UPDATE jobs SET status = 'done' WHERE id = :picked_id;
COMMIT;
Десять воркеров параллельно запускают этот SQL — и каждый получит свою job. Никто не дублирует работу, никто не блокируется.
Демонстрация SKIP LOCKED на одной сессии. Поскольку в pglite одна сессия, конкуренции нет — все запрошенные строки доступны. В реальной БД с 10 параллельными воркерами каждый получил бы свою долю строк.
Job queue паттерн целиком
-- В каждом воркере раз в N секунд:
BEGIN;
-- 1. Получаем следующий доступный job
SELECT id, payload
FROM jobs
WHERE status = 'pending'
AND scheduled_at <= now()
ORDER BY priority DESC, scheduled_at
LIMIT 1
FOR UPDATE SKIP LOCKED;
-- Если ничего не вернулось — UNLOCK и подождать.
-- 2. Помечаем как in-progress, чтобы остальные точно его не взяли
UPDATE jobs SET status = 'running', started_at = now() WHERE id = :id;
COMMIT;
-- Дальше воркер обрабатывает job в своём процессе/корутине.
-- Отдельной транзакцией:
UPDATE jobs SET status = 'done', finished_at = now() WHERE id = :id;
-- или
UPDATE jobs SET status = 'failed', error = :err WHERE id = :id;
Что даёт этот паттерн:
- Никаких race conditions:
FOR UPDATE SKIP LOCKEDгарантирует, что два воркера не выберут одну job. - Никаких бесконечных ожиданий: если N-1 воркеров уже работают над job-ами, N-ный воркер быстро (не дожидаясь освобождения) скажет «empty queue».
- Не нужна отдельная очередь: Redis, RabbitMQ, Kafka — всё это можно заменить таблицей
jobsв твоём существующем Postgres. Для масштаба до десятков тысяч jobs/sec этого достаточно. (Свыше — нужна специализированная шина.) - Транзакционность: если воркер упал между чтением и обработкой —
BEGINоткатился, lock снялся, job снова доступен через секунду.
Подводные камни SKIP LOCKED
-
Поведение зависит от плана. Если SELECT с SKIP LOCKED идёт через Index Scan — Postgres пропускает залоченные строки. Если через Seq Scan — то же. Но если в плане есть Sort после locking — порядок может быть нарушен. Обычно избегаем
ORDER BYпослеFOR UPDATE SKIP LOCKEDна больших таблицах. -
LIMIT 1обязателен. Без LIMIT воркер заберёт ВСЕ доступные jobs и будет держать их под lock’ом всё время своей работы. Это сразу останавливает конкуренцию (lock всего queue одной транзакцией = другие воркеры ничего не получат). -
Stale jobs. Если воркер упал и держит lock через
BEGIN; SELECT FOR UPDATE; -- crash --— lock висит до завершения backend’а (TCP keepalive снимет после нескольких минут). Чтобы это лечить, обычно делаютstatus = 'running'в отдельной короткой транзакции, а потом обрабатывают job уже без lock’а в memory. Если воркер упадёт во время обработки, поstarted_at < now() - 5 minutescron-задача переводит running → pending.
Сравнение в реальном времени
Покажем, что NOWAIT и SKIP LOCKED работают на свободных строках одинаково — они отличаются только при наличии конфликта (которого в pglite single-session мы воспроизвести не можем).
Когда что выбрать
Выбираем модификатор по тому, что делать при конфликте.
Чек-лист
- Четыре уровня row-lock:
FOR KEY SHARE,FOR SHARE,FOR NO KEY UPDATE,FOR UPDATE— по возрастанию силы. FOR UPDATE— самый частый: «читаю с намерением обновить».FOR NO KEY UPDATE— то же, но FK-checks не блокируются (используй, если не меняешь PK).NOWAIT— на залоченной строке мгновенный error, не ждём.SKIP LOCKED— на залоченной строке пропускаем, как будто её нет. Основа job queue в Postgres.- Канонический job queue:
SELECT ... FOR UPDATE SKIP LOCKED LIMIT 1+ UPDATE статуса + COMMIT. LIMIT 1обязателен — без него воркер заберёт всё.- Stale-jobs лечатся через cron, который проверяет
started_at < now() - Xи сбрасывает статус.