Фоновые задания и очереди: retries, DLQ, cron, outbox и согласованность
Фоновые задания появляются там, где надёжность важнее немедленного ответа HTTP. Как только вы переносите работу в фон, меняются правила игры:
выполнение становится конкурентным и распределённым;
большинство систем доставки работают в режиме at-least-once (возможны повторы);
ошибки перестают быть “ответом клиенту”, а становятся состоянием джобы;
согласованность данных становится отдельной задачей.В предыдущих статьях курса мы построили основу:
как классифицировать и централизованно обрабатывать ошибки (Node.js, Express, Nest);
как конкурентность приводит к гонкам и почему идемпотентность и транзакции обязательны.Теперь переносим эти принципы в фон: retries, DLQ, cron, паттерн transactional outbox и практики согласованности.
!Общая архитектура: API, outbox, очередь, воркеры и DLQ
Что такое фоновые задания
Фоновая джоба это единица работы, которая выполняется вне HTTP-обработчика. Типовые примеры:
отправка email или push;
генерация отчёта;
обработка медиа;
синхронизация с внешними системами;
периодические задачи (очистка, пересчёты).Почти всегда цель фона одна: сделать систему устойчивой к задержкам и сбоям зависимостей, не удерживая HTTP-соединение и ресурсы.
Очередь и семантика доставки
Когда вы используете очередь (Redis-based, брокер сообщений, managed queue), важно понимать базовую терминологию.
Producer, consumer, worker
Producer публикует джобу.
Consumer получает джобу из очереди.
Worker выполняет джобу и фиксирует результат.Один процесс часто совмещает роли, но надёжность проще строить, когда роли разделены.
Ack, retry и почему повторы нормальны
Большинство очередей и job-раннеров обеспечивают не “ровно один раз”, а at-least-once:
джоба может быть доставлена повторно из-за падения воркера;
джоба может быть обработана повторно из-за таймаута подтверждения;
сеть может дать дубликаты;
вы сами включите retries.Практический вывод:
идемпотентность воркера обязательна, если задача меняет состояние.Для managed очередей полезно знать термин visibility timeout: сообщение “спрятано” на время обработки, и если воркер не подтвердил выполнение, сообщение возвращается в очередь.
Документация Amazon SQS: Visibility TimeoutRetries: как повторять правильно
Retries нужны, потому что часть ошибок операционные и временные: таймауты, сетевые сбои, 503, временно недоступная БД или провайдер.
Связь с предыдущими модулями про error handling:
повторяем только повторяемые (retryable) ошибки;
ошибки валидации и доменные конфликты обычно не повторяются.Классификация ошибок для джоб
Практичная схема:
retryable: таймаут, 429, 503, reset соединения, временная блокировка ресурса;
non-retryable: невалидный payload, отсутствует сущность, нарушено доменное правило;
unknown: считаем багом или неожиданным сбоем, обычно ограниченное число ретраев и затем DLQ.Backoff и jitter
Если ретраить “сразу”, вы усиливаете аварию: зависимость перегружена, а вы шлёте ещё больше запросов.
Поэтому почти всегда используют:
exponential backoff: задержка растёт с каждой попыткой;
jitter: добавление случайности, чтобы воркеры не ретраили синхронно.AWS Architecture Blog: Exponential Backoff And JitterОграничение времени жизни джобы
Ретраи без ограничений превращаются в бесконечный “шум”. На практике вводят:
лимит попыток;
общий TTL задачи;
отдельные лимиты на шаги (например, внешний API не дольше N минут).DLQ: Dead Letter Queue и “ядовитые” сообщения
DLQ это место, куда попадают задачи, которые не удалось обработать автоматически.
Причины, почему задача уходит в DLQ:
исчерпан лимит retries;
payload некорректен;
задача систематически ломает воркеры (например, баг);
бизнес-правило требует ручного разбора.DLQ нужна не для “склада ошибок”, а для управляемости:
отдельная алертизация;
возможность ручного requeue после фикса;
возможность анализировать причины по статистике.!Жизненный цикл задачи с retries и DLQ
Идемпотентность воркеров: фундамент надёжности “at-least-once”
Если джоба может выполниться дважды, воркер должен быть безопасен к повторам.
Типовые стратегии:
идемпотентный ключ: jobId или бизнес-ключ (например, paymentId);
таблица обработанных событий с уникальным индексом по ключу;
upsert вместо “создать если нет”;
уникальные ограничения в БД как последняя линия защиты.Пример: фиксируем обработку jobKey в таблице, чтобы повтор не менял состояние:
Псевдологика воркера:
Ключевой момент: запись об обработке должна делаться так, чтобы конкурирующие воркеры не прошли одновременно. Обычно это делается уникальным индексом и атомарной вставкой.
Cron против очередей: разные классы задач
Cron это планирование по времени: “каждые 5 минут”, “в полночь”. Очередь это реакция на событие: “после создания заказа отправь письмо”.
Проблема cron в распределённой среде:
если у вас 10 инстансов, cron запустится 10 раз.Есть два базовых подхода:
single leader: только один инстанс выполняет cron-задачу;
sharding: все инстансы выполняют часть работы, но без пересечений.Node.js инструменты для cron-подобных задач:
Bree
node-cronА на уровне оркестратора:
Kubernetes CronJobПрактическая рекомендация:
cron хорошо подходит для периодических задач без строгих требований к точности;
если задача критична и должна быть выполнена гарантированно, чаще удобнее моделировать её как очередь + планировщик, который кладёт джобы.Как запускать “один инстанс из многих”: distributed locks
Чтобы cron-задача выполнялась в одном экземпляре, нужен механизм взаимного исключения.
Вариант: advisory locks в PostgreSQL
Если у вас уже есть PostgreSQL, часто удобно использовать advisory locks.
PostgreSQL: Advisory LocksИдея:
при запуске задачи пробуем взять lock;
кто взял lock, тот выполняет;
остальные пропускают.Вариант: Redis distributed locks
Для Redis есть известный подход Redlock. Важно относиться к нему осознанно и понимать ограничения.
Redis documentation: Distributed LocksПрактическое правило:
любой lock должен иметь TTL, чтобы “не зависнуть навсегда” при падении владельца;
лидерство должно обновляться, если задача длительная.Transactional outbox: решаем проблему dual write
Одна из самых дорогих проблем в фоновых системах: dual write.
Сценарий:
вы в HTTP-транзакции записали изменения в БД;
затем попытались отправить сообщение в очередь;
между ними сервис упал или сеть отвалилась;
в итоге БД обновилась, а событие не ушло, либо наоборот.Transactional outbox решает это так:
вы пишете бизнес-данные и запись “что надо отправить” в одну транзакцию;
отдельный dispatcher читает outbox и публикует в очередь;
после успешной публикации помечает outbox-сообщение отправленным.Классическое описание паттерна:
microservices.io: Transactional OutboxМинимальная схема таблицы outbox
Запись в outbox в той же транзакции, что и бизнес-изменения
Dispatcher: конкурентная отправка без дублей
Если dispatcher-ов несколько, они не должны “забирать” одни и те же строки. Для PostgreSQL часто используют FOR UPDATE SKIP LOCKED.
Поток dispatcher-а:
взять пачку outbox-сообщений с блокировкой строк;
опубликовать в очередь;
пометить published_at.Даже при повторной публикации устойчивость достигается идемпотентностью потребителя.
Очередь в Node.js: практический пример с BullMQ
Для Redis-based очередей в Node.js часто используют BullMQ.
BullMQProducer: кладём задачу
Здесь важно:
attempts и backoff задают базовую стратегию retries;
jobKey пригодится для идемпотентности;
requestId связывает логи HTTP и воркера.Worker: обрабатываем и классифицируем ошибки
Связь с предыдущими статьями про error handling:
внутри воркера вы всё так же разделяете доменные и технические ошибки;
но “ответом” становится не HTTP, а переход задачи в состояние completed или failed, возможно с DLQ.Согласованность: что именно гарантируем
В фоновых системах чаще всего реалистичная цель не “мгновенная консистентность”, а eventual consistency:
HTTP-операция фиксирует факт (например, “заказ создан”);
фон доводит систему до полного состояния (письма, синхронизации, вторичные индексы).Чтобы это было надёжно, вы формулируете инварианты:
что должно быть строго атомарно в транзакции (например, “заказ создан”);
что допустимо “догнать” позже (например, “email отправлен”);
какие повторения допустимы (обычно любые), если воркер идемпотентен.Наблюдаемость для джоб
Без наблюдаемости фон быстро превращается в “чёрный ящик”. Минимальный набор:
метрики количества completed и failed по типам джоб;
метрики retries: среднее число попыток, доля задач, дошедших до DLQ;
время в очереди (queue latency) и время выполнения (processing time);
алерт по росту backlog (очередь растёт быстрее, чем воркеры успевают);
логи с jobId, jobName, requestId, attempt, и доменным error.code.Связь с фундаментом курса:
если воркер делает CPU-тяжёлую работу, он так же блокирует event loop, как и HTTP-сервис;
backpressure теперь выражается не в streams, а в контроле конкурентности воркеров и скорости публикации.Практический чеклист
Делайте воркеры идемпотентными, исходя из at-least-once.
Вводите retries только для retryable ошибок и используйте backoff.
Используйте DLQ как управляемый контур: алерты, анализ, requeue.
Для cron в мульти-инстанс среде используйте leader/lock или переносите периодику в очередь.
Для надёжной публикации событий используйте transactional outbox.
Прокидывайте requestId или аналог корреляции в джобы.
Мерите backlog, длительность, retries и долю DLQ.Что дальше в курсе
Следующий слой после очередей и outbox:
стратегии масштабирования воркеров и лимитов конкурентности;
graceful shutdown воркеров и безопасная остановка без потери задач;
гарантии “ровно один инстанс” для периодических задач в Kubernetes и в bare-metal среде;
практики миграции от “cron внутри приложения” к очередям.