Анатомія надійної черги задач: 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,cancelledpayload(JSONB) іresult(JSONB)retry_count,max_retries,last_errorpriority(діапазон обмежений),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-вираз у реалізації —
Порівняння з альтернативами: про різні категорії, а не «хто кращий»
Коли люди кажуть «але ж є 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 всюди в критичних апдейтах).

13 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів