Очереди фоновых задач без сложной инфраструктуры: как запускать email, отчеты и вебхуки с ретраями, backoff и DLQ, без лишних платформ.

Фоновая задача - это работа, которую лучше не делать в том же запросе, где пользователь ждет ответ. Пользователь получает быстрый отклик, а система спокойно доделывает остальное за кулисами.
Почти всегда вредно выполнять прямо в обработчике запроса такие вещи, как отправка email, сбор и выгрузка отчетов, вызовы вебхуков во внешние сервисы. Они могут занимать секунды или минуты и зависят от чужих систем, которые не обязаны отвечать быстро.
Пора выносить работу в фон, если:
Представьте: пользователь оформляет заказ, а вы сразу дергаете платежный сервис, CRM и еще отправляете письмо. Если CRM притормозит, страдает весь сценарий. С фоновой задачей вы подтверждаете заказ сразу, а интеграции догоняют позже, с повторами и паузами.
На старте обычно не нужны тяжелые платформы и отдельные кластеры. Важнее добиться предсказуемого поведения: чтобы задачи не терялись, безопасно повторялись, а ошибки можно было разбирать. Очередь в базе или простая очередь в памяти с сохранением состояния часто закрывает 80% потребностей.
Главная цель фоновых задач - надежно доставлять работу и контролировать сбои, а не строить инфраструктуру ради инфраструктуры.
Если вы хотите очереди фоновых задач без сложной инфраструктуры, начните с того, что уже есть в проекте. Чаще всего это база данных. Второй вариант - Redis, когда нужна скорость и короткая жизнь сообщений.
Очередь в PostgreSQL хорошо подходит, когда задач не миллионы в минуту, но важны надежность и понятная отладка. Задача лежит в таблице: вы видите статус, число попыток, время следующего запуска. Бэкапы, миграции, права доступа и аудит обычно уже настроены теми же инструментами, что и для остального приложения.
Практичный сценарий: пользователь запросил отчет, вы создали запись задачи со статусом queued, воркер забрал ее, сделал отчет и сохранил результат. Если процесс упал, задача не пропадает: она все еще в БД.
Redis удобен как быстрый буфер для задач, которые должны стартовать почти мгновенно: отправка email, быстрая обработка вебхуков, прогрев кеша. Но заранее решите, что для вас приемлемо: сохраняются ли данные на диск, что будет при рестарте, как вы избегаете потери сообщений и дублей.
Cron и встроенные планировщики подходят для регулярных задач по расписанию (например, раз в сутки пересчитать статистику). Но они не заменяют очередь, когда задач много, они зависят от действий пользователей, и вам нужны повторы, контроль попыток и понятная история выполнения.
Если выбор неочевиден, ориентируйтесь на смысл:
В простых очередях важнее не транспорт, а понятная модель задачи. Она делает ретраи безопасными, упрощает поддержку и снижает число странных багов.
В задаче обычно достаточно хранить:
send_email, build_report, call_webhook);run_at (время следующего запуска), дедлайн (после которого задача теряет смысл), последний текст ошибки.Статусы лучше держать простыми:
queued - задача готова к обработкеprocessing - воркер взял задачу в работуdone - успешно выполненоretry - попытка не удалась, будет повторdead_letter - больше не трогаем автоматически, нужна ручная проверкаИдемпотентность простыми словами: если задача выполнится два раза, итог не должен стать "в два раза сильнее". Повтор неизбежен: воркер может упасть до записи done, сеть может обрубиться, внешнее API может ответить нестабильно.
Пример: отправка письма. Если просто дергать "отправить", повтор может отправить дубль. Лучше добавить идемпотентный ключ (например, idempotency_key или event_id) и использовать его на стороне отправки: "если письмо с таким ключом уже отправляли, не отправляй повторно". Для отчетов идемпотентность часто достигается записью результата под фиксированным ключом (например, "отчет за период X для пользователя Y") вместо "создать новый".
Чтобы уменьшить риск дублей, обычно хватает нескольких правил: уникальный ключ на смысл задачи, проверка "уже сделано" перед выполнением, атомарная фиксация результата и статуса там, где это возможно, и таймаут для зависшего processing, чтобы задачу можно было вернуть в очередь.
Самый понятный старт для фоновых задач - разделить сценарий на два шага: быстро принять запрос пользователя и отдельно выполнить тяжелую работу. Пользователь не должен ждать отправку письма или расчет отчета.
Ниже - шаблон для email, отчетов и вебхуков, который хорошо ложится на очередь в PostgreSQL.
Постановка задачи. В обработчике HTTP вы создаете запись задачи (тип, payload, created_at) и сразу отвечаете пользователю. Запись о задаче и бизнес-изменение (например, создание заказа) лучше делать в одной транзакции, чтобы не было ситуации "заказ есть, задачи нет".
Забор задачи без гонок. Воркер в цикле выбирает одну задачу в статусе queued, берет блокировку и переводит в processing. Это защищает от ситуации, когда два воркера берут одну и ту же работу.
WITH job AS (
SELECT id
FROM jobs
WHERE status = 'queued' AND run_at <= now()
ORDER BY priority DESC, id
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE jobs
SET status='processing', started_at=now(), attempt=attempt+1
WHERE id IN (SELECT id FROM job)
RETURNING *;
Выполнение и фиксация результата. После обработки переведите задачу в done и сохраните полезный результат (например, id отправленного письма). При ошибке запишите last_error и переведите в retry.
Повторы и планирование следующей попытки. Заранее задайте max_attempts. При временных ошибках (таймаут, 429, 5xx) ставьте run_at в будущем: backoff (например, 1 мин, 5 мин, 15 мин) плюс небольшой джиттер, чтобы задачи не стартовали одновременно после сбоя.
Graceful shutdown. На остановку процесса воркер должен перестать брать новые задачи и дать текущей завершиться. Если времени мало, верните задачу из processing обратно в queued с ближайшим run_at, чтобы она не зависла до ручного вмешательства.
Ретраи нужны, когда задача может не выполниться с первого раза по причинам вне вашего контроля: сеть, временная перегрузка сервиса, короткий сбой базы. Но если повторять все подряд, вы получите лишнюю нагрузку, дубликаты писем и злые внешние API. Поэтому заранее договоритесь, какие ошибки повторяем, а какие считаем конечными.
Обычно удобно делить ошибки на три группы:
Backoff нужен, чтобы не долбить систему каждую секунду. Экспоненциальный backoff на пальцах: чем больше попыток, тем дольше пауза. Например, при базовой задержке 5 секунд:
Между ретраями добавляйте небольшой джиттер (случайный плюс-минус), иначе много задач проснутся одновременно и создадут пик нагрузки.
Чтобы ретраи не превратились в мини-DDoS чужого API, поставьте рамки: жесткий таймаут на запрос, лимит попыток, и пауза для направления (circuit breaker), если внешний сервис явно лежит. Пример: вы шлете вебхук партнеру, получаете 503 на 200 задач подряд. Вместо 200 параллельных ретраев лучше на 1-5 минут прекращать новые попытки и копить их до следующего окна.
И главное: ретраи безопасны только вместе с идемпотентностью. Для писем используйте уникальный ключ отправки, для вебхуков - idempotency key или дедупликацию по task_id, чтобы повтор не создал второй платеж или второе письмо.
Dead-letter очередь (DLQ) - это место для задач, которые уже попробовали выполниться несколько раз и все равно падают. Она экономит время: вместо бесконечных ретраев вы быстро видите, что именно ломается, и перестаете нагружать систему и внешние сервисы.
Обычная очередь отвечает за выполнение работы, DLQ - за разбор полетов. Попадание в DLQ не означает "никогда". Это означает "нужно внимание": исправить данные, обновить токен, поднять лимиты, поправить код.
Чтобы DLQ была полезной, храните не только payload, но и контекст последней неудачи:
failed_reason: короткая причина (например, timeout, 429, validation_error)error_message и stacktrace: чтобы понять, где упалоlast_attempt_at и attempts: когда и сколько раз пробовалиlast_response_code и last_response_body: если это вызов внешнего APIoriginal_task_id или dedupe_key: чтобы связать с исходной задачей и идемпотентностьюПример: вебхук в CRM начал возвращать 401 после смены ключа. Без DLQ воркер будет каждые N секунд стучаться в API и получать тот же ответ. С DLQ вы увидите пачку одинаковых 401, обновите ключ и вручную переотправите только нужные задачи.
DLQ должна быть процессом, иначе она превращается в кладбище. Простое правило: есть владелец и понятный ритм просмотра (например, раз в день в будни, а для критичных задач - чаще).
Ручной повтор из DLQ допустим, если ясно, что поменялось: исправили баг, обновили токен, поправили данные, сняли лимит у внешнего сервиса. Если причина не ясна, повтор только увеличит шум.
Когда вы строите очереди фоновых задач без сложной инфраструктуры, проще всего начинать с трех задач, которые почти всегда есть в продукте: письма, генерация отчетов и отправка вебхуков. У них разные риски, поэтому и правила безопасного выполнения отличаются.
Главная проблема писем - повторы. Воркер может упасть после отправки, сеть может таймаутиться, провайдер может вернуть временную ошибку. Поэтому у каждого письма нужен idempotency key: например, ключ вида user_id + template + period. Перед отправкой проверяйте, не было ли уже успешной отправки с этим ключом.
Письма удобно ставить в очередь по приоритету: пароль и подтверждения выше, маркетинговые ниже. Шаблоны храните отдельно от задачи: в задаче только имя шаблона и данные для подстановки.
Отчеты ломают опыт пользователя, если считать их в запросе. Типичный шаблон: пользователь нажал "Сформировать", вы ставите задачу, а в интерфейсе показываете статус.
Практично делить выполнение на шаги: собрать данные, сформировать файл, положить в хранилище, сохранить идентификатор, отправить уведомление о готовности. Так проще делать ретраи: если упала генерация файла, вы не пересчитываете данные заново.
Пример: бухгалтер запросил отчет за квартал. В задаче хранится период и фильтры, а результатом становится запись в таблице "готовые отчеты" со статусом и метаданными.
Вебхуки должны быть проверяемыми и повторяемыми. Добавляйте подпись (HMAC) и timestamp, чтобы получатель мог проверить, что событие не подделали и оно не слишком старое.
Порядок важен не всегда, но если важен (например, created должно прийти раньше deleted), задайте ключ последовательности по объекту (например, order_id) и обрабатывайте события последовательно внутри этого ключа.
Для всех трех кейсов почти всегда полезны дедлайны (после N часов задача уже не имеет смысла), лимиты (максимум попыток и ограничение скорости) и раздельные очереди по типам задач, чтобы отчеты не блокировали письма.
Очередь фоновых задач ломается редко и обычно тихо: письма не уходят, вебхуки копятся, отчеты становятся "вечными". Наблюдаемость лучше добавить сразу, но в минимальном объеме, чтобы ей реально пользовались.
Начните с четырех чисел, которые легко снять хоть из PostgreSQL, хоть из Redis:
queued/retry)retry + dead_letter за час и процент от всех попыток)Эти метрики быстро отвечают на главный вопрос: "Мы успеваем?" Если длина очереди стабильна, но возраст задач растет, значит воркеры живы, но уперлись во внешний сервис или стали слишком медленными.
Логи должны связывать постановку и выполнение одной задачи. Для этого заведите correlation_id (например, UUID) и пишите его в каждом сообщении. В логе выполнения фиксируйте номер попытки, причину ретрая и планируемую задержку.
Хорошая строка: "webhook delivery: correlation_id=... attempt=3 status=retry reason=timeout next_run_in=60s". Плохая: "ошибка отправки" без деталей.
Три тревоги обычно закрывают большую часть рисков: рост DLQ, очередь не уменьшается в течение X минут, воркеры перестали подавать признаки жизни (heartbeat или просто регулярный лог "я жив"). Это полезнее, чем десятки алертов, которые потом начинают игнорировать.
Даже простая таблица задач экономит часы. Покажите: статус, тип, created_at, run_at, attempt, last_error и payload (с маскировкой чувствительных полей). Добавьте два действия: "повторить сейчас" и "перевести в DLQ/закрыть".
Практический пример: у вас зависли вебхуки из-за проблем у партнера. Вы видите рост возраста задач, в логах - timeout, попытка 4, backoff 5 минут. Вы снижаете параллельность, а после восстановления сервиса нажимаете "повторить" для пачки задач и быстро разгребаете очередь.
Проблема простых очередей в том, что они ломаются не "красиво", а тихо: задачи ставятся, воркер вроде работает, а письма не уходят или вебхуки стреляют по два раза.
Самая частая ловушка - отсутствие идемпотентности. Повтор почти неизбежен: сеть моргнула, процесс перезапустился, база вернула таймаут. Если задача "выставить счет" или "создать запись" выполняется второй раз и создает дубль, данные быстро портятся. Простое правило: задача должна уметь безопасно повторяться.
Вторая ошибка - одна очередь на все. Когда в одной куче лежат и email, и тяжелые отчеты, и вебхуки, медленные задачи забивают быстрые. В итоге письма уходят с задержкой, хотя система "не перегружена". Разделяйте хотя бы по типам или приоритетам и задайте отдельные лимиты воркерам.
Еще несколько типичных проблем надежности:
Отдельно про двойную обработку: если вы сначала "берете" задачу и только потом помечаете ее как взятую, появляется окно гонки. Нужна атомарная операция взятия (в БД) или надежная семантика в Redis.
Перед тем как включать очередь в проде, зафиксируйте ожидания. Например: письмо пользователю должно уйти за 2 минуты, отчет может строиться до 30 минут, вебхук должен либо доставиться, либо перейти в ошибку с понятной причиной. Эти ожидания влияют на число воркеров, таймауты и частоту опроса очереди.
Проверьте надежность на уровне настроек:
Короткий тестовый сценарий помогает поймать слабые места. Например, сделайте 100 задач на вебхуки, намеренно верните 500 и таймауты у 20% запросов и посмотрите: растет ли backoff, не забивается ли очередь, появляются ли записи в DLQ, видно ли в логах, почему конкретная задача провалилась.
Дальше обычно важнее не добавлять новые технологии, а довести до ума наблюдаемость и правила: простая админка для задач и DLQ, пара метрик (глубина очереди, доля ошибок, возраст самой старой задачи), понятные лимиты и дедлайны. К более тяжелым системам имеет смысл переходить, когда вам реально нужен высокий throughput, много независимых потребителей, длительное хранение событий или строгий порядок по ключу.
Если вы собираете продукт на TakProsto (takprosto.ai), такой старт удобно оформить как понятный MVP: очередь в PostgreSQL, один воркер, базовые ретраи и DLQ. А когда упретесь в нагрузку, останется путь к усложнению без переписывания всей логики.
Фоновая задача нужна, когда пользователь должен быстро получить ответ, а «тяжелая» работа может выполняться позже: отправка писем, генерация отчетов, вызовы внешних API.
Так вы снижаете таймауты, делаете отклик предсказуемым и получаете возможность безопасно повторять операции при сбоях.
Самый простой и надежный старт — очередь в PostgreSQL: задачи не теряются при перезапуске, их легко дебажить (видны статусы, попытки, ошибки), а бэкапы и права доступа обычно уже настроены.
Redis уместен как быстрый буфер, когда нужна минимальная задержка старта и задачи «короткоживущие», но заранее решите, как вы переживаете рестарт и исключаете потери/дубли.
Чаще всего хватает:
Идемпотентность — это когда повторный запуск не портит итог. Повторы неизбежны: воркер может упасть после выполнения, сеть может оборваться, внешнее API может ответить нестабильно.
Практика:
idempotency_key)Цель — чтобы два запуска не приводили к двум письмам, двум платежам или двум одинаковым записям.
Нужно «атомарное взятие» задачи: один воркер выбирает запись и сразу переводит ее в processing под блокировкой.
В PostgreSQL типичный подход — FOR UPDATE SKIP LOCKED + UPDATE ... RETURNING, чтобы два воркера физически не могли забрать одну и ту же задачу.
Начните с простого правила:
Backoff лучше делать растущим (например, 10s → 30s → 2m → 10m) и добавлять небольшой джиттер, чтобы задачи не стартовали одновременно после сбоя.
DLQ (dead-letter) — место для задач, которые исчерпали лимит попыток или упираются в постоянную ошибку.
Что хранить, чтобы это было полезно:
failed_reason)payload (с маскировкой чувствительных данных)Важно, чтобы у DLQ был процесс: кто и как часто разбирает, и когда уместен ручной повтор.
Часто хватает минимума:
queued/retry)В логах связывайте постановку и выполнение через correlation_id, а для каждой попытки пишите причину ретрая и задержку до следующего запуска.
Самая частая ошибка — отсутствие идемпотентности: ретрай превращается в дубликаты писем/записей.
Другие типичные проблемы:
processingЛечится простыми правилами: раздельные очереди/приоритеты, лимиты попыток, дедлайны, backoff и понятная модель статусов.
Проверьте базовый чеклист:
max_attempts, таймаут выполнения, backoff и джиттерЕсли вы собираете приложение на TakProsto, удобный MVP — очередь в PostgreSQL, один воркер, базовые ретраи и DLQ, а усложнение добавлять только по мере реальной нагрузки.
type (что делать: send_email, build_report, call_webhook)payload (JSON с данными)status (queued, processing, done, retry, dead_letter)attempt и max_attemptsrun_at (когда можно запускать)deadline (после чего задача уже не нужна)last_error (последняя ошибка)Этого достаточно, чтобы управлять повторами, видеть историю и разбирать сбои.