sqlpostgresqlconcurrencylocking

Job Queues in SQL: SELECT ... FOR UPDATE SKIP LOCKED

How to make a dozen workers safely pull tasks from a queue table without blocking each other, using FOR UPDATE SKIP LOCKED in PostgreSQL.

3 min läsningReferencesql · postgresql · concurrency · locking · job-queue
Den här artikeln finns för närvarande på ryska — en engelsk översättning är på gång.

Иногда не хочется тащить в проект 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-запросом).
  • Не держите транзакцию открытой на время работы. Если воркер генерирует PDF минуту, не оставляйте BEGIN открытым всё это время. Захватите задачу, переведите в processing, сделайте COMMIT — и только потом выполняйте долгую работу. Иначе одна зависшая транзакция тормозит VACUUM и копит блокировки.
  • SKIP LOCKED ослабляет порядок. Если первая задача в очереди залочена, воркер возьмёт следующую. Строгий FIFO не гарантируется — для очереди задач это нормально, но не закладывайтесь на точный порядок.
  • MySQL / другие СУБД. SKIP LOCKED есть в MySQL 8.0+ (синтаксис FOR UPDATE SKIP LOCKED — почти такой же) и в Oracle. А вот ClickHouse колоночный и не предназначен для очередей с построчными блокировками — для OLTP-очереди берите PostgreSQL или MySQL, а не аналитическую СУБД.

Этого набора достаточно, чтобы поднять надёжную очередь на сотни задач в секунду без отдельного брокера. Когда упрётесь в потолок — тогда и думайте про Kafka.

Öva på riktiga uppgifter

Lös uppgifter i SQL-tränaren med omedelbar rättning och ledtrådar.

Öppna tränaren