sqlpostgresqlconcurrencylocking

Colas de tareas en SQL: SELECT ... FOR UPDATE SKIP LOCKED

Cómo hacer que una decena de workers extraigan tareas de una tabla de cola sin bloquearse entre sí, usando FOR UPDATE SKIP LOCKED en PostgreSQL.

3 min de lecturaReferencesql · postgresql · concurrency · locking · job-queue
Este artículo está actualmente en ruso — la traducción está en curso.

Иногда не хочется тащить в проект 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.

Practica con ejercicios reales

Resuelve ejercicios en el entrenador de SQL con corrección instantánea y pistas.

Abrir el entrenador