RabbitMQ на тысяче сообщений в день: брокер, который никому не нужен
Кто-то на созвоне сказал «давайте через очередь, это стандарт для таких вещей» — и через полгода в проекте, где за сутки набегает едва ли тысяча сообщений (письма после регистрации, уведомление в телеграм, пересчёт статуса заказа), крутится RabbitMQ с management-плагином, отдельным пользователем и паролем, и строкой в чеклисте на каждое обновление. Разберём честно: при каком объёме и какой сложности маршрутизации RabbitMQ действительно отрабатывает свою цену, а когда для тех же задач хватит очереди в самой базе данных и простого воркера с опросом — без отдельного сервиса, который нужно поднимать, патчить и мониторить.
Содержание
Тысяча сообщений в день — это сколько на самом деле
Прежде чем спорить о том, нужен брокер или нет, полезно перевести «тысячу сообщений в день» в единицы, которыми думает инфраструктура. Тысяча сообщений за 86400 секунд суток — это в среднем одно сообщение примерно раз в полторы минуты. Даже если поток размазан неравномерно и половина прилетает в рабочие часы, речь всё равно о единицах сообщений в минуту, а не в секунду.
RabbitMQ проектировался не под это. Брокер держит устойчивый поток на порядки выше — точную цифру пропускной способности вашего сервера называть не будем (она зависит от размера сообщений, числа очередей, durability и железа), но разница между «то, что нужно проекту» и «то, под что рассчитан брокер» — это не проценты, а разы, обычно тысячи раз.
| Показатель | Реальная нагрузка проекта | Под что спроектирован RabbitMQ |
|---|---|---|
| Сообщений в сутки | ~1000 | миллионы и больше на серьёзном железе |
| Среднее число сообщений в секунду | ~0.01 | устойчивый поток на порядки выше |
| Что делает большую часть суток | ничего — очередь пуста | обслуживает поток и всплески |
| Резидентный процесс | нужен 24/7 ради полутора минут ожидания между событиями | рассчитан работать под постоянной нагрузкой |
Это не значит, что маленький объём автоматически исключает брокер — ниже разберём, когда дело не в объёме, а в сложности маршрутизации. Но если единственный аргумент в пользу RabbitMQ — «а вдруг вырастет», при текущей тысяче сообщений в день вы платите инфраструктурную цену уже сегодня за гипотезу, которая может не подтвердиться никогда.
Что вы получаете вместе с RabbitMQ, даже если очередь пустая
Брокер не выставляет счёт только за сообщения — он выставляет счёт за само своё существование в инфраструктуре, независимо от того, сколько сообщений через него прошло.
- Erlang/OTP как обязательная зависимость. RabbitMQ написан на Erlang, и на сервере появляется не только сам брокер, но и рантайм под него — со своей матрицей совместимости версий, которую нужно сверять при каждом обновлении ОС или пакетов.
- Резидентная память даже в простое. Процесс
beam.smpс включённым management-плагином держит в памяти не только очереди, но и HTTP-сервер веб-интерфейса и статистику. Точную цифру называть не будем — она гуляет по версиям и конфигурации, — но это не «ноль мегабайт, пока пусто», а измеримый постоянный расход. Проверить у себя:rabbitmqctl status, разделmemory. - Три открытых порта вместо одного. AMQP на 5672, management UI на 15672, распределённый протокол Erlang на 25672 (нужен даже однонодовой установке для части команд
rabbitmqctl). Каждый порт — это то, что нужно закрыть файрволом и не забыть при аудите. - Erlang cookie как отдельная сущность безопасности. Файл
/var/lib/rabbitmq/.erlang.cookie— общий секрет для узлов и части инструментов управления. Его права доступа и то, что он не должен утечь в git вместе с конфигом — ещё один пункт, о котором нужно помнить, даже для одного узла. - Пороги памяти и диска, которые нужно настроить осознанно.
vm_memory_high_watermarkиdisk_free_limitпри неверной настройке на маленьком VPS могут внезапно заблокировать публикацию всех сообщений, если брокер решил, что памяти или диска стало мало. - Обновления с собственным темпом мажорных релизов. Между мажорными версиями бывают изменения формата хранения метаданных (сверяйтесь с changelog вашей версии перед апгрейдом) — это не пакет, который обновляют не глядя, как утилиту без состояния.
- Отдельная точка бэкапа. Состояние очередей, пользователей и биндингов не попадает автоматически в бэкап вашей БД — это отдельный процесс
rabbitmqctl export_definitions, про который легко забыть, потому что «оно же само работает».
Ни один из этих пунктов не катастрофа сам по себе. Но это цена, которую вы платите постоянно, а не разово при установке — и платите её одинаково что при тысяче сообщений в день, что при миллионе. Разница только в том, покупает ли эта цена вам что-то ценное.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать VPS под проектГде RabbitMQ реально нужен — маршрутизация, а не просто буфер
Честная причина ставить RabbitMQ на маленький объём почти никогда не «нам нужен буфер» — тысяча сообщений в день не создаёт нагрузки, которую нужно буферизовать. Настоящая причина — это гибкая маршрутизация, которую RabbitMQ умеет из коробки, а простая таблица в БД не умеет вовсе.
Ядро модели RabbitMQ — не очередь, а exchange (обменник), который решает, в какие очереди попадёт сообщение, на основе routing key и типа обменника:
- direct — сообщение уходит в очередь с точно совпадающим routing key;
- topic — routing key сопоставляется по шаблону с wildcard (
order.*.created,order.eu.#) — это единственный тип, который реально сложно повторить без брокера; - fanout — сообщение уходит сразу во все привязанные очереди, без разбора routing key;
- headers — маршрутизация по заголовкам сообщения, а не по строке routing key.
Пример, где это оправдано: событие order.created должно по-разному маршрутизироваться в зависимости от страны и суммы заказа, и несколько независимых команд подписываются на свои срезы этого потока, не зная друг о друге:
# Создаём topic-обменник
rabbitmqadmin declare exchange name=orders type=topic durable=true
# Команда логистики слушает только заказы из ЕС
rabbitmqadmin declare queue name=logistics.eu durable=true
rabbitmqadmin declare binding source=orders destination=logistics.eu \
routing_key="order.eu.*"
# Команда фрода слушает крупные заказы независимо от региона
rabbitmqadmin declare queue name=fraud.large durable=true
rabbitmqadmin declare binding source=orders destination=fraud.large \
routing_key="order.*.large"
Продюсер публикует одно сообщение с routing key order.eu.large — и оно уходит сразу в обе очереди, без единой строчки кода о том, что этих потребителей вообще двое. Добавить третьего потребителя со своим срезом — значит объявить ещё один binding, не трогая ни продюсера, ни существующих консьюмеров. Это настоящая причина держать брокер: таблица job'ов в БД такого не даёт без того, чтобы каждый потребитель сам фильтровал общий поток, добавляя нагрузку на БД пропорционально числу потребителей.
Другие честные причины: dead letter exchange с автоматическим переносом «протухших» сообщений в отдельную очередь для разбора, delayed message через плагин для отложенной доставки, приоритетные очереди (x-max-priority), когда часть сообщений должна обгонять остальные. Если ни одной из этих задач в проекте нет — маршрутизация тривиальна («одно событие — один обработчик»), и вы платите за гибкость, которой не пользуетесь. Про роли продюсера, брокера и консьюмера и зачем вообще нужна асинхронная развязка — в статье как устроена очередь сообщений; та же проблема через призму «очередь вместо cron» — в статье антипаттерн: очередь сообщений там, где хватило бы cron.
Что вместо брокера: очередь в БД и простой worker
Если маршрутизация тривиальная, а объём — тысяча сообщений в сутки, у вас уже есть всё необходимое для очереди — это база данных, которая и так стоит под проект. Классический паттерн называется job table (или transactional outbox, если задача рождается вместе с бизнес-транзакцией).
Схема таблицы для Postgres:
CREATE TABLE jobs (
id BIGSERIAL PRIMARY KEY,
kind TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
run_at TIMESTAMPTZ NOT NULL DEFAULT now(),
locked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX jobs_pending_idx ON jobs (run_at)
WHERE status = 'pending';
Воркер забирает работу атомарно, без гонки между несколькими экземплярами воркера, через SELECT ... FOR UPDATE SKIP LOCKED — эта конструкция и есть встроенный аналог ack/consumer lock, только на уровне СУБД:
UPDATE jobs
SET status = 'processing', locked_at = now(), attempts = attempts + 1
WHERE id = (
SELECT id FROM jobs
WHERE status = 'pending' AND run_at <= now()
ORDER BY run_at
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING id, kind, payload;
SKIP LOCKED означает: если строку уже держит другой воркер, не ждать её освобождения, а сразу взять следующую свободную — именно это поведение и делает несколько параллельных воркеров безопасными без отдельного брокера.
Есть два варианта, как воркер узнаёт о новой работе:
- Polling с интервалом — воркер раз в несколько секунд опрашивает таблицу. Просто, предсказуемо по нагрузке на БД, но добавляет задержку до следующего опроса.
LISTEN/NOTIFY— Postgres умеет будить подписанное соединение мгновенно, без опроса:
-- Триггер, который шлёт уведомление при вставке новой задачи
CREATE OR REPLACE FUNCTION notify_new_job() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('new_job', NEW.id::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER jobs_notify AFTER INSERT ON jobs
FOR EACH ROW EXECUTE FUNCTION notify_new_job();
Воркер подписывается на канал LISTEN new_job и просыпается сразу при вставке — задержка обработки становится сопоставимой с push-моделью RabbitMQ, но без отдельного сервиса. На практике разумно держать оба механизма: LISTEN/NOTIFY для быстрой реакции плюс редкий страховочный polling на случай, если уведомление потерялось из-за разрыва соединения — Postgres не гарантирует доставку NOTIFY, если слушатель в этот момент не подключён.
Есть и структурный плюс, который часто упускают: при записи задачи в ту же таблицу, что и бизнес-данные, работа и её постановка в очередь попадают в одну транзакцию с продюсером — оба INSERT (заказ и задача на письмо) коммитятся вместе. С внешним брокером у вас всегда есть классическая проблема двойной записи (dual write): записали заказ в БД, а перед публикацией в RabbitMQ процесс упал — сообщение не отправлено, и это решают отдельным outbox-паттерном поверх самого брокера. Очередь прямо в БД эту проблему не создаёт — она и есть outbox.
На стороне воркера консьюмер брокера превращается в цикл с SKIP LOCKED и диспетчеризацией по полю kind:
while True:
job = fetch_next_job(db) # UPDATE ... RETURNING из раздела выше
if job is None:
wait_for_notify_or_timeout(timeout=30)
continue
try:
handlers[job['kind']](job['payload'])
mark_done(db, job['id'])
except Exception:
mark_failed_or_retry(db, job['id'])
И вместо демона-консьюмера — обычный systemd-юнит с Restart=always, как для любого фонового воркера:
# /etc/systemd/system/jobs-worker.service
[Unit]
Description=Background jobs worker
After=postgresql.service
[Service]
ExecStart=/opt/app/venv/bin/python /opt/app/worker.py
Restart=always
RestartSec=5
User=app
[Install]
WantedBy=multi-user.target
Ретраи и «мёртвые» задачи решаются полем attempts: после N неудачных попыток статус меняется на failed, и задача уходит в алерт для ручного разбора — та же роль, что у dead letter exchange в RabbitMQ, только без отдельного механизма поверх брокера. Идемпотентность нужна в обоих случаях одинаково — ни job table, ни RabbitMQ с ack не гарантируют «обработано ровно один раз», оба дают at-least-once.
Если периодическая часть логики завязана на расписание, а не на события — обычный cron или systemd timer закрывает часть задач вообще без очереди: подробности и готовые примеры — в статье как установить и настроить cron-задачи на VPS.
Когда пора обратно к брокеру — сигналы, а не ощущения
Отказ от RabbitMQ на маленьком объёме — не решение навсегда. Дело в том, чтобы не платить цену заранее под гипотезу, а перейти на брокер тогда, когда появится измеримая причина. Вот на что стоит смотреть:
- Контеншен на
SKIP LOCKEDрастёт. Десятки параллельных воркеров начинают заметно конкурировать за строки одной таблицы, а нагрузка на БД от очереди задач становится сопоставима с нагрузкой от основного приложения — сигнал, что job table упирается в практический потолок. - Появился настоящий fan-out. Несколько независимых команд или сервисов должны реагировать на один и тот же факт по-разному, и в коде обработчика заводятся условия «если заказ из ЕС, а ещё вот это» — ровно та задача, для которой придуманы exchange и binding.
- Продюсер и потребитель — разные сервисы без общей БД. Job table работает, пока обе стороны имеют доступ к одной базе. Как только консьюмер — независимый сервис на другом сервере без прямого доступа к вашей БД, нужен транспорт между процессами, и брокер уже не роскошь, а необходимость.
- Объём растёт не линейно, а на порядки. Тысяча сообщений в день, ставшая тысячей в час, — это другая система с другим профилем нагрузки, где стоит пересчитать, успевает ли текущее число воркеров разгребать поток. Формальный расчёт через формулу Литтла — с какой глубины воркеры физически не догонят очередь — разобран в статье потолок очереди задач: с какой глубины не догнать.
Если ни один из этих сигналов не про вас — переход на брокер не «более правильная» архитектура, а просто более дорогая при той же полезности.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать VPS под проектНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
RabbitMQ вообще плохой инструмент?
Нет, это зрелый и надёжный брокер с гибкой маршрутизацией — проблема не в инструменте, а в его применении там, где ни объём, ни сложность маршрутизации этого не требуют. Для события с несколькими независимыми потребителями и гарантией доставки при сбоях RabbitMQ по-прежнему один из самых предсказуемых вариантов.
А если объём сейчас маленький, но через полгода вырастет в 50 раз?
Прогноз — не то же самое, что измеренная потребность. Job table на Postgres с SKIP LOCKED спокойно выдерживает рост на порядок без изменений архитектуры; когда рост станет фактом, а не гипотезой, миграция на брокер с уже идемпотентным обработчиком обычно занимает дни, а не переписывание системы с нуля.
Не потеряю ли я надёжность, отказавшись от RabbitMQ в пользу таблицы в БД?
При том же объёме — нет. SKIP LOCKED даёт то же «доставлено минимум одному воркеру, не потеряно при падении», что и ack/redelivery в RabbitMQ, а вставка задачи в одной транзакции с бизнес-данными закрывает проблему двойной записи, которую с внешним брокером приходится решать отдельно.
Что если нужна доставка между разными серверами или сервисами разных команд?
Это ровно тот случай, где очередь в общей БД не работает в принципе — консьюмер должен иметь доступ к данным, а не только к сети. Здесь брокер (RabbitMQ или более лёгкая альтернатива) — обоснованный выбор, а не архитектура «на всякий случай».
Как понять, что contention на SKIP LOCKED действительно стал проблемой, а не кажется?
Смотрите не на ощущение «воркеров стало много», а на реальные метрики блокировок Postgres (pg_stat_activity, ожидания на строках таблицы jobs) и на то, растёт ли задержка между вставкой задачи и её обработкой при постоянном числе воркеров. Если задержка стабильна — запаса ещё достаточно.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →