Короткий ответ
В конкретном стенде для приёма задач с zero-loss и SLA p99 до 50 мс локальный WAL и отдельная ingest-база оказались ровнее и проще Valkey Streams и RabbitMQ. Результат не доказывает, что брокеры бесполезны: он показывает, что очередь оправдана маршрутизацией и независимыми потребителями, а не самим фактом асинхронной записи.Очереди все еще не нужны.
Каждый разработчик думает об архитектуре нового проекта, и часто приходит к такому заключению: «_Завезу микросервисы, и чтоб красиво кафку туда, или реббит, чтоб общались. Редис для кешей, и все это в кубере. Заживу!_».
Обоснование тому такое: «_Задел на будущее, кучу систем так построили, нормально работает, даже не больно, практически. И распределенка, и отказы с пиками держим. Красота!_». Я мог бы согласится, но не буду. Если начать копать глубже, всплывает много нюансов. Большую часть того, что всплывает, я описал в прошлый раз.
Тогда же, в комментариях, чтобы не быть голословным и ради академического интереса, решил собрать стенд и уже на цифрах тебе показать, что очереди в классическом понимании все еще не нужны.
Расскажу что собирал, как и что измерял и какие результаты получил.
Часть первая: Преамбула
Начну издалека, с теоретического обоснования, как учили еще в университетах.
Есть у тебя сервис А. Базу свою этот сервис и в хвост и в гриву, поэтому запись там медленная — допустим, 60 мс. Сервис А принимает на эндпоинт /task задачу, а бизнес вместе с SRE говорит тебе как архитектору: «_Ждать ажно 60мс никак не можем, ответить нужно за 50 и точка. Zero loss, идемпотентность, at-least-once. Ещё давай, чтобы graceful degradation было, throughput не падал, latency стабильно в SLA. Ordering guarantees можно не жёсткие, но durability кровь из носа. И чтоб backpressure обязательно. Завтра релиз_».
Отдельное спасибо бизнесу, что порядок записи не так уж и важен, и этим ты можем пренебречь, то есть писать можно параллельно пачками и быстрее разбирать хвост.
Формализуем требования:
- SLA входа: p99(t_api) ≤ 50 мс при 500 RPS (без аварий).
- At-least-once доставка без дублей в целевой БД (идемпотентность по
id). - Durability на приёме: после ответа клиенту запись потеряться не должна при падении сервиса/воркера/базы.
Остальное как бы не услышал, оно тебе не важно.
Уяснив требования бизнеса и хотелки SRE, наливаешь кофу и начинаешь думать, какие варианты у тебя есть и чего эти варианты тебе будут стоить.
Часть вторая: Кейсы
Возьмем сферический проект в вакууме, представим, что у тебя нет ничего кроме как api и базы. Api напрямую пишет задачу в бд и беспощадно нарушает SLA.
"_Чтож делать? Чтож делать?_"
Кейс 1. WAL (Write-Ahead Logging)
Старый “советский” метод. Api пишет не в базу, а в локальный журнал. Формат тупой как гвоздь: длина + payload. Один fsync на батч или по таймауту. Ротация — по размеру и времени. Старый сегмент закрыли, новый открыли. Воркеры читают завершённые сегменты и докатывают в базу пачками, хоть по 100, хоть по 1000 штук.
Плюсы: – Latency +-1 мс на входе (fsync по батчу/таймеру). – Durability честная после fsync. – At‑least‑once через идемпотентность в БД. – Failover тривиальный (перечитываем сегмент). – Естественный backpressure по росту сегментов.
Минусы: – Дисковая нагрузка (особенно fsync=always). – Своя реализация и поддержка (скорее всего реализации готовой не будет или будет написана индусом). – Нужен мониторинг лага/ротации, контроль места на диске.
WAL – это "скучный" вариант, но и предсказуемый, сам сломал –сам чинишь. Нет брокеров, нет «кластеров», нет магии. Файл и логика: как писать, как читать, как ротировать. Стоит упомянуть, что все последующие кейсы в той или иной степени под капотом имеют схожий механизм обеспечения сохранности данных – fsync.
Кейс 2. Ingest DB
Тут уже будет нужен DevOps. Вместо того, чтобы писать в журнал, пишем сразу в базу — но не в боевую, а в отдельный инстанс (ingest), который не тормозит, заточен на быструю запись и физически крутится на другом сервере. Уже из него воркер параллельно пачками переливает в нужную базу.
Плюсы: – Durability на стороне СУБД. – Дубликаты гасит ON CONFLICT. – Масштабирование воркерами, размер пачки. – Лаг прозрачен (ingest → main).
Минусы: – Индексы на ingest могут тормозить вставку. – Вакуум/блоат и размер пачки нужно тюнить. – UNLOGGED нельзя при zero‑loss. – Возможна гонка за ресурс на горячих партициях. – Медленная/общая БД = боттлнек.
Получается, что IngestDB — это в теории решение между чистым WAL и брокером. Простая вставка на входе, батчи на выходе, минимум инфраструктуры. Теоретически хорошее решение, завез отдельную базу, затюнил и решил проблему.
Кейс 3: Valkey (Streams с AOF)
Вот ты уже практически прям рядом с очередью. Еще не жирный брокер, но уже очередь на минималках. Берём Valkey (форк Redis), включаем streams и appendfsync=always. Тут как и с базой нужОн DevOps, лучше два, поднимаем новый отдельный сервис.
А работает кейс так: – api пишет событие в XADD stream * payload. Операция быстрая, но не бесплатная, каждый fsync блокирует запись. – Клиент получает ACK сразу после попадания в AOF, значит durability честная. – Воркер читает через XREADGROUP, обрабатывает пачку и помечает offset.
Плюсы: – Низкая теоретическая базовая задержка на XADD. – AOF даёт честную durability. – At‑least‑once встроен (PEL/ACK) (с оговоркой, только на доставку, дубли не контролируются). – Привычный стек (в теории).
Минусы: – p99 скачет из‑за fsync на нагрузке. – Backpressure кривой: растёт лаг, сервис ест память. – Failover: потеря хвоста без WAIT/min‑replicas‑to‑write. – Доп. сервис и зоопарк, рост RAM до краша.
Valkey хорош для UX-шных событий, коротких очередей, быстрых стримов, где пропускная способность важнее стабильной задержки. Но в задаче «zero loss + SLA по latency» valkey начинает подтекать: да, не потеряет, но предсказуемость страдает. В оправдание запишем тот факт, что скорее всего, valkey или redis уже будет на проекте как cache.
Кейс 4: Rabbit
Ну вот и добрались до Очереди. Да, именно с большой буквы. Настоящая, взрослая, жирненькая очередь, как у больших дядек в фаанге. "Для очередей придумана!" - это про нее. Все архитекторы дружно кивают головами: durable queue, ack, retry, всё как завещали тебе Фаулер, Хоппе (Хохпе?) и Клишин.
Как работает: – api пишет задачу в durable queue через AMQP. Это не запись в файл напрямую, а запись через брокер, его буферы, его собственный журнал, еще и по сети. – ACK клиенту прилетает сразу после попадания в очередь (и тут уже вопрос: в память или на диск). – Воркер подписан на очередь, получает сообщения, подтверждает через ack, или говорит, что не смог через nack.
Плюсы: – Ack/Nack/retry/маршрутизация из коробки. – At‑least‑once встроен (с оговоркой: гарантируется сохранность сообщения, но не отсутствие дубликатов) – Fanout/headers/топики для сложной топологии.
Минусы: – Latency выше и пила по p99. – Durability условная без durable+persistent+publisher confirm. – Failover через Raft: пауза на выбор лидера, redelivery, без confirm — потеря. – Сложный зоопарк (кластер, HA‑политики, мониторинг), непредсказуемый throughput при пиках.
Несмотря на то, что RabbitMQ выглядит «серьёзным», "большим", "взрослым" и очень нужным, по факту добавляет: дополнительный hop в pipeline по сети, непредсказуемые задержки и вероятность потерять данные при кривой конфигурации.
Для задач «принять таску и сохранить без потерь» — rabbit не лучше, чем WAL или Ingest. Для задач «маршрутизировать в десять разных консьюмеров с fanout/headers-routing» — норм. Но если тебе это не надо, RabbitMQ — это, уже на теоретической части, оверинжиниринг.
Примерно такой поток мыслей будет в голове у архитектора, который с кофой сидит и думает как будет закрывать SLA. Давай теперь расскажу как и на чем тесты проводились.
Часть третья: Стенд. Что? Где? Когда?
Собрал значит стенд, один репозиторий, 4 сценария. Поднимаем через docker-compose, немного bash скриптов, make.
Окружение у стенда получилось следующее: - Node.js ≥ 22: api, воркеры, утилиты, клиенты для бд и очередей, самописный WAL - PostgreSQL 16: основная медленная СУБД и ingest в отдельном контейнере для второго кейса - RabbitMQ 4.1: для четвертого кейса - Генератор нагрузки: autocannon. - Метрики: логи в json для таймингов. - ОС-метрики: telegraf 1.35
Для каждого кейса отдельный api и worker (см. apps/*). – WAL: запись в файл, ротация и воркер, который докатывает сегменты. – Ingest: отдельная БД для приема и воркер, который батчами с ограниченной параллельностью переливает из ingest в main. – Valkey: XADD в stream, чтение через consumer group. – Rabbit: durable очередь по AMQP.
Для Ingest, Valkey, Rabbit использовал готовые клиенты из npm. WAL пришлось написать самому, в npm не оказалось нормальной реализации, есть пара пакетов, один древний, второй сомнительный. Ну и zerodeps как никак. 700 строк и +- рабочий WAL готов, с ротациями, с таймерами, локами и блекджеком.
Долгую запись в основную бд эмулирую через триггер со sleep на запись каждой строки. Нагрузка идёт отдельным сервисом loadgen (autocannon под капотом).
Сценарий нагрузки: RPS: 500. Каждый прогон: 30 с прогрев (1/2 RPS) → 5 мин стабильно RPS → 30 спад (1/2 RPS). Сбои (на 3-й минуте): Kill воркера на 30с, затем рестарт.
Можно придумать другие сценарии, но не стал, одного считаю достаточным для сравнения в рамках данного изыскания.
По метриками тоже не стал изобретать. Telegraf для os, там все из коробки. По бизнес метрикам json логи в файл. Просто читать и анализировать.
Что собирал:
durable— время надёжной фиксации в соответствующем стораже:
- WAL: до записи в журнал. - Ingest: до коммита транзакции в ingest-БД. - Rabbit: до получения publisher confirm. - Valkey: задержка XADD; только при appendfsync=always это «сразу durable».
committed— кол-во строк и время коммита в основную бдdb_count– кол-во записей в основной бд в момент времениbacklog– размер хвоста в момент времениapi_latency– время, за которое ответил api и каким кодом- OS метрики cpu, mem, diskio, net для контейнеров, участвующих в прогоне (api/worker/сервис).
Для сравнения по каждому кейсу будем считать набор стабильных метрик:
- latency (api, durable, committed) — чтобы видеть, где именно в цепочке появляется задержка;
- backlog — динамику роста и слива очереди, пик и площадь под кривой, то есть сколько «долга» система накапливает;
- пропускную способность (committed rps), стабильность и хвостовые индексы (tail index) — насколько тяжёлые редкие задержки по сравнению с медианой;
- итоговое количество записей (rows_total) — для нормализации этих значений между прогонами разного масштаба.
В результате получаем сопоставимые профили: видно, как быстро система набирает очередь, с какой скоростью от неё избавляется, какие задержки видны пользователю на API-уровне и насколько стабильны внутренние транзакции.
Пример отчета можно посмотреть тут: comare.md
Так же оценим когнитивную нагрузку для каждого кейса.
Критерии оценки:
- Код: строки собственного кода (API+воркер+реплей/ретраи/метрики).
- Конфиг: число обязательных настроек.
- Failure modes: список типов отказов, о которых нужно помнить.
- Наблюдаемость: минимально достаточный набор метрик/алертов.
- Ранбуки: шаги запуска/восстановления для «упал X».
Каждый пункт от 0 до 5, суммируем и получаем «индекс когнитивки»; чем ниже — тем проще.
В целом никакого рокет сайенс, можно самому при желании потрогать. Перейдем к конкретике.
Часть четвертая: Когнитивная нагрузка
WAL • Код: 5/5. (+- 50 api и worker + 600 реализация). • Конфиг: 1/5 (Путь до рабочей директории, когда и как fsync, когда и как rorate, размер батча). 0 devops • Failure: 3/5 (диск/ротация/fsync). • Наблюдаемость: 3/5 (лаг сегментов, место на диске, скорость докатки). • Ранбуки: 2/5 (перезапуск воркера, зачистка сегментов, квоты на диск).
Сумма: 14/25 - реализация подводит, код, который нужно будет поддерживать, не выкинуть. DevOps: 0 - не нужен.
Ingest • Код: 2/5, ( +-100 api + worker). • Конфиг: 2/5 (Конфиги БД, размер батча, lease_ms) . 1 devops • Failure: 4/5 (вакуум/блоат, гонки за ресурс, ретраи транзакций, тайминги lease). • Наблюдаемость: 3/5 (лаг ingest→main, deadlocks/retries, длительность батчей). • Ранбуки: 3/5 (восстановить сервис, чистки/автовакуум, реплей).
Сумма: 14/25 — средняя нагрузка, «можно держать в голове». DevOps: 1 - вторая бд на другом сервере
Valkey (Streams + AOF) • Код: 2/5 ( +- 100 api + worker). • Конфиг: 3/5. (Конфигурация Valkey, stream, group, blockMs claimIdleMs) 2 devops • Failure modes: 5/5 (RAM-утечка на lag, AOF/репликация, PEL/claim, trim-политики). • Наблюдаемость: 4/5 (lag/PEL/maxlen/latency скачет от fsync). • Ранбуки: 4/5 (перевыбор лидера, WAIT/min-replicas-to-write, ручные trim).
Сумма: 18 — высокий индекс, «держать в голове труднее». DevOps: 2 - отдельный "новый" сервис, возможны трудности.
RabbitMQ (Quorum) • Код: 1/5 (+-80 строк api + worker). • Конфиг: 5/5 (users, permissions, exchanges, queues, bindings, policies и тд) 4 DevOps • Failure modes: 5/5 (quorum/confirm, flow control, DLX, poison msg, переизбрания). • Наблюдаемость: 5/5 (queues/channels/consumers/lag/DLQ/flow/memory/disk alarms). • Ранбуки: 5/5 (кластер, политики, шардирование, восстановление, дрейф конфигов).
Сумма: 21 — высокая когнитивка, зоопарк, труднее удержать в голове DevOps: 4 - кластер, конфиги и риски потерь высокие.
В итоге по когнитивке:
- WAL (14/25) — простая эксплуатация, но платишь поддержкой собственного кода.
- Ingest (14/25) — баланс: чуть DevOps, остальное — дисциплина СУБД.
- Valkey (18/25) — «быстро, но дорого в голове»: много тюнинга и аварийных сценариев.
- Rabbit (21/25) — минимум кода у тебя, максимум сложности в инфраструктуре.
Оценка субъективна, попытка переложить на цифры виденье сложности каждого решения. Перейдем к тому, ради чего мы тут все собрались.
Часть пятая: Цифры
SLA (p99 API ≤ 50 мс)
Все четыре кейса выдержали заявленный SLA по входным запросам. Разница в том, насколько уверенно они это делают:
- WAL — минимальные задержки, p99 = 14 мс. Ответы приходят практически мгновенно, так как запрос завершается сразу после записи в локальный журнал.
- RabbitMQ — 23 мс. Здесь есть сетевой hop и подтверждение от брокера, но запас прочности остаётся высоким.
- Ingest — 44 мс, вплотную к верхней границе SLA. Для сценария «50 мс» этого хватает, но зазора на будущее почти нет.
- Valkey — 46 мс, прямо на краю допустимого. Любые пики нагрузки могут выбить его за SLA.
Все четыре решения «держат SLA», но WAL и RabbitMQ имеют запас, Ingest и Valkey балансируют на границе.
Durable latency
Это время до того момента, когда запись гарантированно не потеряется (fsync или confirm).
- WAL — 14 мс, фактически совпадает с API latency. Минимум прослоек = минимум задержки.
- RabbitMQ — 22 мс. Брокер добавляет задержку из-за своей внутренней журнализации и подтверждений.
- Ingest — 44 мс. Всё упирается в транзакцию Postgres, на каждое подтверждение уходит десятки миллисекунд.
- Valkey — 45 мс, почти то же самое: fsync на AOF дороже, чем кажется, и скачет вместе с нагрузкой.
Видно, что «каждая новая прослойка» добавляет десятки миллисекунд к фиксации. WAL выигрывает именно простотой: запись в файл и всё.
Коммиты в основную БД
Тут все варианты упираются в одно «бутылочную горлышко»: медленная основная база, ожидаемо. p99 коммита ~ 12.3 секунд, средний throughput около 16 rps.
- WAL: среднее время ~ 9.8 с. Профиль ровный, вариативность низкая — хорошая предсказуемость.
- Ingest: ~ 11.7 с. Немного хуже WAL, но тоже стабильно.
- Valkey: ~ 11.3 с. Схож с Ingest, чуть быстрее.
- RabbitMQ: выбивается — среднее всего 5.5 с, но при этом хвост тяжёлый, доходит до 12.4 с. Коэффициент вариации самый высокий (0.035), то есть профиль «рваный»: часть задач пролетает быстро, часть задерживается надолго.
Никакая очередь сама по себе не лечит узкое место в базе: throughput одинаковый, разница только в том, насколько предсказуемо сообщение добирается до базы.
Backlog и дренаж
Здесь видно, насколько быстро система накапливает очередь и как справляется с хвостом после сбоя:
- WAL: пик ~ 47k задач, скорость дренажа 353/с, хвост уходит за ~ 133 с. Рабочий, предсказуемый профиль.
- Ingest: чуть больше очередь — 50k, дренаж 362/с, очистка за ~ 138 с. По сути те же цифры, что у WAL.
- Valkey: также 50k в пике, но дренирует медленнее — 327/с, до нуля ~ 152 с. Отставание небольшое, но заметное.
- RabbitMQ: резко выбивается. Очередь раздувается до 88k, скорость дренажа всего 250/с, на полное очищение уходит ~ 353 с.
RabbitMQ тратит больше ресурсов на собственные механизмы и за счёт этого копит самый большой хвост и дренирует его хуже всех. WAL и Ingest — наоборот, разгребают быстрее всего.
Стабильность RPS
Коэффициент вариации показывает, насколько «ровно» система держит нагрузку.
- WAL: CV = 0.004 — почти идеально прямая линия.
- Ingest: CV = 0.003 — ещё ровнее.
- Valkey: CV = 0.026, заметные колебания.
- RabbitMQ: CV = 0.035, самая «пилообразная» динамика.
Чем выше CV, тем чаще будут провалы и скачки нагрузки. Для бизнеса важнее ровный поток — тут выигрывают WAL и Ingest.
Нагрузка на железо
Смотрим на ресурсы контейнеров:
- WAL: worker грузит CPU до 68%, в среднем ~16%. Для single-process нагрузки это нормально.
- Ingest: Postgres-ingest забирает ~9% CPU, пиками до ~32%. Очень умеренно.
- Valkey: CPU потребление невысокое, память в пределах пары гигабайт.
- RabbitMQ: сам брокер в среднем ~19% CPU, но с пиками до 100%. При этом база тоже нагружена.
RabbitMQ на ровном месте создаёт дополнительную нагрузку на CPU, WAL и Ingest в этом плане экономичнее.
Все собранные цифры можно посмотреть тут или самому собрать.
Что в итоге?
По цифрам картинка складывается однозначная:
- WAL и Ingest закрывают SLA и zero-loss с минимальными накладными расходами. Первый вариант даёт мгновенный ответ и предсказуемое поведение ценой поддержки собственного кода. Второй требует отдельной базы и DevOps-дисциплины, но зато использует "привычные" инструменты (бд). В обоих случаях задержка минимальна, профиль ровный, дренаж очереди быстрый.
- Valkey и RabbitMQ тоже справляются с задачей, но делают это хуже: добавляют десятки миллисекунд на ровном месте, сильнее накапливают хвост и дают нестабильный профиль. RabbitMQ особенно выделяется большим backlog и скачущим RPS, а вместе с этим тащит в проект новые точки отказа, дполонительные сложности эксплуатации и накладные расходы.
Из этого следует простой вывод: очередь — не фундамент архитектуры, а специализированный инструмент.
Нужен фан-аут на десятки консьюмеров, маршрутизация по ключам или хитрые сценарии доставки? Тогда Rabbit/Kafka оправданы: они решают задачи иснтументами, которых нет у простого WAL или Ingest.
Нужно просто принять событие и гарантированно не потерять? Файловый журнал или ingest-база справляются проще, быстрее и надёжнее.
Тащить очередь «по умолчанию» — это всё равно что начинать проект сразу с микросервисов в Kubernetes: звучит солидно, но на деле означает больше кода, DevOps шапито, лишняя инфраструктура, больше мониторинга и больше точек отказа.
Очереди нужны не «по умолчанию», а когда без них никак. В остальных случаях — это фетиш, технический долг и лишний костыль, который сам себе суёшь в ...
Что еще почитать?