SELECT FOR UPDATE и очередь задач
Пессимистичные блокировки строк чинят гонку lost update: FOR UPDATE блокирует прочитанные строки, делая следующую запись безопасной. FOR UPDATE SKIP LOCKED превращает таблицу в очередь задач без конкуренции за блокировки.
Команда построила пул воркеров над таблицей jobs: каждый воркер выполнял SELECT id FROM jobs WHERE status='queued' LIMIT 1, затем помечал её running. С одним воркером всё было идеально. На десяти воркерах каждую задачу подхватывали трое-четверо — дубль-письма, тройные списания, всё подряд. Фикс — восемь символов: FOR UPDATE SKIP LOCKED. Эти же восемь символов превращают обычную таблицу в продакшн-очередь. Это самая полезная блокирующая клауза в Postgres.
Гонка lost update и пессимистичный фикс
Прежде чем тянуться к FOR UPDATE, спроси себя: читаю ли я значение и пишу ли затем решение, основанное на нём? Если да, и другая сессия может сделать то же самое конкурентно, — у тебя прямо сейчас окно для lost update.
Умолчание READ COMMITTED не сериализует read-then-write, сделанный двумя операторами. Две сессии могут обе прочитать баланс, обе вычислить новое значение по устаревшему чтению и обе записать — вторая молча перезатирает первую. Это lost update.
-- Сессия 1 и Сессия 2 обе выполняют это, чередуясь:
SELECT balance FROM accounts WHERE id = 1; -- обе читают 500
-- приложение вычисляет 500 - 100 = 400
UPDATE accounts SET balance = 400 WHERE id = 1; -- обе пишут 400; одно списание исчезаетSELECT ... FOR UPDATE чинит это пессимистично: он берёт блокировку записи уровня строки на каждую возвращённую строку. Вторая транзакция, пытающаяся заблокировать ту же строку, блокируется до коммита первой, затем продолжает по уже актуальным данным.
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- блокирует строку
-- только эта транзакция держит её; другие ждут здесь
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT; -- блокировка снимается; следующий ожидающий перечитывает свежий балансБлокировка живёт остаток транзакции и снимается на COMMIT или ROLLBACK. Цена — сериализация: спорные строки обрабатываются по одной транзакции за раз. В этом и смысл — корректность важнее пропускной способности на горячей строке.
Четыре силы блокировок
В Postgres четыре режима блокировки строк, от сильнейшего к слабейшему. Выбор слабейшего корректного пропускает больше конкурентности.
- FOR UPDATE — полная блокировка записи. Блокирует любой другой
FOR UPDATE/FOR SHAREи блокируетUPDATE/DELETEстроки. Используй, когда будешь менять строку. - FOR NO KEY UPDATE — чуть слабее; берётся автоматически обычным
UPDATE, не трогающим ключевую колонку. Он не блокируетFOR KEY SHARE, который берут проверки foreign key — так что снижает FK-конкуренцию. - FOR SHARE — разделяемая блокировка чтения. Несколько транзакций могут держать её; она блокирует писателей. Используй, чтобы закрепить читаемую, но не меняемую строку.
- FOR KEY SHARE — слабейшая; её берёт проверка существования по foreign key на родительской строке. Блокирует лишь изменения ключа строки.
Практический урок: INSERT в дочернюю таблицу берёт FOR KEY SHARE на родителя, а UPDATE неключевой колонки родителя берёт FOR NO KEY UPDATE — эти два сделали совместимыми, чтобы обычный FK-трафик не сериализовался. Тянись к простому FOR UPDATE только когда реально намерен обновить заблокированную строку.
SKIP LOCKED: каноничная конкурентная очередь
SKIP LOCKED меняет поведение ожидания: вместо блокировки на уже занятой строке запрос пропускает её и возвращает следующую доступную. Это ровно то, что нужно очереди задач — каждый воркер хватает другую незанятую задачу.
-- Атомарный dequeue одного воркера: взять старейшую свободную задачу, пропуская занятые соседом.
BEGIN;
SELECT id FROM jobs
WHERE status = 'queued'
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1;
-- пометить взятую строку, чтобы её не выбрали снова после коммита:
UPDATE jobs SET status = 'running', locked_at = now() WHERE id = :claimed_id;
COMMIT;Десять воркеров, выполняющих это параллельно, каждый блокирует и берёт отдельную строку; нет дублей, нет блокировок, почти линейное масштабирование. Так работают Sidekiq-Pro, pg-boss, Que и большинство современных Postgres-очередей — без отдельного брокера, просто FOR UPDATE SKIP LOCKED.
▸Граничные случаи
Ещё две ручки. NOWAIT — противоположность SKIP LOCKED: вместо ожидания или пропуска он мгновенно падает с ошибкой (55P03), если любая целевая строка занята — полезно, когда «кто-то другой редактирует это» должно быть мгновенным отказом, а не ожиданием. И консистентный порядок блокировок — правило, предотвращающее deadlock (следующий урок): если каждая транзакция, блокирующая несколько строк, всегда блокирует их в одном порядке — например, всегда ORDER BY id перед FOR UPDATE — две транзакции никогда не смогут каждая держать строку, нужную другой. Перевод денег, блокирующий счета 1 и 2, должен блокировать меньший id первым в обоих направлениях перевода, иначе под нагрузкой словишь deadlock.
Воркер очереди использует SELECT ... FOR UPDATE (без SKIP LOCKED) с LIMIT 1, чтобы взять задачу. На десяти воркерах что произойдёт?
Нужно, чтобы «редактирование записи мгновенно падало, если другая сессия уже её редактирует» — без ожидания, без пропуска. Какая клауза?
Упорядочи атомарный, без-lost-update dequeue одного воркера из таблицы jobs, разделяемой десятью воркерами:
- 1 BEGIN транзакцию, чтобы взятие и обновление статуса были одной единицей
- 2 SELECT старейшую queued-задачу с ORDER BY id ... FOR UPDATE SKIP LOCKED LIMIT 1
- 3 UPDATE этой строки на status='running', locked_at=now()
- 4 COMMIT, чтобы снять блокировку и сделать взятие долговечным
- 01Что такое проблема lost update и как SELECT FOR UPDATE её предотвращает?
- 02Как FOR UPDATE SKIP LOCKED строит конкурентную очередь задач и как выглядит запрос dequeue?
- 03Когда использовать NOWAIT вместо SKIP LOCKED или простой блокировки и чем они различаются на занятой строке?
Уровень изоляции по умолчанию оставляет двухоператорный read-then-write открытым для lost update: две сессии читают одно значение, вычисляют по устаревшему чтению и обе пишут, теряя одно изменение. SELECT ... FOR UPDATE закрывает это пессимистично, беря блокировку записи строки во время чтения; конкурирующий блокировщик ждёт до твоего коммита, затем работает по свежим данным, так что горячая строка обрабатывается по одной транзакции за раз. Postgres предлагает четыре силы блокировок — FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE, FOR KEY SHARE — и ты берёшь слабейшую корректную, чтобы обычный FK-трафик не сериализовался. Звёздный паттерн — FOR UPDATE SKIP LOCKED: он шагает мимо строк, занятых другими воркерами, так что обычная таблица jobs становится конкурентной очередью без конкуренции, где каждый воркер берёт отдельную строку через ORDER BY id ... LIMIT 1. Дополни это NOWAIT для быстрого отказа при редактировании и консистентным порядком блокировок, чтобы избежать deadlock’ов, которые изучишь дальше. Теперь, когда встретишь баг с дубль-задачей или двойным списанием, первый инстинкт — проверить, использует ли запрос dequeue FOR UPDATE SKIP LOCKED; если нет — ты знаешь, что именно добавить.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.