Šis straipsnis šiuo metu yra rusų kalba — vertimas į anglų kalbą rengiamas.
Иногда не хочется тащить в проект Kafka или RabbitMQ ради простой очереди фоновых задач: отправить письмо, пересчитать отчёт, сгенерировать PDF. Если у вас уже есть PostgreSQL — он умеет быть очередью сам, и делает это надёжно. Ключ ко всему — одна строчка: FOR UPDATE SKIP LOCKED. Она позволяет десяти воркерам разбирать задачи из одной таблицы параллельно, не наступая друг другу на ноги и не выстраиваясь в очередь на блокировках.
Разберём, как это работает, на примере таблицы jobs, привязанной к заказам.
Проблема: два воркера хватают одну задачу
Заведём таблицу очереди. Каждая строка — одна задача, например «отправить подтверждение по заказу».
CREATE TABLE jobs (
id bigserial PRIMARY KEY,
order_id bigint NOT NULL,
status text NOT NULL DEFAULT 'pending',
run_after timestamptz NOT NULL DEFAULT now(),
attempts int NOT NULL DEFAULT 0,
locked_by text,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO jobs (order_id) VALUES (1001), (1002), (1003);
Наивный воркер делает так:
SELECT id FROM jobs WHERE status = 'pending' ORDER BY id LIMIT 1;
Между SELECT и UPDATE второй воркер успеет прочитать ту же строку. Итог — два воркера обрабатывают заказ 1001, письмо уходит дважды. Это классическая гонка (race condition).
Решение: FOR UPDATE SKIP LOCKED
PostgreSQL даёт блокировку на уровне строки прямо в SELECT. Добавляем FOR UPDATE — строка блокируется до конца транзакции. Добавляем SKIP LOCKED — конкурент не ждёт занятую строку, а просто пропускает её и берёт следующую свободную.
BEGIN;
SELECT id, order_id
FROM jobs
WHERE status = 'pending'
AND run_after <= now()
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1;
UPDATE jobs
SET status = 'processing', locked_by = 'worker-7', attempts = attempts + 1
WHERE id = 1;
COMMIT;
Что здесь происходит по шагам:
FOR UPDATE — блокирует выбранные строки до COMMIT/ROLLBACK.
SKIP LOCKED — заблокированные другими транзакциями строки исключаются из результата, без ожидания.
LIMIT 1 — берём ровно одну задачу (можно и пачку, см. ниже).
ORDER BY id — задаём порядок, иначе очередь становится непредсказуемой.
Теперь запустите этот блок в двух сессиях одновременно: первая получит id = 1, вторая мгновенно получит id = 2. Никто никого не ждёт.
Берём задачи пачкой и одним запросом
Транзакция «SELECT, потом UPDATE» работает, но изящнее всё сделать одним запросом через CTE. Заодно возьмём сразу несколько задач для батч-воркера:
WITH picked AS (
SELECT id
FROM jobs
WHERE status = 'pending'
AND run_after <= now()
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 10
)
UPDATE jobs j
SET status = 'processing',
locked_by = 'worker-7',
attempts = j.attempts + 1
FROM picked
WHERE j.id = picked.id
RETURNING j.id, j.order_id;
RETURNING сразу отдаёт воркеру список захваченных задач — id и order_id для обработки. Блокировка действует на время этой транзакции; после COMMIT строки уже помечены processing, поэтому другие воркеры их и так не возьмут.
Когда работа сделана, закрываем задачу:
UPDATE jobs SET status = 'done', locked_by = NULL WHERE id = 1;
А если задача упала — возвращаем в очередь с задержкой, чтобы повторить позже:
UPDATE jobs
SET status = 'pending',
run_after = now() + interval '30 seconds'
WHERE id = 1 AND attempts < 5;
Индексы и производительность
Без индекса SKIP LOCKED всё равно будет сканировать таблицу и упрётся в заблокированные строки. Поставьте частичный индекс ровно под запрос выборки:
CREATE INDEX idx_jobs_pending
ON jobs (run_after, id)
WHERE status = 'pending';
Индекс покрывает фильтр (status = 'pending', run_after <= now()) и сортировку (id), а частичность держит его маленьким — готовые задачи в него не попадают.
Подводные камни
SKIP LOCKED без транзакции бессмысленен. Блокировка живёт ровно до конца транзакции. Если выполнить SELECT ... FOR UPDATE в autocommit-режиме, строка освободится сразу — и параллельный воркер её схватит. Держите выборку и пометку processing в одной транзакции (или одним CTE-запросом).
- Не держите транзакцию открытой на время работы. Если воркер генерирует PDF минуту, не оставляйте
BEGIN открытым всё это время. Захватите задачу, переведите в processing, сделайте COMMIT — и только потом выполняйте долгую работу. Иначе одна зависшая транзакция тормозит VACUUM и копит блокировки.
SKIP LOCKED ослабляет порядок. Если первая задача в очереди залочена, воркер возьмёт следующую. Строгий FIFO не гарантируется — для очереди задач это нормально, но не закладывайтесь на точный порядок.
- MySQL / другие СУБД.
SKIP LOCKED есть в MySQL 8.0+ (синтаксис FOR UPDATE SKIP LOCKED — почти такой же) и в Oracle. А вот ClickHouse колоночный и не предназначен для очередей с построчными блокировками — для OLTP-очереди берите PostgreSQL или MySQL, а не аналитическую СУБД.
Этого набора достаточно, чтобы поднять надёжную очередь на сотни задач в секунду без отдельного брокера. Когда упрётесь в потолок — тогда и думайте про Kafka.
Иногда не хочется тащить в проект Kafka или RabbitMQ ради простой очереди фоновых задач: отправить письмо, пересчитать отчёт, сгенерировать PDF. Если у вас уже есть PostgreSQL — он умеет быть очередью сам, и делает это надёжно. Ключ ко всему — одна строчка:
FOR UPDATE SKIP LOCKED. Она позволяет десяти воркерам разбирать задачи из одной таблицы параллельно, не наступая друг другу на ноги и не выстраиваясь в очередь на блокировках.Разберём, как это работает, на примере таблицы
jobs, привязанной к заказам.Проблема: два воркера хватают одну задачу
Заведём таблицу очереди. Каждая строка — одна задача, например «отправить подтверждение по заказу».
CREATE TABLE jobs ( id bigserial PRIMARY KEY, order_id bigint NOT NULL, status text NOT NULL DEFAULT 'pending', -- pending | processing | done | failed run_after timestamptz NOT NULL DEFAULT now(), attempts int NOT NULL DEFAULT 0, locked_by text, created_at timestamptz NOT NULL DEFAULT now() ); INSERT INTO jobs (order_id) VALUES (1001), (1002), (1003);Наивный воркер делает так:
-- ❌ так нельзя при нескольких воркерах SELECT id FROM jobs WHERE status = 'pending' ORDER BY id LIMIT 1; -- ...а потом отдельным запросом UPDATE ... SET status = 'processing'Между
SELECTиUPDATEвторой воркер успеет прочитать ту же строку. Итог — два воркера обрабатывают заказ 1001, письмо уходит дважды. Это классическая гонка (race condition).Решение: FOR UPDATE SKIP LOCKED
PostgreSQL даёт блокировку на уровне строки прямо в
SELECT. ДобавляемFOR UPDATE— строка блокируется до конца транзакции. ДобавляемSKIP LOCKED— конкурент не ждёт занятую строку, а просто пропускает её и берёт следующую свободную.BEGIN; SELECT id, order_id FROM jobs WHERE status = 'pending' AND run_after <= now() ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1; -- воркер видит, например, id = 1, помечает задачу UPDATE jobs SET status = 'processing', locked_by = 'worker-7', attempts = attempts + 1 WHERE id = 1; COMMIT;Что здесь происходит по шагам:
FOR UPDATE— блокирует выбранные строки доCOMMIT/ROLLBACK.SKIP LOCKED— заблокированные другими транзакциями строки исключаются из результата, без ожидания.LIMIT 1— берём ровно одну задачу (можно и пачку, см. ниже).ORDER BY id— задаём порядок, иначе очередь становится непредсказуемой.Теперь запустите этот блок в двух сессиях одновременно: первая получит
id = 1, вторая мгновенно получитid = 2. Никто никого не ждёт.Берём задачи пачкой и одним запросом
Транзакция «SELECT, потом UPDATE» работает, но изящнее всё сделать одним запросом через CTE. Заодно возьмём сразу несколько задач для батч-воркера:
WITH picked AS ( SELECT id FROM jobs WHERE status = 'pending' AND run_after <= now() ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 10 ) UPDATE jobs j SET status = 'processing', locked_by = 'worker-7', attempts = j.attempts + 1 FROM picked WHERE j.id = picked.id RETURNING j.id, j.order_id;RETURNINGсразу отдаёт воркеру список захваченных задач —idиorder_idдля обработки. Блокировка действует на время этой транзакции; послеCOMMITстроки уже помеченыprocessing, поэтому другие воркеры их и так не возьмут.Когда работа сделана, закрываем задачу:
UPDATE jobs SET status = 'done', locked_by = NULL WHERE id = 1;А если задача упала — возвращаем в очередь с задержкой, чтобы повторить позже:
UPDATE jobs SET status = 'pending', run_after = now() + interval '30 seconds' WHERE id = 1 AND attempts < 5;Индексы и производительность
Без индекса
SKIP LOCKEDвсё равно будет сканировать таблицу и упрётся в заблокированные строки. Поставьте частичный индекс ровно под запрос выборки:CREATE INDEX idx_jobs_pending ON jobs (run_after, id) WHERE status = 'pending';Индекс покрывает фильтр (
status = 'pending',run_after <= now()) и сортировку (id), а частичность держит его маленьким — готовые задачи в него не попадают.Подводные камни
SKIP LOCKEDбез транзакции бессмысленен. Блокировка живёт ровно до конца транзакции. Если выполнитьSELECT ... FOR UPDATEв autocommit-режиме, строка освободится сразу — и параллельный воркер её схватит. Держите выборку и пометкуprocessingв одной транзакции (или одним CTE-запросом).BEGINоткрытым всё это время. Захватите задачу, переведите вprocessing, сделайтеCOMMIT— и только потом выполняйте долгую работу. Иначе одна зависшая транзакция тормозитVACUUMи копит блокировки.SKIP LOCKEDослабляет порядок. Если первая задача в очереди залочена, воркер возьмёт следующую. Строгий FIFO не гарантируется — для очереди задач это нормально, но не закладывайтесь на точный порядок.SKIP LOCKEDесть в MySQL 8.0+ (синтаксисFOR UPDATE SKIP LOCKED— почти такой же) и в Oracle. А вот ClickHouse колоночный и не предназначен для очередей с построчными блокировками — для OLTP-очереди берите PostgreSQL или MySQL, а не аналитическую СУБД.Этого набора достаточно, чтобы поднять надёжную очередь на сотни задач в секунду без отдельного брокера. Когда упрётесь в потолок — тогда и думайте про Kafka.