MAATRIX / Блог / Антипаттерн: очередь сообщений там, где хватило бы cron

Антипаттерн: очередь сообщений там, где хватило бы cron

MAATRIX

Задача звучит скромно: раз в час забрать новые записи из таблицы и что-то с ними сделать — отправить письма, пересчитать статус заказа, выгрузить отчёт. А в архитектуре внезапно появляется брокер сообщений: RabbitMQ или Kafka, producer, consumer, топики или очереди, отдельный сервис, который надо поднимать, обновлять и мониторить. Через месяц-другой выясняется, что вся эта конструкция делает ровно то же самое, что сделала бы одна строка в crontab — только теперь у вас на один сервис и один повод для 3 часов ночи с алертом больше.

Как выглядит этот антипаттерн

Сценарий почти всегда один и тот же. Есть периодическая задача: «раз в час обработать новые заказы», «раз в сутки выгрузить отчёт для бухгалтерии», «каждые 5 минут проверить статус внешних платежей». На этапе проектирования кто-то произносит фразу вроде «давайте сразу сделаем через очередь — это надёжнее» или «так масштабируется, вдруг нагрузка вырастет». Решение принимается не из-за конкретного требования, а из-за общего ощущения, что очередь — это «правильная», «взрослая» архитектура, а cron — это что-то из 2005 года.

Дальше разворачивается знакомая цепочка: поднимается RabbitMQ или Kafka (часто в Docker-контейнере рядом с приложением), пишется producer, который кладёт сообщение «пора обработать записи» в очередь по расписанию (тем же cron'ом — расписание никуда не делось, просто теперь оно триггерит не саму обработку, а публикацию сообщения), и consumer, который это сообщение вычитывает и запускает ту же самую обработку, которую можно было запустить напрямую.

В итоге в системе появляется процесс, который слушает очередь 24/7, хотя реальная работа происходит раз в час и занимает пару минут — посредник между «время пришло» и «задача выполнена», который не добавляет ни одного бита новой функциональности, а только удлиняет путь от расписания до результата.

Когда очередь сообщений — правильный выбор

Чтобы понимать, где проходит граница, полезно перечислить случаи, где брокер сообщений действительно решает задачу, которую иначе решить трудно или дорого:

  • Асинхронная обработка событий с быстрым откликом наружу. Пользователь загрузил видео — API должен ответить за доли секунды, а реальная обработка (транскодирование, превью) занимает минуты. Запрос кладётся в очередь, обработчик забирает его в фоне, пользователь получает 202 Accepted и опрашивает статус или получает вебхук.
  • Развязка сервисов с разным жизненным циклом. Producer и consumer пишутся разными командами, деплоятся независимо, временно расходятся по версии протокола. Очередь становится буфером: producer не обязан знать, жив ли сейчас consumer и сколько их.
  • Гарантии доставки при частичном отказе посреди обработки. Если задача упала на середине (сеть моргнула, процесс убили по OOM), брокер с подтверждением (ack/nack) вернёт сообщение в очередь, и его заберёт другой consumer или тот же после перезапуска — без ручного вмешательства и без риска потерять событие.
  • Fan-out — одно событие вызывает несколько независимых реакций. «Заказ оплачен» одновременно должен: отправить письмо, списать товар со склада, дёрнуть аналитику, уведомить партнёрскую систему. Каждый consumer подписан на своё, падает и ретраится независимо от остальных.
  • Горизонтальное масштабирование обработчиков под неравномерную нагрузку. Когда поток событий непредсказуем и может кратковременно вырасти в 10-50 раз (например, во время распродажи), очередь работает буфером: вы добавляете consumer'ов, а глубина очереди — встроенный сигнал backpressure.
  • Упорядоченная обработка по ключу при высоком параллелизме. Kafka с партиционированием по ключу (например, по user_id) гарантирует порядок событий одного пользователя, а разные пользователи обрабатываются параллельно на разных партициях — такое сложно воспроизвести вручную без брокера.

Если у вас в системе есть хотя бы один из этих признаков — событие, а не время, как триггер; несколько независимых потребителей одного факта; необходимость пережить падение consumer'а без потери события; нужда масштабировать обработчиков отдельно от источника нагрузки — обсуждение очереди обосновано. Подробнее о том, как это устроено на уровне протокола и гарантий, у нас есть отдельный разбор: как устроена очередь сообщений.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Когда достаточно cron или systemd timer

Теперь противоположный список — признаки, что перед вами не событийная задача, а периодическая, и очередь ей ничего не добавляет:

  • Триггер — время, а не событие. «Раз в час», «каждую ночь в 03:00», «каждые 5 минут» — расписание уже известно заранее и не зависит от внешних сигналов. Планировщик ОС для этого и создан.
  • Один запуск — один обработчик. Не нужно несколько независимых consumer'ов, реагирующих на один и тот же факт по-разному. Есть одна работа, которую нужно сделать один раз за интервал.
  • Задержка в обработке не критична. Если задача не успела выполниться ровно в момент срабатывания (сервер был занят, шёл деплой), выполнение через минуту или на следующем тике обычно ничего не ломает. Это принципиально другой профиль требований, чем «событие должно быть обработано и не потеряно, во что бы то ни стало».
  • Объём работы за один запуск предсказуем и укладывается в интервал. «Раз в час обработать несколько сотен новых записей» — это секунды-минуты работы, а не поток, требующий распределённой обработки.
  • Повтор при сбое = следующий запуск по расписанию, и это приемлемо. Если задача упала, а её идемпотентно можно перезапустить на следующем тике (через час, через сутки) без потери данных — вам не нужен механизм ack/redelivery, потому что его роль уже играет расписание.

Классический пример из практики: «раз в час обработать новые записи в таблице orders со статусом new». Это ровно тот случай, когда cron или systemd timer не «упрощённая замена» очереди, а по сути более простой и более надёжный инструмент для этой конкретной задачи — потому что в задаче нет ничего из того, для чего очередь придумана. Про сам механизм cron и типичные грабли при настройке есть отдельный материал: как установить и настроить cron-задачи на VPS.

Что стоит лишний сервис в инфраструктуре

Добавление брокера сообщений — это не бесплатное архитектурное решение «на всякий случай», а конкретная и постоянная статья расходов, даже если сам брокер бесплатный (open source):

  • Ещё один процесс, который может упасть. RabbitMQ или Kafka — отдельный сервис со своим состоянием, логами и способами сломаться (переполнение диска persist-очередями, деградация при нехватке памяти, рассинхронизация кластера). Каждый новый сервис — новая точка отказа для всей цепочки, даже если бизнес-логика не изменилась.
  • Ресурсы, которые нужны просто чтобы брокер существовал. Kafka требовательна к памяти и диску даже при небольшом потоке сообщений — она рассчитана на устойчивую пропускную способность, а не на редкие всплески раз в час. RabbitMQ легче, но всё равно постоянно резидентный процесс с собственным потреблением RAM, даже когда очередь пуста.
  • Мониторинг, которого раньше не было. Нужно следить не только за тем, выполнилась ли задача, но и за глубиной очереди, consumer lag, состоянием самого брокера — новые дашборды, алерты и знания для дежурных.
  • Операционная нагрузка на обновления. Брокер — ещё один пакет или образ, за версией которого нужно следить, накатывать патчи, проверять breaking changes между мажорными версиями.
  • Усложнение локальной разработки и CI. Чтобы прогнать интеграционный тест, теперь нужно поднять не только БД, но и брокер — в docker-compose.yml, в CI-пайплайне, на машине каждого разработчика.
  • Более длинный путь диагностики инцидента. Раньше «где искать» — это лог cron-джобы. Теперь: жив ли producer, дошло ли сообщение до брокера, не осело ли в dead-letter очереди, забрал ли его consumer, не упал ли при обработке — четыре точки вместо одной.

Ни один из этих пунктов не является поводом никогда не использовать очередь — это просто её реальная цена, которую стоит осознанно платить только тогда, когда она покупает вам что-то, чего не купить проще. Похожая логика разбирается и в другом мифе про архитектуру по умолчанию: миф про микросервисы «так правильно, монолит устарел» — тот же принцип «сложность должна быть куплена конкретной выгодой, а не заведена про запас».

Как быстро понять, что вам нужно

Прежде чем добавлять брокер сообщений в архитектуру, стоит ответить на пять вопросов:

  1. Что запускает обработку — время или внешнее событие? Время — аргумент в пользу планировщика. Событие (действие пользователя, вебхук, изменение в другом сервисе) — аргумент в пользу очереди.
  2. Сколько независимых потребителей должно отреагировать на один факт? Один — планировщик достаточен. Несколько разных consumer'ов с разной логикой — это fan-out, для которого очередь и придумана.
  3. Что произойдёт, если обработчик упадёт посреди работы? Если приемлемо переисполнить задачу идемпотентно на следующем тике — брокер с ack/redelivery не даёт здесь ничего сверх того, что уже даёт cron. Если событие обязано быть обработано немедленно и не потеряно — аргумент за очередь.
  4. Нужно ли масштабировать обработчиков отдельно от источника нагрузки? Непредсказуемый поток — очередь с несколькими consumer'ами. Стабильный объём в рамках одного интервала — не нужно.
  5. Есть ли у вас сейчас конкретная, а не гипотетическая причина? «Вдруг понадобится масштабироваться» — прогноз, а не причина. Причина — это измеримое требование уже сегодня.

Если ответы в основном «время», «один потребитель», «повтор на следующем тике — ок», «нагрузка стабильна», «конкретной причины нет» — перед вами задача для cron или systemd timer. Брокер в этом случае не повышает надёжность, а снижает её: там, где была одна точка отказа, появляется цепочка из нескольких.

Практика: периодическая задача без брокера

Возьмём конкретный пример — «раз в час обработать новые записи в таблице orders». Вот как это решается без единого дополнительного сервиса.

Вариант 1 — cron с блокировкой от параллельного запуска. Блокировка через flock нужна на случай, если предыдущий запуск ещё не завершился (например, БД временно подтормаживала) — это тот же принцип, что и «один consumer обрабатывает сообщение за раз», только реализованный штатным механизмом ОС, без брокера:

# crontab -e
0 * * * * /usr/bin/flock -n /var/lock/process-orders.lock \
  /opt/app/bin/process_new_orders.sh >> /var/log/process_new_orders.log 2>&1

Флаг -n (non-blocking) означает: если предыдущий запуск ещё держит блокировку, новый просто не стартует — вместо того чтобы встать в очередь и запуститься сразу после. Иногда нужен именно блокирующий flock без -n, решайте по задаче.

Вариант 2 — systemd timer, если вам нужнее нормальное логирование через journalctl, явные зависимости от других юнитов (например, дождаться, пока поднимется БД) и обработку пропущенных запусков после простоя сервера:

# /etc/systemd/system/process-new-orders.service
[Unit]
Description=Process new orders batch job
After=postgresql.service

[Service]
Type=oneshot
ExecStart=/opt/app/bin/process_new_orders.sh
# /etc/systemd/system/process-new-orders.timer
[Unit]
Description=Run process-new-orders hourly

[Timer]
OnCalendar=hourly
Persistent=true
RandomizedDelaySec=60

[Install]
WantedBy=timers.target

Persistent=true означает: если сервер был выключен или перегружен в момент срабатывания таймера, задача выполнится сразу после старта системы — тот самый сценарий «не потерять запланированное событие», ради которого иногда без раздумий тянут брокер. RandomizedDelaySec сдвигает старт на нескольких серверах с одинаковым таймером, чтобы не бить по БД одновременно.

Активация и проверка:

systemctl daemon-reload
systemctl enable --now process-new-orders.timer
systemctl list-timers process-new-orders.timer
journalctl -u process-new-orders.service --since today

Идемпотентность вместо ack. Роль подтверждения сообщения в очереди здесь играет простое условие в запросе — обработчик выбирает только необработанные записи и сразу помечает их взятыми в работу:

UPDATE orders
SET status = 'processing', locked_at = now()
WHERE status = 'new'
  AND (locked_at IS NULL OR locked_at < now() - interval '1 hour')
RETURNING id;

Если обработка упадёт на середине, запись просто останется в статусе processing дольше часа и будет подхвачена следующим запуском — то же поведение, которое даёт redelivery в брокере, но без самого брокера.

Наблюдаемость. Единственное, что действительно стоит добавить — это внешний контроль того, что задача вообще запускается и не падает молча, потому что у cron и systemd timer нет встроенного алертинга. Проще всего — пинг на внешний сервис в конце скрипта, если он завершился успешно:

/opt/app/bin/process_new_orders.sh && curl -fsS -m 10 --retry 3 \
  https://hc-ping.com/ваш-uuid-проверки

Подробнее — в разборе мониторинга cron-задач через healthchecks.io: если пинг не пришёл вовремя, вы узнаёте о проблеме раньше пользователей, и для этого не нужен ни брокер, ни отдельный дашборд.

Если в какой-то момент объём новых записей за час перестанет укладываться в интервал между запусками — это уже не гипотеза, а измеримый факт, и вот тогда разговор про очередь сообщений и параллельных consumer'ов станет обоснованным, а не преждевременным.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Чем плоха идея «поставим очередь сейчас, вдруг понадобится масштабирование»?

Тем, что вы платите цену (лишний сервис, мониторинг, точка отказа, усложнение CI) уже сегодня за выгоду, которая, возможно, никогда не понадобится. Архитектура под измеренную потребность обычно устойчивее, чем архитектура под прогноз.

А если нужна гарантия, что задача точно выполнится, а не потеряется?

Для периодической задачи это покрывается связкой systemd timer с Persistent=true, идемпотентным обработчиком и внешним мониторингом вроде healthchecks.io. Брокер здесь не даёт дополнительной гарантии — расписание уже само себе очередь из одного элемента.

Что если задач станет так много, что один часовой прогон перестанет успевать?

Это сигнал пересмотреть решение, но не автоматически в пользу брокера. Сначала — более дешёвые варианты: сократить интервал, распараллелить обработку внутри запуска, оптимизировать запрос к БД. Очередь с независимыми consumer'ами обоснованна, когда параллелизм внутри одного запуска упирается в потолок или природа задачи меняется с «пакетной» на «событийную».

Чем cron хуже или лучше systemd timer для такой задачи?

Cron проще и работает везде. Systemd timer выигрывает, если нужны логи через journalctl, зависимости от других сервисов (After=), гарантированное выполнение пропущенных запусков (Persistent=true) и разброс старта между серверами (RandomizedDelaySec). Для простой задачи разница чаще всего не критична.

Можно ли начать с cron, а потом перейти на очередь, если реально понадобится?

Да, и это не «технический долг», а разумный порядок: простое решение под текущее требование, миграция на брокер — когда появляется конкретный измеримый повод. Идемпотентный обработчик, написанный для cron, обычно почти без изменений становится consumer'ом очереди.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →