Очереди все еще не нужны.

Каждый  разработчик думает об архитектуре нового проекта,  и часто приходит к такому заключению: «_Завезу микросервисы, и чтоб красиво кафку туда, или реббит, чтоб общались. Редис для кешей, и все это в кубере. Заживу!_».

Обоснование тому такое: «_Задел на будущее, кучу систем так построили, нормально работает, даже не больно, практически. И распределенка, и отказы с пиками держим. Красота!_». Я мог бы согласится, но не буду. Если начать копать глубже, всплывает много нюансов. Большую часть того, что всплывает, я описал в прошлый раз.

Тогда же, в комментариях, чтобы не быть голословным и ради академического интереса, решил собрать стенд и уже на цифрах тебе показать, что очереди в классическом понимании все еще не нужны.

Расскажу что собирал, как и что измерял и какие результаты получил.

Часть первая: Преамбула

Начну издалека, с теоретического обоснования, как учили еще в университетах.

Есть у тебя сервис А. Базу свою этот сервис и в хвост и в гриву, поэтому запись там медленная — допустим, 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 мс. Ответы приходят практически мгновенно, так как запрос завершается сразу после записи в локальный журнал.
  • RabbitMQ23 мс. Здесь есть сетевой hop и подтверждение от брокера, но запас прочности остаётся высоким.
  • Ingest44 мс, вплотную к верхней границе SLA. Для сценария «50 мс» этого хватает, но зазора на будущее почти нет.
  • Valkey46 мс, прямо на краю допустимого. Любые пики нагрузки могут выбить его за SLA.

Все четыре решения «держат SLA», но WAL и RabbitMQ имеют запас, Ingest и Valkey балансируют на границе.

Durable latency

Это время до того момента, когда запись гарантированно не потеряется (fsync или confirm).

  • WAL14 мс, фактически совпадает с API latency. Минимум прослоек = минимум задержки.
  • RabbitMQ22 мс. Брокер добавляет задержку из-за своей внутренней журнализации и подтверждений.
  • Ingest44 мс. Всё упирается в транзакцию Postgres, на каждое подтверждение уходит десятки миллисекунд.
  • Valkey45 мс, почти то же самое: 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 шапито, лишняя инфраструктура, больше мониторинга и больше точек отказа.

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

Что еще почитать?