У веб-продукта появляется первая фоновая задача.
Например после регистрации нужно отправить письмо.
Потом появляются:
уведомления;
обработка файлов;
публикация отложенных материалов;
очистка временных данных;
вызовы внешних 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_CONFIRMEDWorker/dispatcher затем решает:
email;
уведомление;
analytics;
integration.Если позже продукт вырастет до отдельного message broker, эта таблица уже может стать настоящим transactional outbox.
То есть PostgreSQL-очередь не закрывает путь к RabbitMQ или Kafka
Она может быть эволюционной стадией.
Сегодня:
Business Transaction
↓
PostgreSQL job
↓
WorkerПозже:
Business Transaction
↓
PostgreSQL outbox
↓
Publisher
↓
RabbitMQ / KafkaDomain 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:00Job:
PUBLISH_ARTICLE
available_at =
2026-10-10 09:00Worker просто не увидит её раньше времени:
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:00worker начал:
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
WorkerJobs остаются частью того же 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-очереди
Перед тем как считать такую очередь готовой к боевой эксплуатации, мы бы проверили:
| Область | Что должно быть определено |
|---|---|
| Claim | FOR UPDATE SKIP LOCKED, deterministic ORDER BY, короткая transaction |
| State | pending / running / completed / failed |
| Scheduling | available_at и delayed jobs |
| Retry | backoff, jitter и классификация ошибок |
| Limits | max_attempts и terminal failed state |
| Recovery | stale lock/lease recovery |
| Long jobs | heartbeat либо адекватный execution timeout |
| Idempotency | повтор job не создаёт повторный business effect |
| Side effects | стабильные idempotency keys для внешних API, где возможно |
| Indexes | partial/indexed поиск ready jobs |
| Retention | старые completed jobs не растут бесконечно |
| Observability | pending/running/failed, queue age, processing latency, retries |
| Workers | heartbeat, graceful shutdown, controlled concurrency |
| Contracts | versioned/стабильный payload |
| Scheduling | защита от duplicate scheduled jobs |
| Deployment | старые pending jobs совместимы с новой версией worker |
| Alerts | failed 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:
UPRabbitMQ:
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-решением.