Анатомія надійної черги задач: Postgres, leases і все, що ламається в продакшені

Вітаю, спільното! Мене звати Євген, я Software Engineer з 15+ роками досвіду. Починав з PHP, далі були JavaScript/TypeScript і Python, а останнім часом багато пишу на Rust. Ця стаття буде цікава тим, хто будує бекенди з фоновими воркерами: черги задач, retries, планування, відновлення після збоїв і реальний дебаг у продакшені.

Я хочу поділитися досвідом створення Spooled Cloud — open-source менеджера фонових задач, яку я робив як окремий сервіс (а не як бібліотеку під одну мову). Нижче — про рішення, які я приймав саме як інженер: чому така модель джобу, як влаштований конкурентний dequeue, чому lease потрібен навіть з Postgres-локами, як я зробив retries з backoff, DLQ, idempotency і видимість.

З чого все почалося: типові болі черг

У різних продуктах я знов і знов бачив одні й ті самі класи інцидентів:

  • Фонові задачі «десь зникли» або «десь зависли», і ніхто не розуміє де.
  • Зовнішній API ліг на 10 хвилин — після підйому його добиває шквал повторів (retry storm).
  • Дублікати: повторний виклик, повторний клік, повторна доставка — і система робить роботу двічі (інколи з грошима).
  • Відсутня видимість: без аудит-трейлу ви не відповісте на просте питання «що сталося з конкретною задачею?».

Мені хотілося систему, де «черга» — це не просто push/pop, а передбачувана модель життєвого циклу з інваріантами та інструментами для операційної підтримки.

Чому сервіс, а не бібліотека

Я поважаю бібліотечні підходи (Sidekiq/Celery/BullMQ тощо) — вони дуже ефективні в межах однієї мови і одного застосунку. Але як тільки в компанії з’являється кілька сервісів/скриптів/різних мов, починається фрагментація:

  • різні реалізації retries і backoff,
  • різна видимість (або її немає),
  • різні формати payload/result,
  • різні правила дедуплікації.

Тому я пішов у модель «одна черга як сервіс» + thin SDK/клієнти: будь-яка частина системи може ставити задачі, а будь-який воркер — забирати й обробляти. Центр спостереження теж один.

Postgres як фундамент: чому це працює

Я зробив Postgres єдиним джерелом правди. Мотивація проста:

  • транзакції та блокування вже є,
  • є стандартні механізми бекапів/реплікації,
  • SQL — реальний інструмент дебагу,
  • менше інфраструктури = менше точок відмови.

Так, у певних навантаженнях брокери (Kafka/Redis Streams і т.д.) можуть бути швидші. Але мій фокус був на «надійно, передбачувано, просто розгортати і підтримувати».

Модель джобу: які поля реально потрібні

Серце системи — таблиця jobs. Я свідомо тримав її такою, щоб:

1) вистачало для продакшену, 2) можна було пояснити її інваріанти, 3) вона лишалась «зрозумілою SQL-ом».

Ключові поля:

  • status: pending, scheduled, processing, completed, failed, deadletter, cancelled
  • payload (JSONB) і result (JSONB)
  • retry_count, max_retries, last_error
  • priority (діапазон обмежений), timeout_seconds
  • таймінги: created_at, scheduled_at, started_at, completed_at, expires_at
  • tags (JSONB) — для фільтрації/кореляції
  • lease-поля: assigned_worker_id, lease_id, lease_expires_at
  • idempotency_key + унікальний індекс на (organization_id, idempotency_key) (коли ключ не NULL)

Окремо я додав:

  • job_history — аудит подій джобу (created/processing/retry_scheduled/completed/deadlettered тощо)
  • dead_letter_queue — окрема таблиця для «остаточно впалих» задач з копією payload і деталями помилки
  • queue_config — конфігурація черги (наприклад, можливість поставити чергу на паузу)

Це дрібниця на папері, але на практиці саме job_history і dead_letter_queue економлять години під час інцидентів.

Конкурентний dequeue: FOR UPDATE SKIP LOCKED + batch

Найважливіша операція — «забрати джоб з черги» так, щоб:

  • двоє воркерів не взяли один і той же джоб,
  • воркери не блокували один одного,
  • черга працювала горизонтально (кілька воркерів/нод),
  • не було дублювання під високою конкуренцією.

У Spooled я використовую Postgres-патерн FOR UPDATE SKIP LOCKED. Ідея:

1) транзакційно вибираємо один (або N) «eligible» джобів, 2) блокуємо їх на час транзакції, 3) одразу ж оновлюємо статус і lease-поля, 4) повертаємо джоб воркеру.

Скелет батч-захоплення виглядає так:

WITH eligible_jobs AS (
  SELECT id
  FROM jobs
  WHERE organization_id = $org
    AND queue_name = $queue
    AND status IN ('pending', 'scheduled')
    AND (scheduled_at IS NULL OR scheduled_at <= NOW())
    AND (expires_at IS NULL OR expires_at > NOW())
  ORDER BY priority DESC, created_at ASC
  LIMIT $batch
  FOR UPDATE SKIP LOCKED
)
UPDATE jobs
SET status = 'processing',
    assigned_worker_id = $worker,
    lease_id = $lease,
    lease_expires_at = NOW() + INTERVAL '60 seconds',
    started_at = NOW(),
    updated_at = NOW()
FROM eligible_jobs
WHERE jobs.id = eligible_jobs.id
RETURNING jobs.*;

Важливі деталі, які неочевидні, поки не побудуєш це сам:

  • Врахування scheduled_at: retries і cron зводяться до «звичайних джобів, які можна брати не раніше певного часу».
  • Врахування expires_at: якщо джоб протермінований — його не треба брати взагалі.
  • Порядок сортування: priority DESC, created_at ASC дає передбачуваність і мінімальну «голодовку».
  • Batch dequeue зменшує кількість round-trip’ів до БД і добре масштабується по воркерах.

Навіщо lease, якщо є SKIP LOCKED

FOR UPDATE SKIP LOCKED вирішує конкурентний вибір. Але він не вирішує класичну проблему «воркер помер під час виконання».

Якщо воркер впав після того, як перевів джоб у processing, цей джоб вже не в pending — і ніхто його не підхопить, якщо не зробити додаткового механізму.

Тому я використовую lease з TTL:

  • при dequeue виставляється lease_expires_at = now + lease_duration
  • воркер періодично може оновлювати lease для довгих задач
  • планувальник/maintenance-процес відновлює «прострочені» leases: повертає задачі назад у pending (або переводить у deadletter, якщо перевищено max_retries)

Щоб не було «вічного локапу», lease_duration я обмежив до безпечного діапазону (мінімум — секунди, максимум — до години). Якщо воркер просить більше — значить він має робити renew, а не блокувати джоб на довго.

Retries: exponential backoff у секундах + jitter

Просто «повторити пізніше» — недостатньо. Важливо не створювати піки.

У Spooled при fail логіка така:

  • якщо retry_count < max_retries:
  • збільшуємо retry_count,
  • ставимо status = 'pending',
  • виставляємо scheduled_at = now + backoff,
  • очищаємо assigned_worker_id/lease_*,
  • записуємо подію в job_history типу retry_scheduled.
  • інакше:
  • переводимо в deadletter,
  • копіюємо дані в dead_letter_queue для подальшого розбору.

Backoff — експоненційний, з капом і маленьким jitter:

  • 1s, 2s, 4s, 8s, 16s, 32s, 60s (cap) + до ~500ms jitter

Ключове: джоб лишається звичайним джобом в таблиці, просто з scheduled_at в майбутньому. Це означає, що одна й та сама операція dequeue працює і для первинних задач, і для повторів.

DLQ і replay: що робити з «остаточно впалими»

Коли задача остаточно не виконалась, вона має стати видимою і керованою.

Я виніс DLQ окремо, щоб:

  • можна було дивитись, фільтрувати і сортувати DLQ без шуму «живої» черги,
  • зберігати копію payload і деталей помилки в більш «інцидентно-дружньому» вигляді,
  • робити replay (перестворення джоба з тим самим payload) контрольовано, вже після фіксів або змін в зовнішній системі.

Idempotency: як не плодити дублікати у реальному світі

Дублікати — це не виняток. Це нормальний стан систем, які взаємодіють через мережу.

Тому enqueue підтримує idempotency_key. На рівні БД є унікальний індекс на (organization_id, idempotency_key) для не-NULL ключів, а вставка робиться через ON CONFLICT ... DO UPDATE ... RETURNING, щоб:

  • атомарно повернути існуючий джоб, якщо ключ уже був,
  • не мати гонок «перевірив — потім вставив».

Це дозволяє клієнту викликати enqueue хоч 10 разів і отримувати один і той самий job_id, не створюючи зайвої роботи.

Скасування задач: що реально можливо

Питання «чи можна скасувати running job» завжди складніше, ніж здається.

На рівні черги я можу гарантовано скасувати pending/scheduled — вони просто не будуть видані воркерам.

А от «вбити» вже виконувану задачу насильно — це вже про керування процесами/контейнерами/хостами і про те, чи сама задача безпечно переривається. Тому мій базовий підхід — кооперативне скасування: воркер періодично перевіряє прапорець/статус і завершує виконання сам. Це не магія, але це передбачувано і не ламає інваріанти.

Планування (cron) як доповнення до черги

Щоб не змішувати «планувальник» і «чергу», я зробив schedules окремим шаром:

  • schedules: cron_expression, timezone, payload_template, next_run_at, інші параметри
  • schedule_runs: історія спрацювань

Планувальник періодично створює звичайні джоби в jobs, і далі їх обробляє та сама черга.

Важливий нюанс: cron-вираз у реалізації — 6-польовий (із секундами). Це дозволяє робити часті графіки і простіше тестувати.

Порівняння з альтернативами: про різні категорії, а не «хто кращий»

Коли люди кажуть «але ж є X», часто виявляється, що X з іншої категорії:

  • River queue (open-source) — сильний варіант для Go-проєктів як бібліотека «всередині» застосунку.
  • Sidekiq/Celery/BullMQ (open-source ядро) — дуже практично в межах однієї мови, але складніше уніфікувати поліглотні системи.
  • Temporal (open-source/enterprise) — максимальна потужність workflow-оркестрації, але й більша складність інтеграції.
  • SQS/Cloud Tasks (managed, closed) — мінімум власної підтримки, але інші компроміси (lock-in, дебаг, інтеграційні особливості).

Spooled Cloud я будував як «середину»: окремий сервіс з Postgres-фундаментом, чіткими інваріантами черги, retries/DLQ/idempotency та зручністю для систем, де компонентів багато і вони різні.

Що я виніс з цієї роботи

1) Черги — це 80% про edge cases: падіння воркерів, дедуплікація, порядок обробки, керовані retries, видимість.
2) Postgres з FOR UPDATE SKIP LOCKED — дуже сильна база для практичної черги, якщо правильно тримати інваріанти.
3) Аудит (job_history) і DLQ — це не «nice to have», а фундамент для підтримки продакшену.
4) Немає ідеальної універсальної черги: важливіше чесно обрати категорію і зробити її якісно.

Якщо буде цікаво, можу зробити продовження з більш прикладними деталями: як я вибирав індекси для jobs, як тестував конкурентний dequeue, або як організував безпечні операції з multi-tenant моделлю (organization_id всюди в критичних апдейтах).

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось12
До обраногоВ обраному5
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

От чому тут усюди використовується «черга»?? Тут немає ніяких черг а виключно планування завдач за розкладом. Черга це дещо інше.

Подтверждаю это рабочий вариант. 5 лет в проде под средней нагрузкой работало четко.
.
Основной концепт

FOR UPDATE SKIP LOCKED

Остальное все обвязка.

Дякую за цікаву і якісну статтю!

Цікава стаття, дякую

Дещо здивувався, що у топіку про черги не було абсолютно жодної згадки rabbitmq ))
Стосовно використання постгреса як сховища черг — є нодівська ліба pg-boss, яка наче працює дуже схоже до того, як ви описали.

Анатомія надійної черги задач
Ця стаття буде цікава тим, хто будує бекенди з фоновими воркерами: черги задач, retries, планування, відновлення після збоїв і реальний дебаг у продакшені.

Здається, що ми говоримо не про чергу, а менеджер (бекграунд) задач.
Якщо так, то які альтернативи розглядались? Чим не підійшов temporal.io?

Дякую, виправив в тексті.
Temporal дивився, схоже що класний інструмент, але для моїх задач це вже трохи too much. Мені хотілось простіше: окремий standalone сервіс на Postgres з REST/gRPC API, щоб різні сервіси/скрипти на різних мовах працювали однаково. Тому тут радше «надійна черга» з retries/DLQ/scheduling, ніж повноцінна платформа оркестрації.

Так наче темпорал відповідає вимогам.
Окремий сервіс? Так
На постгрі? Так
Однаково для різних мов? Так

Наскільки я розумію, Temporal — це все ж таки інша парадигма (workflows as code, event sourcing, replay), яка тягне за собою суттєво вищий поріг входу і оверхед у підтримці. Але, можливо, я не правий.
У будь-якому випадку, мені хотілося зробити інструмент, який буде максимально «прозорим» і простим в експлуатації для моїх задач, а вже в процесі це переросло в ідею окремого сервісу. Перший клієнт, звісно, я сам.

Наскільки я розумію, Temporal — це все ж таки інша парадигма (workflows as code, event sourcing, replay), яка тягне за собою суттєво вищий поріг входу і оверхед у підтримці.

Для вашого сценарію оверхед в підтримці буде менше ніж навіть витрати на вивчення використаних у вас абстракцій.

Так наче темпорал відповідає вимогам.

Схоже він має «фатальний недолік»

Напевно тим що це не безоплатний сервіс
судячі з temporal.io/pricing

Напевно тим що це не безоплатний сервіс
судячі з temporal.io/pricing

Ну якщо ви хочете клауд, то доведеться платити.
А якщо селфхост docs.temporal.io/...​f-hosted-guide/deployment то воно безоплатне

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

Підписатись на коментарі