Learning Platform
Урок 11.04 · 24 мин
Продвинутый
FOR UPDATEFOR SHARENOWAITSKIP LOCKEDJob queueRow lock

В предыдущих уроках row-level locks упоминались как «механизм, который защищает строку от двух одновременных UPDATE». Но SELECT ... FOR UPDATE — это не одна команда, а целое семейство: четыре силы блокировки и два модификатора, которые определяют поведение под конфликтом.

Если ты пишешь только обычные INSERT/UPDATE/DELETE — этот семейство тебе не нужно, всё работает само. Но как только появляется требование «прочитать строку, поработать с ней в памяти, потом записать назад» — приходишь к FOR UPDATE. А SKIP LOCKED — это вообще секретный соус Postgres, позволяющий реализовать job queue без отдельной очереди типа Redis/RabbitMQ.

Четыре уровня row-lock

Иерархия row-level locks

Слева направо: от самого лёгкого (FOR KEY SHARE — защищает только PK от удаления) до самого сильного (FOR UPDATE — защищает строку от любого изменения). FOR NO KEY UPDATE добавлен в 9.3 для FK-related кейсов.

1. FOR KEY SHAREсамый лёгкий
назначениенельзя DELETE строку и менять её PK
2. FOR SHAREshared read lock
назначениенельзя DELETE/UPDATE строку
3. FOR NO KEY UPDATEexclusive, кроме FOR KEY SHARE
назначениемоя UPDATE не трогает PK; FK всё ещё может SHARE
4. FOR UPDATEсамый сильный, exclusive
назначениеполный exclusive row-level lock

Матрица совместимости row-level locks (X = конфликт):

взятый ↓ / новый →FOR KEY SHAREFOR SHAREFOR NO KEY UPDATEFOR UPDATE
FOR KEY SHARE...X
FOR SHARE..XX
FOR NO KEY UPDATE.XXX
FOR UPDATEXXXX

Когда что использовать

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 SHARE
) не будет блокироваться. Это снижение конкуренции в схемах с FK.

FOR 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'.

PostgreSQL

Применение: когда задача может быть пропущена, если занята другим воркером. Например, в 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 параллельными воркерами каждый получил бы свою долю строк.

PostgreSQL

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;

Что даёт этот паттерн:

  1. Никаких race conditions: FOR UPDATE SKIP LOCKED гарантирует, что два воркера не выберут одну job.
  2. Никаких бесконечных ожиданий: если N-1 воркеров уже работают над job-ами, N-ный воркер быстро (не дожидаясь освобождения) скажет «empty queue».
  3. Не нужна отдельная очередь: Redis, RabbitMQ, Kafka — всё это можно заменить таблицей jobs в твоём существующем Postgres. Для масштаба до десятков тысяч jobs/sec этого достаточно. (Свыше — нужна специализированная шина.)
  4. Транзакционность: если воркер упал между чтением и обработкой — BEGIN откатился, lock снялся, job снова доступен через секунду.

Подводные камни SKIP LOCKED

  1. Поведение зависит от плана. Если SELECT с SKIP LOCKED идёт через Index Scan — Postgres пропускает залоченные строки. Если через Seq Scan — то же. Но если в плане есть Sort после locking — порядок может быть нарушен. Обычно избегаем ORDER BY после FOR UPDATE SKIP LOCKED на больших таблицах.

  2. LIMIT 1 обязателен. Без LIMIT воркер заберёт ВСЕ доступные jobs и будет держать их под lock’ом всё время своей работы. Это сразу останавливает конкуренцию (lock всего queue одной транзакцией = другие воркеры ничего не получат).

  3. Stale jobs. Если воркер упал и держит lock через BEGIN; SELECT FOR UPDATE; -- crash -- — lock висит до завершения backend’а (TCP keepalive снимет после нескольких минут). Чтобы это лечить, обычно делают status = 'running' в отдельной короткой транзакции, а потом обрабатывают job уже без lock’а в memory. Если воркер упадёт во время обработки, по started_at < now() - 5 minutes cron-задача переводит running → pending.

Сравнение в реальном времени

Покажем, что NOWAIT и SKIP LOCKED работают на свободных строках одинаково — они отличаются только при наличии конфликта (которого в pglite single-session мы воспроизвести не можем).

PostgreSQL

Когда что выбрать

Decision tree

Выбираем модификатор по тому, что делать при конфликте.

строка точно должна быть обработана сейчасFOR UPDATE (без модификатора) — жди
не хочу ждать, лучше быстро отвечу 'занято'FOR UPDATE NOWAIT
job queue: возьму любую свободнуюFOR UPDATE SKIP LOCKED LIMIT 1
ждать с дедлайномSET LOCAL lock_timeout + FOR UPDATE

Чек-лист

  • Четыре уровня 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 и сбрасывает статус.
Блокировки строк: SELECT FOR UPDATE, NOWAIT, SKIP LOCKED Stack и queue в Data Engineering: backpressure, BFS, SQL parser
Проверка знанийKnowledge check
Ты пишешь систему обработки платежей. Есть таблица payments_to_process. 50 воркеров параллельно её обрабатывают. Каждая обработка ~10s (внешний API call). Как написать SQL, который безопасно раздаёт работу между воркерами, не позволяет двум воркерам взять одну payment, и не залипает если воркер упал?
ОтветAnswer
Архитектура в две транзакции. Транзакция 1 (claim): BEGIN; SELECT id FROM payments_to_process WHERE status='pending' AND (claimed_at IS NULL OR claimed_at < now() - interval '60s') ORDER BY priority DESC, created_at LIMIT 1 FOR UPDATE SKIP LOCKED; UPDATE payments_to_process SET status='claimed', claimed_at=now(), worker_id=:my_id WHERE id=:picked; COMMIT;. Здесь FOR UPDATE SKIP LOCKED гарантирует, что 50 воркеров не возьмут одну payment; внутри transaction'и lock снимется через миллисекунды после COMMIT. Транзакция 2 (process): API call в обычном коде, безо всяких lock'ов на БД. После успеха или ошибки — UPDATE статуса. Защита от crashed воркеров — условие claimed_at < now() - interval '60s' в SELECT'е: если воркер упал и не освободил, через минуту другой воркер заберёт ту же payment. Можно усложнить: добавить attempts счётчик, чтобы после N сбоев пометить как 'failed' и не пытаться больше.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. В чём принципиальная разница между NOWAIT и SKIP LOCKED на FOR UPDATE при попытке прочитать залоченную другой транзакцией строку?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5