Архитектура и данные

Очередь задач без RabbitMQ и Kafka: когда PostgreSQL + SKIP LOCKED достаточно для production

У веб-продукта появляется первая фоновая задача.

Например после регистрации нужно отправить письмо.

Потом появляются:

уведомления;
обработка файлов;
публикация отложенных материалов;
очистка временных данных;
вызовы внешних API;
генерация документов;
повтор неудачных интеграций.

Разработчик смотрит на этот список и приходит к почти автоматическому выводу:

Нам нужна очередь. Значит, ставим RabbitMQ.

Или:

Проект серьёзный — лучше сразу Kafka.

Через несколько дней архитектура выглядит так:

Application
    │
    ├── PostgreSQL
    │
    ├── Redis
    │
    └── Message Broker
          │
          └── Worker

Теперь нужно отдельно:

разворачивать брокер;
обновлять его;
мониторить;
резервировать;
защищать;
следить за диском;
настраивать reconnect;
думать о публикации сообщения при сбое сети.

При этом приложение может выполнять:

несколько тысяч фоновых задач в сутки

и уже иметь надёжный PostgreSQL, без которого всё равно не способно работать.

Возникает вопрос:

нужен ли отдельный брокер именно сейчас?

Во многих веб-продуктах ответ оказывается:

пока нет.

PostgreSQL способен одновременно хранить бизнес-данные и durable-очередь фоновых работ, а FOR UPDATE SKIP LOCKED позволяет нескольким worker безопасно выбирать разные задачи без последовательного ожидания друг друга.

Но из этого не следует:

PostgreSQL полностью заменяет RabbitMQ и Kafka.

Это было бы другой крайностью.

Правильнее определить границу:

для какого класса фоновых работ таблица PostgreSQL является достаточно хорошей production-очередью, а когда требования уже действительно оправдывают специализированную messaging-инфраструктуру?


Начнём с самой простой очереди

Допустим пользователь создал проект.

После этого нужно отправить уведомление.

Самый наивный вариант:

await createProject();

await sendEmail();

Он кажется логичным.

Но теперь внешний SMTP отвечает:

12 секунд

Пользователь всё это время ждёт HTTP-response.

Ещё хуже, если SMTP временно недоступен:

createProject ✓

sendEmail ✗

HTTP request → 500

Проект фактически создан.

Но пользователь получает ошибку.


Business transaction и побочный эффект имеют разную критичность

Основная операция:

Создать проект

должна закончиться быстро и предсказуемо.

Отправка письма:

может произойти через несколько секунд.

Поэтому появляется background job:

HTTP request
     ↓
Create Project
     ↓
Create SEND_EMAIL job
     ↓
Commit
     ↓
200 OK

          Worker
             ↓
         SEND_EMAIL

Теперь SMTP больше не находится на критическом пользовательском пути.


И здесь PostgreSQL даёт очень сильное свойство

Проект и задача находятся в одной базе.

Значит, можно выполнить:

BEGIN;

INSERT INTO projects (...);

INSERT INTO jobs (
    type,
    payload,
    status,
    available_at
)
VALUES (
    'PROJECT_CREATED_EMAIL',
    '{"projectId": 1842}',
    'pending',
    now()
);

COMMIT;

Теперь существует только два возможных результата:

Project ✓
Job     ✓

или:

Project ✗
Job     ✗

Не возникает промежуточной ситуации:

Project ✓
Message broker ✗

Это одно из главных преимуществ PostgreSQL-очереди для задач, тесно связанных с бизнес-транзакциями.


С отдельным брокером появляется dual-write problem

Представим:

BEGIN PostgreSQL

INSERT project

COMMIT

После этого:

publish message → RabbitMQ

Что произойдёт, если:

PostgreSQL COMMIT ✓

а затем:

network timeout

при публикации сообщения?

Мы не знаем:

сообщение не дошло?

или:

дошло,
но acknowledgement потерялся?

Эту проблему можно решить.

Например через transactional outbox.

Но это уже дополнительная архитектура.

Если job изначально находится в PostgreSQL, atomicity business state + task получается значительно проще.


Как может выглядеть таблица jobs

Например:

CREATE TABLE jobs (
    id              bigserial PRIMARY KEY,
    type            text NOT NULL,
    payload         jsonb NOT NULL,

    status          text NOT NULL
                    DEFAULT 'pending',

    priority        integer NOT NULL
                    DEFAULT 0,

    available_at    timestamptz NOT NULL
                    DEFAULT now(),

    attempts        integer NOT NULL
                    DEFAULT 0,

    max_attempts    integer NOT NULL
                    DEFAULT 5,

    locked_by       text,
    locked_at       timestamptz,

    last_error      text,

    created_at      timestamptz NOT NULL
                    DEFAULT now(),

    finished_at     timestamptz
);

У такой записи уже есть всё основное:

что выполнить;
когда;
сколько раз пробовали;
кто забрал;
когда забрал;
какая была последняя ошибка.

Но простой SELECT ещё не создаёт конкурентную очередь

Представим одновременно запущены:

Worker A
Worker B
Worker C

Каждый выполняет:

SELECT *
FROM jobs
WHERE status = 'pending'
ORDER BY id
LIMIT 1;

Все три могут увидеть:

job #1842.

И каждый начнёт её выполнять.

Получим:

email × 3

или:

webhook × 3

или:

создание документа × 3.

Нужен безопасный механизм захвата.


FOR UPDATE решает только часть задачи

Можно сделать:

SELECT *
FROM jobs
WHERE status = 'pending'
ORDER BY id
FOR UPDATE
LIMIT 1;

Первый worker заблокирует строку.

Но второй:

дойдёт до той же job

и начнёт ждать освобождения lock.

Третий тоже.

Получается:

Worker A → Job 1

Worker B → ждёт Job 1

Worker C → ждёт Job 1

Хотя в таблице спокойно могут лежать:

Job 2
Job 3
Job 4

Здесь появляется SKIP LOCKED

Запрос:

SELECT id
FROM jobs
WHERE status = 'pending'
  AND available_at <= now()
ORDER BY
  priority DESC,
  available_at,
  id
FOR UPDATE SKIP LOCKED
LIMIT 1;

Работает иначе.

Если:

Job 1

уже заблокирована Worker A, Worker B не ждёт её.

Он пропускает строку и берёт следующую доступную.

Получаем:

Worker A → Job 1
Worker B → Job 2
Worker C → Job 3

Именно такой сценарий прямо предусмотрен PostgreSQL: документация отдельно указывает, что SKIP LOCKED не предназначен для обычных согласованных выборок, но подходит для снижения lock contention при нескольких consumers, работающих с queue-like table.


Но держать транзакцию открытой всё время выполнения job — плохая идея

Наивная реализация:

BEGIN
↓
SELECT FOR UPDATE SKIP LOCKED
↓
HTTP request к внешнему сервису
↓
генерация PDF 40 секунд
↓
email
↓
COMMIT

Теперь database transaction живёт десятки секунд или минуты.

Это создаёт совершенно ненужные проблемы:

длинные row locks;
длинные transactions;
больше dead tuples;
сложнее VACUUM;
непредсказуемая конкуренция.

Поэтому мы хотим короткую фазу claim.


Worker сначала забирает задачу

Например одним запросом:

WITH picked AS (
    SELECT id
    FROM jobs
    WHERE status = 'pending'
      AND available_at <= now()
    ORDER BY
      priority DESC,
      available_at,
      id
    FOR UPDATE SKIP LOCKED
    LIMIT 1
)
UPDATE jobs AS j
SET
    status = 'running',
    locked_by = $1,
    locked_at = now(),
    attempts = attempts + 1
FROM picked
WHERE j.id = picked.id
RETURNING j.*;

Внутри короткой транзакции происходит:

найти;
заблокировать;
пометить running;
записать worker;
вернуть job.

Транзакция завершается.

И только потом worker начинает реальную работу.


Это важное архитектурное разделение

Claim phase

миллисекунды

Execute phase

секунды или минуты

PostgreSQL участвует в первой.

Долгая работа происходит уже без открытой database transaction.


Но теперь появляется другой вопрос

Worker получил:

Job #1842
status = running

Начал работу.

После этого:

process crashed.

Job остаётся:

running

навсегда?

Если так, очередь не является отказоустойчивой.


Нужна lease-модель

Поля:

locked_by
locked_at

становятся не просто журналом.

Они означают:

Worker получил право выполнять задачу на ограниченное время.

Например monitoring/recovery process видит:

status = running

locked_at =
35 минут назад

job timeout =
10 минут

Такая job считается зависшей.


Recovery возвращает её в очередь

Например:

UPDATE jobs
SET
    status = 'pending',
    locked_by = NULL,
    locked_at = NULL,
    available_at = now()
WHERE status = 'running'
  AND locked_at < now() - interval '15 minutes';

Теперь другой worker сможет попробовать задачу снова.


Для длинных jobs лучше heartbeat

Допустим обработка видео может занимать:

45 минут.

Тайм-аут:

15 минут

не подходит.

Можно добавить:

heartbeat_at

и во время выполнения обновлять:

heartbeat_at = now()

Recovery проверяет не:

сколько job выполняется,

а:

жив ли worker,
который её выполняет.

Но recovery автоматически приводит нас к at-least-once

Представим worker:

отправил email ✓

а затем умер до:

UPDATE jobs
SET status = 'completed';

Recovery увидит:

running stale

и выполнит job снова.

Получаем:

email × 2.

Это фундаментальная проблема очередей, а не дефект PostgreSQL.


Exactly once нельзя получить одной красивой колонкой

Можно написать:

status = completed

Но между внешним side effect и записью этого статуса всегда может произойти сбой.

Например:

POST payment-provider/refund
        ↓
Provider выполнил refund
        ↓
network connection lost
        ↓
worker не знает результат

Запуск повторно может создать второй refund.


Поэтому production queue требует идемпотентных jobs

Нужно проектировать обработчик так, чтобы:

повторное выполнение

не создавало неправильный второй бизнес-эффект.

Например:

Job:
SEND_PROJECT_EMAIL

idempotency key:
project-created-email:1842

В таблице отправок:

UNIQUE(event_key)

Первый запуск:

INSERT ✓
SEND

Второй:

UNIQUE conflict
→ уже обработано.

Для внешнего API нужен отдельный idempotency strategy

Если provider поддерживает:

Idempotency-Key,

job должна использовать стабильный ключ.

Например:

refund:payment-813:full

а не генерировать новый при каждой retry.


Именно поэтому очередь не решает двойную обработку сама

RabbitMQ тоже использует acknowledgements и redelivery, а его документация прямо предупреждает о возможности повторной доставки и необходимости идемпотентной обработки/дедупликации при соответствующих failure scenarios.

То есть идея:

Перейдём на брокер и проблема дублей исчезнет.

неверна.


Следующая необходимая часть — retry

Не каждая ошибка означает:

Задача окончательно сломана.

Например:

SMTP 503

может исчезнуть через минуту.

Или:

external API timeout.

Поэтому после failure мы не всегда ставим:

FAILED

сразу.


Можно перенести задачу в будущее

Например:

attempt 1
→ +30 секунд

attempt 2
→ +2 минуты

attempt 3
→ +10 минут

attempt 4
→ +30 минут

В базе:

UPDATE jobs
SET
    status = 'pending',
    available_at = $nextAttemptAt,
    locked_by = NULL,
    locked_at = NULL,
    last_error = $error
WHERE id = $jobId;

Worker продолжает выбирать только:

available_at <= now()

Получается delayed queue без отдельного scheduler.


Нужен backoff, а не мгновенный retry-loop

Плохо:

API unavailable
↓
retry
↓
fail
↓
retry
↓
fail
↓
retry...

Теперь одна неисправная интеграция создаёт собственную DDoS-нагрузку.

Backoff даёт внешней системе время восстановиться.


Полезен и jitter

Если:

10 000 jobs

упали одновременно и все имеют:

retry after exactly 60 sec,

через минуту они одновременно проснутся.

Получаем новую волну.

Небольшая случайная добавка к задержке распределяет retry во времени.


После max_attempts job должна стать видимой проблемой

Например:

status = failed

а не исчезнуть.

Это фактически dead-letter state.

Запись сохраняет:

type;
payload;
attempts;
last_error;
created_at.

Администратор или support может понять:

Что именно не получилось?

Нельзя писать catch { continue }

Один из худших worker-паттернов:

try {
  await execute(job);
} catch {
  // ignore
}

Job исчезает из поля зрения.

Пользователь считает:

Система что-то сделала.

Но фактически действие потеряно.


Каждая job должна закончиться одним из явных состояний

Например:

pending
running
completed
failed

Дополнительно:

cancelled

если продукт поддерживает отмену.

Главное — чтобы не существовало невидимого:

«куда-то пропала».

Индекс критически важен

Без подходящего индекса worker будет постоянно сканировать:

миллионы старых completed jobs.

Для queue-like workload обычно нужен partial index по активным задачам.

Например:

CREATE INDEX jobs_ready_idx
ON jobs (
    priority DESC,
    available_at,
    id
)
WHERE status = 'pending';

Теперь completed/failed history не участвует в поиске следующей работы.


Историю можно хранить отдельно или ограничивать retention

Представим:

1 000 000 jobs/day

и каждая completed job хранится навсегда.

Через год таблица становится уже совсем другой системой.

Нужно определить:

сколько хранить successful jobs;
сколько failed jobs;
нужна ли audit history;
можно ли архивировать старые записи.

Queue table не должна бесконтрольно становиться многолетним архивом.


PostgreSQL queue особенно удобна, когда job связана с транзакцией

Рассмотрим реальный бизнес-сценарий.

Пользователь оплатил заказ.

В одной транзакции:

BEGIN;

UPDATE orders
SET status = 'paid'
WHERE id = 1842;

INSERT INTO jobs (
    type,
    payload
)
VALUES (
    'ISSUE_DOCUMENTS',
    '{"orderId":1842}'
);

INSERT INTO jobs (
    type,
    payload
)
VALUES (
    'SEND_PAYMENT_EMAIL',
    '{"orderId":1842}'
);

COMMIT;

Теперь невозможно получить:

Order paid

но забыть создать background jobs из-за сетевой ошибки между PostgreSQL и брокером.


Это почти встроенный transactional outbox

Можно даже пойти дальше.

Вместо технической:

SEND_PAYMENT_EMAIL

писать бизнес-событие:

PAYMENT_CONFIRMED

Worker/dispatcher затем решает:

email;
уведомление;
analytics;
integration.

Если позже продукт вырастет до отдельного message broker, эта таблица уже может стать настоящим transactional outbox.


То есть PostgreSQL-очередь не закрывает путь к RabbitMQ или Kafka

Она может быть эволюционной стадией.

Сегодня:

Business Transaction
      ↓
PostgreSQL job
      ↓
Worker

Позже:

Business Transaction
      ↓
PostgreSQL outbox
      ↓
Publisher
      ↓
RabbitMQ / Kafka

Domain logic при этом меняется значительно меньше.


А как worker узнаёт, что появилась новая job?

Самый простой вариант:

poll every N milliseconds.

Например:

SELECT job
↓
ничего
↓
sleep
↓
SELECT job

Для умеренного продукта этого достаточно.


Polling необязательно означает огромную нагрузку

Если запрос:

индексированный;
короткий;

а worker делает его:

несколько раз в секунду,

это может быть совершенно незаметной нагрузкой.

Не нужно оптимизировать polling, который ещё не стал проблемой.


Но PostgreSQL умеет и LISTEN / NOTIFY

Можно использовать гибрид.

Durable job всё равно сохраняется:

jobs table.

После commit отправляется:

NOTIFY jobs;

Worker подписан:

LISTEN jobs;

и получает сигнал:

Появилась работа.

PostgreSQL поддерживает asynchronous notifications через LISTEN и NOTIFY: подключённые listeners получают notification после соответствующего события.


При этом NOTIFY не должен становиться самой очередью

Правильная модель:

jobs table
→ источник истины

NOTIFY
→ wake-up signal.

Если worker был отключён и не услышал сигнал:

job всё равно осталась
в таблице.

После reconnect worker снова проверит backlog.


Это важный принцип

Не:

NOTIFY получил
→ job существует.

А:

NOTIFY получил
→ возможно, стоит проверить таблицу.

Durability остаётся в PostgreSQL.


Несколько workers масштабируются очень просто

Например:

Worker #1
Worker #2
Worker #3
Worker #4

Все выполняют один и тот же claim query:

FOR UPDATE SKIP LOCKED.

PostgreSQL распределяет доступные строки через row locks.

Не нужен отдельный coordinator:

Кто возьмёт Job #1842?

Сам lock является координацией.


При этом workers могут находиться на разных серверах

Например:

VPS A:
API + Worker 1

VPS B:
Worker 2

VPS C:
Worker 3

Все используют один PostgreSQL.

Физически это уже распределённая обработка.

Но без отдельного message broker.


Можно выбирать jobs пачками

Например:

...
FOR UPDATE SKIP LOCKED
LIMIT 20;

Worker забирает batch.

Это уменьшает количество round trips к БД.

Но batch size нужно подбирать под характер задач.


Слишком большой batch может ухудшить fairness

Worker A забрал:

500 jobs

и обрабатывает их двадцать минут.

Worker B уже свободен.

Но эти задачи считаются claimed Worker A.

Поэтому для тяжёлых heterogeneous jobs лучше небольшие batches.


Приоритеты тоже требуют осторожности

Например:

priority = 100

для критических задач.

priority = 0

для обычных.

Запрос:

ORDER BY priority DESC, available_at, id

работает понятно.

Но если высокоприоритетные jobs прибывают постоянно, низкие могут голодать.


Нужна политика fairness

Например:

старые jobs постепенно
повышают effective priority.

Или workers периодически выделяют часть capacity обычной очереди.

Опять же, очередь — это не только SKIP LOCKED.

Это модель обслуживания нагрузки.


Следующая проблема — разные типы jobs

Например:

EMAIL
PDF
AI_ANALYSIS
STORAGE_DELETE
WEBHOOK

Если один worker pool выполняет всё, тяжёлые AI-задачи могут занять все slots.

Теперь email:

ждёт 20 минут.

Можно разделить workers по queue/type

Например:

Worker Email
→ EMAIL

Worker Integration
→ WEBHOOK

Worker Heavy
→ PDF / AI

При этом физически всё остаётся в одной таблице.

Например:

WHERE status = 'pending'
  AND type = ANY($allowedTypes)

Или добавить queue

queue = 'default'
queue = 'email'
queue = 'heavy'

И индекс учитывать queue.

Это уже достаточно мощная production-модель.


Но чем больше специальных требований, тем ближе мы к брокеру

Если появляется:

десятки queues;
сложный routing;
fan-out;
consumer priorities;
разные delivery policies;
межсервисная messaging-топология,

нужно задать честный вопрос:

Мы всё ещё упрощаем инфраструктуру или уже пишем собственный RabbitMQ внутри PostgreSQL?

Вот здесь проходит важная граница.


Когда RabbitMQ начинает быть естественнее

RabbitMQ изначально проектировался как message broker.

У него есть модель:

publishers;
exchanges;
bindings;
queues;
consumers;
acknowledgements;
prefetch.

Durable queues и persistent messages могут переживать restart брокера; consumer acknowledgements позволяют broker понимать, когда обработанная доставка может быть удалена, а prefetch регулирует количество outstanding сообщений у consumer.

Если именно эти механизмы становятся центральной частью системы, специализированный broker начинает выигрывать.


Например нужна сложная маршрутизация

Одно событие:

ORDER_CREATED

должно попасть:

Billing
Warehouse
Analytics
Email
Fraud Detection

с разными правилами delivery.

Можно построить это на PostgreSQL.

Но всё больше инфраструктуры придётся писать самостоятельно.

RabbitMQ уже имеет специализированную routing model.


Или message processing превратился в отдельную платформу

Например:

50 services;
сотни consumers;
десятки routing rules;
миллионы сообщений;

Теперь PostgreSQL business database может оказаться совсем не тем компонентом, на который хочется дополнительно возлагать огромную messaging-нагрузку.


Kafka решает ещё другую задачу

Kafka полезно рассматривать не просто как:

«очень большую очередь».

Её центральная модель — partitioned retained log.

События сохраняются в topics по политике retention независимо от того, были ли уже прочитаны consumer, а разные consumer groups могут независимо читать один и тот же поток. Порядок гарантируется внутри partition.

Это принципиально отличается от обычной фоновой job:

Сделай PDF
→ выполнили
→ больше не нужна.

Kafka становится интереснее, когда событие нужно многократно читать

Например:

USER_REGISTERED

нужно:

Analytics Team;
Recommendation System;
Fraud;
Data Warehouse;
CRM Sync.

И новый consumer, подключившийся завтра, должен иметь возможность replay исторического event stream.

Это уже совсем другой класс требований.


PostgreSQL job table отлично отвечает на вопрос

Как распределить работу между несколькими workers?

Kafka лучше отвечает на вопрос:

Как хранить и масштабируемо распространять поток событий между независимыми consumer groups?

RabbitMQ:

Как маршрутизировать сообщения к consumers с broker-controlled delivery semantics?

Эти задачи пересекаются.

Но они не идентичны.


Поэтому выбирать нужно не по масштабу бренда

Не:

маленький проект
→ PostgreSQL

средний
→ RabbitMQ

большой
→ Kafka

Такой лестницы не существует.

Небольшой продукт может иметь event-streaming задачу, идеально подходящую Kafka.

А довольно крупная внутренняя CRM может годами прекрасно работать с PostgreSQL jobs.


Нужен workload

Для PostgreSQL-очереди особенно хорошо подходят задачи, когда:

job тесно связана с бизнес-транзакцией;
объём умеренный;
consumers принадлежат одному приложению;
сложный routing не нужен;
replay event history не является продуктовой функцией;
PostgreSQL и так является обязательной зависимостью.

Пример: отложенная публикация статьи

Редактор создаёт статью:

publish_at =
2026-10-10 09:00

Job:

PUBLISH_ARTICLE
available_at =
2026-10-10 09:00

Worker просто не увидит её раньше времени:

WHERE available_at <= now()

Отдельный delayed-message broker здесь может оказаться избыточным.


Пример: отправка письма после создания проекта

В одной transaction:

Project
+
EMAIL job.

Идеальная связь с PostgreSQL.


Пример: удаление объекта из S3

Пользователь удаляет файл.

Сначала database transaction фиксирует:

file status = deleted

и job:

DELETE_OBJECT.

Worker пытается физически удалить объект.

Если S3 временно недоступен:

retry.

Очень естественная durable job.


Пример: webhook

Нужно отправить событие партнёру.

PostgreSQL job хранит:

destination;
event id;
attempts;
next retry;
last error.

Worker выполняет delivery.

Получаем понятный audit trail.


А вот realtime chat fan-out — уже другой вопрос

Если одно сообщение нужно мгновенно распространить:

сотням WebSocket nodes

таблица jobs может оказаться не самым естественным транспортом.

Redis Pub/Sub, специализированный broker или другая messaging-система могут быть удобнее.


То же с большим event stream

Если нужно хранить:

миллиарды событий;

и читать их:

многими независимыми consumer groups

с возможностью replay, PostgreSQL job queue явно начинает решать не свою основную задачу.


Одна из важных границ — database load

PostgreSQL уже обслуживает:

пользователей;
платежи;
проекты;
поиск;
API.

Теперь queue создаёт:

постоянные INSERT;
UPDATE;
SELECT;
VACUUM;
index churn.

При умеренной нагрузке это нормально.

Но очередь может вырасти до такой степени, что начинает конкурировать с business queries.


Тогда broker даёт fault/resource isolation

Можно вынести:

message workload

из основной transactional database.

Теперь spike background jobs не конкурирует за тот же PostgreSQL I/O с пользовательским checkout.

Это уже сильный аргумент.


Но не нужно решать эту проблему заранее

Если:

PostgreSQL CPU = низкий;
disk latency = нормальная;
queue latency = миллисекунды;
backlog = стабильный,

добавление брокера просто ради потенциального будущего роста может не дать текущей пользы.

Сначала измеряем.


Queue latency — одна из основных метрик

Например job создана:

12:00:00

worker начал:

12:00:01

Получаем:

queue wait = 1 sec.

Если бизнес допускает:

до 30 sec,

система работает прекрасно.


Нужно измерять не только количество jobs

Очень полезны:

pending count;
running count;
failed count;
oldest pending age;
processing duration;
attempt distribution;
recovered stale jobs.

Особенно:

oldest pending age.

Почему backlog count иногда обманывает

Например:

10 000 pending jobs.

Выглядит страшно.

Но workers обрабатывают:

5 000/sec.

Backlog исчезнет за две секунды.


Другой случай

30 pending jobs.

Но самая старая ждёт:

6 часов.

Это намного серьёзнее.

Поэтому возраст jobs часто информативнее простого COUNT.


Worker health тоже нельзя измерять только process status

Например:

docker ps
→ Worker UP

Но он час не завершил ни одной job.

Нужны:

heartbeat;
last successful job;
processing rate;
queue age.

То есть PostgreSQL queue тесно связывается с health/readiness архитектурой.


Poison job тоже нужно учитывать

Есть задача, которая всегда падает:

PDF_GENERATE
payload malformed.

Без max_attempts она будет бесконечно:

fail
retry
fail
retry...

Поэтому после определённого количества попыток:

failed

и alert.


Не все ошибки должны retry

Например:

HTTP 503

вероятно, retryable.

Invalid email format

вероятно, нет.

Authentication credentials revoked

может требовать вмешательства, а не сотни повторов.


Worker должен классифицировать errors

Например:

TransientError
→ retry

PermanentError
→ failed

RateLimitError
→ retry after provider delay

Это значительно лучше универсального:

любая ошибка
→ через 5 секунд ещё раз.

Можно добавить уникальность jobs

Представим API два раза вызывает:

scheduleDailyReport(user 52, date X).

Мы не хотим две одинаковые задачи.

Можно использовать:

dedup_key

и database constraint:

UNIQUE (dedup_key)

для нужного жизненного цикла.


PostgreSQL constraints здесь особенно полезны

Очередь находится в той же системе, которая уже умеет enforce:

UNIQUE;
FOREIGN KEY;
CHECK.

Например job может ссылаться на существующий project.

Или database constraint может не позволить создать второй активный job определённого типа.


Но payload не стоит превращать в дамп всего объекта

Например:

{
  "project": {
    "...": "все 200 полей"
  }
}

Через несколько часов проект в БД изменился.

Job несёт старую копию.


Часто лучше хранить идентификатор

{
  "projectId": 1842
}

Worker при выполнении читает актуальное состояние.

Но это зависит от семантики.


Иногда нужен именно snapshot

Например:

Сформировать invoice
с ценами,
которые были на момент покупки.

Тогда job должна не перечитывать текущие цены.

Нужно сохранить immutable business snapshot или ссылку на соответствующую версию данных.


Queue design не отменяет domain modelling

payload JSONB не должен означать:

Складываем туда что угодно и разбираемся потом.

У каждого job type должен быть contract.

Например:

SEND_PROJECT_EMAIL v1

{
  projectId,
  recipientId
}

Versioning job payload тоже может понадобиться

Представим:

старые jobs лежали несколько дней.

После нового release worker ожидает уже другую структуру payload.

Теперь backlog становится несовместимым.


Можно хранить

type = SEND_EMAIL
version = 2

и на переходном этапе поддерживать старый decoder.

Или мигрировать pending jobs контролируемо.

Особенно это важно для delayed jobs, которые могут жить неделями.


Production deployment должен учитывать работающие jobs

Представим Worker v4 выполняет долгую задачу.

В этот момент deployment:

kill worker.

Job обрывается.

Если lease/recovery правильные — она вернётся.

Но пользователь может получить ненужный повтор side effect.


Поэтому worker нужен graceful shutdown

При:

SIGTERM

он перестаёт брать новые задачи.

Текущей позволяет завершиться в пределах grace period.

Затем выходит.

Схема:

SIGTERM
   ↓
stop claiming
   ↓
finish current job
   ↓
exit

Для очень длинных jobs можно выбрать другую recovery policy.


Версии workers тоже лучше разворачивать постепенно

Например:

Worker #1 v4
Worker #2 v4

Обновляем первый:

Worker #1 v5
Worker #2 v4

Некоторое время обе версии работают одновременно.

Значит pending job contract желательно делать совместимым.


Что делать с периодическими задачами

Например:

каждый день 03:00
создать backup.

Необязательно хранить расписание в broker.

Scheduler может создать обычную job:

CREATE_BACKUP

а worker выполнить её.


Но нельзя позволить двум scheduler создать дубль

Если приложение имеет:

API #1
API #2

и оба запускают cron:

03:00.

появятся две jobs.


Здесь можно использовать database uniqueness или advisory lock

PostgreSQL предоставляет application-defined advisory locks, включая неблокирующие pg_try_advisory_*, поэтому для некоторых leader/scheduler сценариев не нужен отдельный coordination service.

Например один scheduler получает lock:

daily-backup:2026-10-03

второй пропускает запуск.

Ещё надёжнее для конкретной бизнес-задачи иногда просто иметь:

UNIQUE(schedule_key).

Database constraint часто сильнее distributed lock

Если правило звучит:

За день должна существовать только одна backup job,

можно enforce:

UNIQUE(job_type, business_date).

Теперь даже при race condition PostgreSQL сам защищает invariant.


Когда PostgreSQL + SKIP LOCKED действительно достаточно

Типичный хороший production-кандидат выглядит так:

одна основная PostgreSQL;
несколько API;
несколько workers;
фоновые jobs связаны с бизнес-данными;
не нужен сложный fan-out;
job throughput остаётся комфортным для БД;
retry измеряется секундами/минутами;
все consumers находятся под контролем одной команды.

Для такой системы отдельный broker может быть не преимуществом, а дополнительным moving part.


Это особенно актуально для модульного монолита

Например:

Application
├── Projects
├── Billing
├── Files
└── Notifications

PostgreSQL
Worker

Jobs остаются частью того же operational boundary.

Не нужно строить distributed infrastructure раньше появления распределённой системы.


Когда мы бы начали смотреть на RabbitMQ серьёзнее

Если messaging itself становится отдельной подсистемой:

много producers;
много consumers;
сложный routing;
нужны разные queues;
consumer prefetch важен;
broker acknowledgements являются частью модели;
нужна изоляция message workload от business database.

Тогда специализированный broker начинает возвращать больше ценности, чем добавляет сложности.


Когда смотреть на Kafka

Когда речь уже не только о jobs:

«выполни эту работу один раз»

а об event platform:

«сохраняй поток событий,
дай многим независимым consumers
читать его в своём темпе,
поддерживай replay».

Kafka хранит записи в partitioned log с configurable retention, а consumer groups позволяют нескольким независимым подписчикам обрабатывать один и тот же поток.

Это совсем другая архитектурная потребность.


Очень полезный вопрос перед внедрением брокера

Не:

Сколько задач в нашей очереди?

А:

Какое конкретное ограничение PostgreSQL-очереди мы уже встретили?

Например:

queue workload мешает OLTP;

или:

нам нужен complex routing;

или:

десятки независимых сервисов должны получать события;

или:

нам нужен replay event history.

Вот это настоящие причины.


А плохая причина звучит так

Все серьёзные проекты используют Kafka.

Архитектура не становится серьёзнее от количества инфраструктурных логотипов на диаграмме.


Production checklist PostgreSQL-очереди

Перед тем как считать такую очередь готовой к боевой эксплуатации, мы бы проверили:

ОбластьЧто должно быть определено
ClaimFOR UPDATE SKIP LOCKED, deterministic ORDER BY, короткая transaction
Statepending / running / completed / failed
Schedulingavailable_at и delayed jobs
Retrybackoff, jitter и классификация ошибок
Limitsmax_attempts и terminal failed state
Recoverystale lock/lease recovery
Long jobsheartbeat либо адекватный execution timeout
Idempotencyповтор job не создаёт повторный business effect
Side effectsстабильные idempotency keys для внешних API, где возможно
Indexespartial/indexed поиск ready jobs
Retentionстарые completed jobs не растут бесконечно
Observabilitypending/running/failed, queue age, processing latency, retries
Workersheartbeat, graceful shutdown, controlled concurrency
Contractsversioned/стабильный payload
Schedulingзащита от duplicate scheduled jobs
Deploymentстарые pending jobs совместимы с новой версией worker
Alertsfailed jobs, stale running jobs и растущий backlog видимы
Loadочередь не разрушает latency основных business queries
Recovery testпадение worker во время job реально проверено

Если все эти свойства реализованы, это уже полноценная production queue.

Не временный cron-скрипт.


Как тестировать SKIP LOCKED

Особенно важен concurrency test.

Создаём:

100 jobs

Запускаем одновременно:

10 workers.

После завершения ожидаем:

completed = 100

и:

каждый job ID
обработан по допустимой семантике.

Но тест без падений слишком оптимистичен

Нужно ещё:

Worker A claims job
↓
kill -9 Worker A
↓
wait lease timeout
↓
Worker B recovers job
↓
job completes

Так мы проверяем настоящее recovery.


Следующий тест — crash после side effect

Например:

worker sends external request
↓
external request succeeds
↓
worker crashes
↓
job returns to queue

Теперь становится видно:

Действительно ли operation idempotent?

Это один из самых ценных тестов всей очереди.


Ещё тест — массовый сбой внешнего API

Создаём:

5 000 webhook jobs.

Provider возвращает:

503.

Ожидаем:

jobs не крутятся бесконечно;
CPU не уходит в 100%;
retry распределён backoff/jitter;
основной API продолжает работать.

То есть проверяем не только happy path.


Нужно тестировать и recovery после restart PostgreSQL

Очередь хранится в durable business database.

После перезапуска:

pending jobs остаются;
running jobs recoverable;
completed jobs не выполняются заново просто из-за restart.

Это реальное production-свойство.


И самое главное — нужно измерять

Допустим PostgreSQL queue стабильно обрабатывает вашу реальную нагрузку.

P95 claim latency небольшая.

Business queries не деградируют.

Backlog не растёт.

Workers масштабируются.

В этой ситуации вопрос:

Может пора Kafka?

не имеет технической причины.


Архитектурная простота тоже является характеристикой production

Иногда её недооценивают.

Система:

Application
PostgreSQL
Worker

имеет меньше:

сетевых связей;
credentials;
health-check;
backup policies;
deployment targets.

чем:

Application
PostgreSQL
RabbitMQ
Worker

Это не означает:

меньше технологий всегда лучше.

Это означает:

каждая дополнительная технология должна окупать собственное существование.

Важно считать стоимость аварии

Если PostgreSQL недоступен, основной продукт всё равно, вероятно, серьёзно деградировал.

Потеря очереди в тот же момент может не создавать новый независимый класс outage.


С отдельным брокером появляется другая отказоустойчивость

PostgreSQL:

UP

RabbitMQ:

DOWN

Теперь бизнес-приложение работает.

Но background processing остановлен.

Это может быть преимуществом изоляции.

Но это также ещё одно состояние, которое support должен диагностировать.


То есть разделение не бесплатно

Мы получаем:

изоляцию;
специализированные функции;
масштабирование messaging.

И покупаем их за:

операционную сложность.

Один из лучших признаков зрелой архитектуры — умение не добавлять технологию раньше времени

Профессиональная архитектура не обязана выглядеть:

PostgreSQL
Redis
RabbitMQ
Kafka
Elasticsearch
Kubernetes

если продукту всё это не нужно.

Иногда зрелая схема выглядит:

Nginx
   ↓
Application × 2
   ↓
PostgreSQL
   ↑
Workers × 4

И при этом:

очередь durable;
retry работает;
workers восстанавливаются;
дубли контролируются;
monitoring настроен.

Это полноценный production.


А когда требования вырастут — путь вперёд остаётся

Можно сохранить:

jobs

для внутренних background operations.

А domain events начать публиковать через:

outbox → RabbitMQ

или:

outbox → Kafka.

Не нужно мигрировать всю систему одной ночью.


Например промежуточная архитектура

                 PostgreSQL
                 /        \
                /          \
      Internal jobs        Outbox
             │                │
          Workers          Publisher
                              │
                              ▼
                         Message Broker

Локальные задачи:

cleanup;
email;
storage;

остаются простыми.

Distributed events уходят наружу.

Это вполне нормальная гибридная архитектура.


Не существует правила «одна очередь на всё»

Технологии можно использовать там, где подходят их свойства.

PostgreSQL:

business-adjacent jobs.

RabbitMQ:

brokered commands/routing.

Kafka:

retained event streams.

Не обязательно объявлять одного победителя.


Вместо вывода

FOR UPDATE SKIP LOCKED — небольшая возможность PostgreSQL, из которой можно построить удивительно практичную production-систему фоновых задач.

Она позволяет нескольким workers конкурентно брать разные строки queue-like table, не ожидая locks друг друга. PostgreSQL прямо приводит очередь с несколькими consumers как один из сценариев, где SKIP LOCKED уместен.

Но сама строка:

FOR UPDATE SKIP LOCKED

ещё не делает очередь production-ready.

Нужны:

короткий claim;
lease/recovery;
retry;
backoff;
max attempts;
idempotency;
индексы;
retention;
monitoring;
graceful shutdown;
failure tests.

Когда всё это реализовано, для большого класса CRM, SaaS, B2B-сервисов и модульных монолитов получается очень сильная архитектура:

Business transaction
        ↓
   PostgreSQL
    /       \
Data        Jobs
             ↓
          Workers

Её главное преимущество не в том, что PostgreSQL «быстрее RabbitMQ».

Главное — отсутствие лишней распределённой границы там, где она ещё не нужна.

Бизнес-изменение и создание job можно зафиксировать одной транзакцией.

Очередь использует уже существующую систему backup, мониторинга и доступа.

Workers легко масштабируются.

Recovery понятен.

А количество moving parts остаётся небольшим.

RabbitMQ становится более естественным, когда сама messaging-топология начинает требовать развитой маршрутизации, acknowledgement/prefetch-механики и независимого жизненного цикла брокера. Kafka — когда продукту нужен уже не просто список работ, а долговечный partitioned event stream для нескольких независимых consumer groups и replay.

Поэтому хороший архитектурный вопрос звучит не:

«Можно ли сделать очередь на PostgreSQL?»

Можно.

Гораздо полезнее другой:

«Какие требования нашей системы PostgreSQL-очередь уже перестала удовлетворять?»

Пока на этот вопрос нет конкретного ответа, PostgreSQL + SKIP LOCKED + несколько правильно спроектированных workers может быть не временным компромиссом, а вполне качественным production-решением.

Есть похожая задача?

Опишите продукт, интеграции и ограничения. До разработки зафиксируем объём, риски и критерии приёмки.

Обсудить проект →