8.3RabbitMQ и альтернативы
Сопоставь Kafka и RabbitMQ по жизненному циклу сообщения: в RabbitMQ сообщение маршрутизируется в очередь и обычно удаляется после ack, а в Kafka сохраняется в логе независимо от чтения. Разбери routing, prefetch, confirms и dead-letter retry на примерах.
Модель AMQP 0-9-1: кто здесь кто
RabbitMQ построен на открытом протоколе AMQP версии 0-9-1. Кроме передачи байтов по сети, протокол задаёт модель маршрутизации: как сообщение само находит дорогу к нужной очереди.
Эту модель проще всего понять на обычной почте. Письмо ты не несёшь адресату в квартиру, а сдаёшь в сортировочный центр, написав на конверте адрес. Дальше центр сверяется со своими таблицами и решает, в какие почтовые ящики положить копии. На языке AMQP сортировочный центр называется exchange (обменник), адрес на конверте служит routing key (ключом маршрутизации), а строчка в таблице «письма с таким адресом класть в такой-то ящик» зовётся binding (привязка). Почтовый ящик, где письмо лежит, пока его не заберут, и есть queue (очередь).
Аналогия держится почти до конца, а ломается в одном месте: сортировочный центр ничего не хранит и ничего не возвращает. Если ни одна строчка таблицы не подошла, письмо не уходит обратно отправителю и не ложится в «невостребованные», а просто исчезает.
На собесе первым делом проговори: продюсер в RabbitMQ никогда не публикует в очередь. Он публикует в exchange с каким-то routing key. Буфера в exchange нет, это чистая функция «сообщение + routing key + заголовки → список очередей». Правило, по которому очередь подписана на exchange, называется binding и само содержит binding key (или набор заголовков для headers).
Полный путь сообщения:
- Connection - одно TCP-соединение (и TLS-сессия) до брокера. Дорогое, живёт долго.
- Channel - логический подканал: внутри одного соединения их мультиплексируется
много. Все операции (publish, consume, ack, qos, declare) идут через канал. Канал между
горутинами не делят: в Go правило «один канал - одна горутина». amqp091-go прикрывает
publish и ack мьютексом, но ответ синхронного вызова (declare, qos) на общем канале может
достаться чужой горутине, а в других клиентах перемешанные фреймы рвут соединение с
505 UNEXPECTED_FRAME. Ошибка протокола закрывает канал (или всё соединение), и клиент должен уметь его пересоздать. - Exchange маршрутизирует. Бывает
direct,topic,fanout,headers; отдельно стоит системный default exchange с пустым именем"". Он работает как direct, и к нему каждая очередь автоматически привязана по своему имени. Поэтому «наивный» примерpublish("", "my-queue", body)и работает без объявления обменника: ты попал в default exchange с routing key, равным имени очереди. - Queue - единственное место, где сообщение реально лежит. Это FIFO-структура, и в classic-варианте она живёт на одном узле кластера, на том, где её объявили.
- Consumer либо подписывается (
basic.consume, push-модель), либо сам опрашивает очередь (basic.get, pull). Второе почти всегда антипаттерн: round-trip на каждое сообщение.
Из этой модели следует неприятная вещь: если сообщение не совпало ни с одной привязкой, оно молча исчезает.
Не в DLX и не в «unroutable», а в никуда. Чтобы такие потери увидеть, публикуют с флагом
mandatory=true и слушают basic.return (в Go через
ch.NotifyReturn(...)) либо вешают на exchange
alternate-exchange, куда стекается вся «непонятная почта».
Механика routing key на примерах
Чаще всех работает topic: почти в любом проекте хватает одного topic-обменника на домен.
Ключ состоит из слов через точку, это иерархия длиной до 255 байт. Спецсимволов всего два:
* заменяет ровно одно слово, # - ноль или больше слов.
| Binding key | order.created | order.created.eu | order.paid.eu.b2b | user.created |
|---|---|---|---|---|
order.created | да | нет | нет | нет |
order.* | да | нет | нет | нет |
order.# | да | да | да | нет |
*.created | да | нет | нет | да |
#.eu.# | нет | да | да | нет |
# | да | да | да | да |
Ключ проектируют от общего к частному и кладут в него только то, на что кто-то
реально захочет подписаться: домен.сущность.событие.регион.
Идентификаторы (order.created.42718) класть в ключ можно, но бессмысленно:
на конкретный id всё равно никто не подпишется, а таблица привязок в брокере
строится как дерево и растёт.
| Тип | По чему маршрутизирует | Стоимость | Под какую задачу |
|---|---|---|---|
direct | Точное равенство routing key и binding key | Хеш-таблица, O(1) | Раздача задач по типу: email, sms, push. Приоритеты через отдельные очереди task.high / task.low |
topic | Шаблон по словам ключа (*, #) | Дерево префиксов, чуть дороже direct | Шина доменных событий: один обменник, подписчики сами выбирают срез. Дефолтный выбор в 80 % случаев |
fanout | Ни по чему - всем привязанным | Самый дешёвый | Широковещание: инвалидация кэша на всех подах, «перечитай конфиг», прогрев |
headers | Заголовки сообщения, x-match=all|any | Самый дорогой: перебор привязок | Когда критериев несколько и они не складываются в иерархию (формат + язык + приоритет). На практике почти всегда заменяется на topic |
"" (default) | direct, где binding key = имя очереди | O(1) | Прямая отправка в конкретную очередь. Удобно для RPC-ответов и быстрых прототипов, но жёстко привязывает продюсера к имени очереди |
Нет. Копии делаются по очередям, а не по консьюмерам. Exchange положит по копии в каждую подходящую очередь, а внутри одной очереди сообщение уйдёт ровно одному подписчику (round-robin с оглядкой на prefetch). Этим pub/sub и отличается от work queue в терминах AMQP: pub/sub = очередь на каждого подписчика, work queue = одна очередь на всех. Классическая ошибка: сделали одну очередь на пять сервисов и удивляются, что событие видит только один.
// Declare идемпотентен, звать его можно при каждом старте.
// Повторный declare с другими параметрами вернёт PRECONDITION_FAILED и закроет канал.
err := ch.ExchangeDeclare(
"ex.orders", // имя
"topic", // тип
true, // durable: переживёт рестарт брокера
false, // autoDelete: не удалять при отключении последней привязки
false, // internal: можно публиковать снаружи
false, // noWait: ждём подтверждения от брокера
nil,
)
q, err := ch.QueueDeclare(
"q.billing.orders", // именованная очередь: имя = контракт владельца-консьюмера
true, // durable
false, // autoDelete
false, // exclusive: доступна и другим соединениям
false, nil,
)
// У очереди сколько угодно привязок, и работают они как OR, а не AND.
_ = ch.QueueBind(q.Name, "order.created.#", "ex.orders", false, nil)
_ = ch.QueueBind(q.Name, "order.paid.#", "ex.orders", false, nil)
Подтверждения на стороне консьюмера: ack, nack, reject
Когда брокер отдаёт сообщение консьюмеру, оно не удаляется из очереди, а переходит в состояние unacked и остаётся привязанным к каналу. Каждая доставка получает delivery tag, монотонный счётчик внутри канала. Это не идентификатор сообщения и не сквозной номер: после реконнекта нумерация начинается заново. Закрыть доставку консьюмер может тремя способами:
| Команда | Что делает | Когда применять |
|---|---|---|
basic.ack(tag, multiple) | Удаляет сообщение из очереди навсегда. multiple=true подтверждает все доставки с тегом ≤ указанного - батчевое подтверждение | Обработка успешно завершена и её эффект зафиксирован (транзакция БД закоммичена) |
basic.nack(tag, multiple, requeue) | Расширение RabbitMQ поверх AMQP: то же, что reject, но умеет multiple | Ошибка. requeue=true - вернуть в очередь, false - выкинуть или в DLX |
basic.reject(tag, requeue) | Стандарт AMQP, ровно одно сообщение | То же самое, когда батч не нужен |
| ничего не делать | Сообщение висит в unacked, пока жив канал | Никогда. Это утечка: очередь не пустеет, prefetch забит, метрика messages_unacknowledged растёт |
Если канал или соединение закрывается (падение пода, сетевой обрыв, ошибка протокола),
все unacked-сообщения этого канала автоматически возвращаются в очередь и будут
доставлены заново с флагом redelivered=true. Так в RabbitMQ и устроен at-least-once:
таймаут видимости, как в SQS, не нужен, гарантию даёт сам разрыв TCP-соединения.
autoAck=true / noAck)
С autoAck брокер считает сообщение доставленным в момент записи в сокет
и сразу удаляет его из очереди. Он не ждёт ни обработки, ни даже того, что клиент реально
прочитает байты из TCP-буфера. Значит, пропадает всё, что было:
- в TCP-буфере ядра, когда под убили;
- во внутренней очереди клиентской библиотеки (в Go это канал
<-chan Delivery, куда библиотека заранее складывает доставки); - в обработке, если случилась паника, OOM или
SIGKILLпри деплое.
Вторая беда: autoAck отключает prefetch. Неподтверждённых сообщений не существует, ограничивать нечего, поэтому брокер льёт в консьюмера всё, что есть, на максимальной скорости, и клиент съедает память. Автоack допустим ровно там, где допустима потеря: метрики, телеметрия, живые логи.
Ack делают последним, а не первым. Сначала транзакция БД закоммичена, файл записан
и fsync-нут, письмо ушло, и только потом ack. Сделаешь
наоборот и получишь at-most-once с честной потерей данных. У долгой обработки опасность
обратная - consumer_timeout (по умолчанию 30 минут,
с RabbitMQ 3.8.17): не подтвердил за это время, и брокер принудительно закрывает канал
с PRECONDITION_FAILED, а работа уходит на переобработку. Длинные задачи либо
режут на шаги, либо поднимают таймаут в конфиге брокера.
Prefetch (basic.qos): сколько сообщений можно держать в руках
Prefetch («предвыборка») задаёт, сколько сообщений брокер может выдать одному консьюмеру авансом, не дожидаясь подтверждений по уже выданным. Это обратное давление из главы 8.1, сжатое в одно число: официант несёт не больше трёх тарелок, и четвёртую ему не дадут, пока он не поставит хотя бы одну.
basic.qos(prefetchSize, prefetchCount, global) задаёт максимум неподтверждённых
доставок. Когда их набралось prefetchCount, этот потребитель ничего нового не получит:
следующее сообщение уйдёт кому-то другому. prefetchSize (лимит в байтах)
на практике не используют. global=false означает «лимит на каждого консьюмера канала»,
global=true - «лимит на весь канал суммарно».
По умолчанию prefetch не ограничен, и хуже дефолта не придумать: первый подключившийся консьюмер выгребает всю очередь себе в память, остальные простаивают.
Как подобрать значение? Каждое подтверждение стоит сетевого round-trip, поэтому
prefetch=1 даёт идеально ровное распределение и низкий throughput: консьюмер
простаивает, пока ack идёт туда, а следующее сообщение обратно. Ориентиры такие:
| Профиль задачи | prefetch | Почему |
|---|---|---|
| Долгая обработка (секунды и минуты): конвертация видео, отчёт, вызов внешнего API | 1 | Round-trip в единицы миллисекунд на фоне секунд не виден, зато задача не «залипнет» за спиной другой |
| Быстрая обработка (единицы миллисекунд), много мелких сообщений | 100–300 | Иначе сеть станет узким местом раньше, чем CPU |
| Пул из N воркеров внутри одного процесса | N или 2N | Держать все воркеры занятыми и один-два в очереди на вход, не больше |
| Неизвестно | 10–50 | Безопасный старт: и память ограничена, и сеть не пилит round-trip на каждое |
Память тут не единственная цена. Сообщения из буфера консьюмера уже не будут перераспределены: их не увидит новый под, который ты только что поднял под пик. При prefetch = 5000 и 10 секундах на сообщение последнее из буфера подождёт почти 14 часов, а метрика длины очереди всё это время показывает ноль. Поэтому при неоднородном времени обработки prefetch держат маленьким, а не «побольше, чтобы быстрее».
Requeue и бесконечный цикл переобработки
nack(requeue=true) возвращает сообщение в очередь. Вопрос в том, куда:
RabbitMQ старается поставить его как можно ближе к исходной позиции, то есть практически в голову.
Если консьюмер один, а ошибка постоянная (невалидный JSON, отсутствующая запись, битая схема),
выходит цикл: доставка → ошибка → requeue → доставка через микросекунду. Ретраем такое не назовёшь:
получается горячий цикл на 100 % CPU, который заодно держит за собой всю очередь.
// Плохо: вечный цикл на постоянной ошибке
msgs, _ := ch.Consume(q, "", false, false, false, false, nil)
for d := range msgs {
if err := handle(d.Body); err != nil {
d.Nack(false, true) // requeue = true
continue // и через мгновение снова сюда
}
d.Ack(false)
}
// Хорошо: разделяем транзиентные и постоянные ошибки
for d := range msgs {
err := handle(d.Body)
switch {
case err == nil:
d.Ack(false)
case errors.Is(err, ErrPermanent), attempts(d) >= maxAttempts:
// DLX у очереди один, и он ведёт в retry. В DLQ кладём сами.
if publishToDLQ(ctx, ch, d) == nil {
d.Ack(false)
} // не вышло - не подтверждаем, сообщение вернётся
default:
d.Nack(false, false) // через DLX в retry-очередь с TTL, см. ниже
}
}
Ещё одна неприятность: в AMQP нет встроенного счётчика попыток. Булево поле
redelivered скажет «это не первая доставка», но номер попытки не назовёт.
Считать попытки можно тремя способами:
- Через
x-death: при каждом проходе через dead-lettering брокер сам наращиваетcountв заголовкеx-death. Самый честный вариант, руками ничего писать не надо, но работает он, только если ретраи идут через DLX. - Своим заголовком: консьюмер читает
x-retry-count, увеличивает, публикует сообщение заново и подтверждает исходное. Работает всегда, но публикация + ack не атомарны: упал между ними и потерял сообщение (или продублировал, если порядок обратный). - Внешним счётчиком в Redis по
message_id. Он нужен, когда попытки считают сквозь несколько разных очередей и сервисов.
DLX - dead letter exchange
Термин dead letter пришёл с почты: так называют «мёртвое письмо», которое не удалось ни доставить, ни вернуть отправителю. Такие письма уходят в отдельный отдел, где их разбирают вручную. Брокеры взяли и слово, и идею: необработанное сообщение не выбрасывают, а откладывают в сторону, чтобы человек потом посмотрел, что с ним не так. Отсюда и DLQ (dead letter queue), «очередь битых сообщений».
DLX не «специальный тип очереди», а обычный exchange: в него очередь пересылает сообщения, которые из неё выбыли не по-хорошему. Задают его двумя аргументами при объявлении очереди, а в проде правильнее через policy, чтобы не передекларировать очередь ради смены маршрута:
args := amqp.Table{
"x-dead-letter-exchange": "dlx.orders", // куда переслать
"x-dead-letter-routing-key": "orders.dead", // с каким ключом; без него сохранится исходный
}
ch.QueueDeclare("q.orders", true, false, false, false, args)
Причин уйти в DLX ровно четыре. На собесе назови все: кандидаты обычно помнят только первую.
reason в x-death | Что произошло | Типичная причина в коде |
|---|---|---|
rejected | basic.reject или basic.nack с requeue=false | Консьюмер решил, что ретрай не поможет |
expired | Истёк TTL сообщения: свой (expiration) или общий для всей очереди (x-message-ttl) | Задержка в retry-очереди или протухшие данные |
maxlen | Очередь упёрлась в x-max-length / x-max-length-bytes | Перегрузка; при overflow=drop-head вытесняется самое старое |
delivery_limit | Quorum-очередь: превышен x-delivery-limit перед доставкой | Poison message, брокер сам отправляет его в DLX |
При каждом прохождении брокер дописывает запись в заголовок x-death. Там лежит
массив структур с полями queue, reason, count,
exchange, routing-keys, time. Другого
штатного счётчика попыток в AMQP нет: x-death[0].count честно говорит,
сколько раз сообщение уже возвращалось через dead-lettering.
- DLX не существует или к нему не привязана ни одна очередь. Тогда сообщение
молча отбрасывается, как обычный unroutable publish, а консьюмер никакой ошибки
не увидит: он уже сделал
nack. - Цикл: DLX очереди A ведёт обратно в A (или в B, чей DLX ведёт в A). Сообщение
крутится по кругу,
x-death.countрастёт, CPU горит. Сам RabbitMQ обрывает цикл, только если сообщение вернулось в очередь, которую уже проходило, и на всём круге его ни разу не отклонили: выбывало оно по TTL, переполнению или лимиту доставок. Был на круге хоть одинrejected— защиты нет. - Никто не читает DLQ. Формально сообщение цело, а на деле про него вспомнят
через месяц, когда кто-то спросит, почему не прошёл платёж. Нужны метрика
rabbitmq_queue_messages{queue=~".*dlq"}(из per-object метрик) и алерт «в DLQ появилось хоть что-то».
Отложенный ретрай: лестница из TTL + DLX
В RabbitMQ нет встроенной «доставки через 30 секунд». Стандартный трюк: завести очередь
без консьюмеров, у которой есть x-message-ttl и DLX обратно на рабочий
обменник. Сообщение полежит там положенное время, «протухнет» и вернётся в работу.
Хочется сделать «одну retry-очередь и класть в каждое сообщение свой
expiration». Получится система, где сообщение с задержкой 1 секунда,
попавшее за сообщение с задержкой 10 минут, выйдет через 10 минут. Classic-очередь
проверяет TTL только у головы: она FIFO, и «протухшее в середине»
физически не может выйти раньше. Решений два: лестница из очередей с фиксированным TTL
(по одной на ступень) или плагин
rabbitmq_delayed_message_exchange, который держит отложенные сообщения
в Mnesia и публикует по таймеру.
Durable, persistent, confirms: четыре независимых условия
«Сообщение переживёт рестарт брокера» требует не одной галочки, а конъюнкции четырёх условий, и каждое отвечает за свой кусок пути. Провалить достаточно одно.
| Что настраиваем | Где | Что будет, если забыть |
|---|---|---|
durable=true у exchange | ExchangeDeclare | После рестарта обменника нет; публикация в него закрывает канал с NOT_FOUND |
durable=true у очереди | QueueDeclare | Определение очереди не восстановится; очередь исчезает вместе со всем содержимым |
DeliveryMode = 2 (persistent) | у каждого сообщения | Тело живёт только в памяти. Очередь durable - но пустая после рестарта |
| Publisher confirms | ch.Confirm() + NotifyPublish | Продюсер не знает, дошло ли. Publish без confirms - это запись в TCP-сокет, а не гарантия |
Комбинации ведут себя ровно по таблице, без сюрпризов: transient-сообщение в durable-очереди пропадёт при рестарте (при нехватке памяти его могут выгрузить на диск, но это экономия памяти, а не долговечность), persistent-сообщение в non-durable-очереди пропадёт вместе с очередью.
// Полный «надёжный» publish
if err := ch.Confirm(false); err != nil { return err } // включаем confirms на канале
confirms := ch.NotifyPublish(make(chan amqp.Confirmation, 256)) // окно на 256 неподтверждённых
returns := ch.NotifyReturn(make(chan amqp.Return, 8)) // сюда придут unroutable
err := ch.PublishWithContext(ctx, "x.orders", "order.created",
true, // mandatory: не смаршрутизировалось - верни, а не выбрось
false, // immediate: RabbitMQ не поддерживает с 3.0, всегда false
amqp.Publishing{
DeliveryMode: amqp.Persistent, // = 2, иначе тело только в RAM
MessageId: evt.ID, // ключ дедупликации для консьюмера
Timestamp: time.Now(),
ContentType: "application/json",
Body: body,
})
c := <-confirms
if !c.Ack {
// брокер не принял: публикуем заново, дубликаты тут норма
}
Для classic-очереди брокер отправляет basic.ack продюсеру, когда сообщение
принято всеми очередями, в которые оно смаршрутизировалось, а если оно
persistent, то и сброшено на диск. Для quorum-очереди ack приходит, когда запись реплицирована
на большинство узлов Raft-группы. Поэтому «включили persistent, значит не потеряем»
без confirms не работает: между вызовом Publish и fsync есть окно в сотни
миллисекунд. Упал брокер в этом окне, и сообщения нет, а продюсер об этом не узнает.
Если ждать каждый confirm синхронно, платишь round-trip за сообщение, и throughput
не поднимется выше единиц тысяч в секунду. Правильно держать окно: публикуем пачкой, ведём map
deliveryTag → сообщение, вычёркиваем по приходящим Ack
(не забывая про флаг multiple, он подтверждает всё до тега включительно),
переотправляем по Nack и по таймауту.
Classic, quorum, streams
- Classic queue, исходный тип, живёт целиком на одном узле. Реплицировать её умело только
устаревшее зеркалирование (
ha-mode): его объявили deprecated и выпилили в 4.0. Она дешёвая по ресурсам и годится для некритичных задач и очередей, которые не жалко потерять. - Quorum queue - реплицированная очередь на Raft, современный дефолт для всего,
что нельзя терять. Всегда durable, подтверждение = запись на большинство узлов, есть
штатная обработка poison message через
x-delivery-limit. Платить приходится диском и памятью (Raft-лог держится до snapshot), отсутствием transient-режима, exclusive-очередей и global-prefetch. - Stream (3.9+) - append-only лог с офсетами: несколько независимых читателей, перечитывание с любой позиции, ретеншен по времени и размеру. По сути так RabbitMQ отвечает Kafka, когда нужно не «раздать задачи», а «многие читают одну ленту».
Kafka vs RabbitMQ: где проходит граница
Хуже всего ответить на собесе «Kafka быстрее». Они различаются не скоростью, а моделью хранения, и из неё выводится всё остальное. Kafka хранит упорядоченный лог, из которого чтение ничего не удаляет: позиция читателя остаётся его собственным числом. RabbitMQ держит набор очередей, из которых сообщение физически исчезает после ack, и мощный маршрутизатор перед ними.
| Свойство | Kafka | RabbitMQ |
|---|---|---|
| Модель | Распределённый лог с офсетами, читатель хранит позицию | Очереди + маршрутизатор, сообщение исчезает после ack |
| Перечитать историю | Да, seek на любой офсет, пока жив retention | Нет (кроме streams). Прочитано - значит удалено |
| Много независимых потребителей одного потока | Естественно: N consumer groups, каждая со своим офсетом | N очередей, привязанных к одному exchange; каждая копия хранится отдельно |
| Маршрутизация | Примитивная: топик и hash(key). Фильтрация - на стороне консьюмера или через отдельный поток | Богатейшая: direct / topic-паттерны / fanout / headers, alternate exchange, consistent-hash плагин |
| Гранулярность подтверждения | Офсет = «прочитано всё до». Одно «плохое» сообщение нельзя пропустить, не сдвинув курсор (share group из Kafka 4.2 подтверждает каждую запись отдельно) | Каждое сообщение отдельно: ack / nack / reject, любой порядок |
| Порядок | Строгий внутри партиции; ключ фиксирует партицию | FIFO у очереди, но параллельные консьюмеры и requeue его разрушают |
| Потолок параллелизма | Число партиций в топике (у share group из Kafka 4.2 его нет, но нет и порядка) | Практически нет; упирается в производительность самой очереди |
| Отложенная доставка, приоритеты | Нет из коробки (делают retry-топиками и внешним планировщиком) | TTL+DLX, плагин delayed exchange, x-max-priority |
| Пропускная способность | Сотни тысяч и миллионы сообщений в секунду, батчи, zero-copy | Десятки тысяч в секунду на очередь; узкое место - Erlang-процесс очереди |
| Задержка одного сообщения | Единицы–десятки мс (батчинг, linger.ms) | Субмиллисекундная при малой нагрузке |
| Размер сообщения | max.message.bytes ~1 МБ по умолчанию; для большего - claim check | По умолчанию до 16 МБ (max_message_size, можно поднять до 512 МБ), практически - держат мелкими: очередь живёт в памяти |
| Эксплуатация | Тяжелее: KRaft (ZooKeeper - до 4.0), ISR, ребалансировки, партиционирование | Легче стартовать, сложнее масштабировать одну очередь |
Как формулировать выбор
- Kafka, если поток служит лентой фактов, которую читают несколько независимых систем и хотят перечитать: событийная интеграция между доменами, аналитика и стриминг, CDC из БД, аудит, материализация проекций, метрики. Признак: «нужен replay» или «потребителей будет больше, чем сейчас».
- RabbitMQ, если по потоку идут задачи: «сконвертируй видео», «отправь письмо», «сходи в 1С». Признаки: нужна сложная маршрутизация по атрибутам, per-message ретраи с задержкой, приоритеты, RPC-стиль request/reply, много мелких разнородных очередей, команда не хочет содержать Kafka.
- Оба сразу тоже нормальная архитектура, никакого хаоса в этом нет: события домена в Kafka, фоновые задачи в RabbitMQ. Ответ «взяли бы Kafka, потому что она модная» идёт в минус, ответ «зависит от того, лента это или задачи» в плюс.
NATS и JetStream
Core NATS - предельно простой брокер на Go: один статический бинарник, десятки
мегабайт памяти, задержки в десятки микросекунд. Модель называется subject-based messaging:
публикуют в тему вида orders.eu.created, подписываются по шаблону с
* (ровно одно слово) и > (хвост целиком). И в отличие от
остальных, Core NATS не хранит ничего. Если в момент публикации подписчика нет,
сообщение просто исчезает: at-most-once, никаких ack. Зато из коробки есть то, чего нет
у соседей: queue groups (подписчики с одинаковым именем группы делят поток,
получаются competing consumers без объявления очередей) и встроенный request/reply с
автоматическим inbox-subject для ответа.
JetStream добавляет поверх слой персистентности и включается флагом. С ним появляются:
- Stream - лог, захватывающий один или несколько subjects, с политикой хранения:
limits(как Kafka: держим, пока влезает поmax_msgs,max_bytes,max_age),interest(удаляем, когда все подписчики подтвердили) иworkqueue(как очередь: сообщение удаляется, как только его подтвердил один консьюмер). Хранить можно в file или memory, реплицирует Raft (replicas: 3). - Consumer - именованная позиция в стриме, durable или ephemeral, push или pull,
с
AckPolicy(none/all/explicit),AckWait,MaxDeliver,BackOff(готовая лестница задержек ретрая, которую в RabbitMQ приходится собирать из очередей) иFilterSubject.MaxAckPendingработает как prefetch. - Дедупликация из коробки: заголовок
Nats-Msg-Idиduplicate_windowна стриме. Брокер сам отбросит повторную публикацию с тем же идентификатором внутри окна. В Kafka ради этого включают идемпотентного продюсера, в RabbitMQ пишут руками. - KV и Object store надстроены над стримом: key-value с watch на изменения и хранилище блобов. Часто они закрывают потребность, ради которой иначе тянули бы ещё и Redis.
«NATS - это шина с микросекундными задержками и почти нулевой эксплуатацией;
JetStream добавляет к ней persistence и умеет вести себя и как лог (limits),
и как очередь задач (workqueue). Берут, когда нужен лёгкий нервный узел
для микросервисов, request/reply и очень много subject-ов; не берут, когда нужна
экосистема Kafka: Connect, Streams, устоявшиеся коннекторы и опыт команды».
Вопросы
5Пять сущностей
- Connection - TCP+TLS-соединение до брокера, дорогое, открывается один раз на процесс.
- Channel - логический подканал внутри соединения. Все команды идут через него. Не потокобезопасен: в Go заводят по каналу на горутину. Ошибка протокола закрывает канал, и приложение должно уметь его пересоздать.
- Exchange - маршрутизатор без состояния. Четыре типа плюс безымянный
default exchange (
""), к которому каждая очередь автоматически привязана по своему имени. Поэтому «hello world» сpublish("", "my-queue", body)и работает без объявления обменника. - Queue - единственное место, где сообщение реально лежит. FIFO, у classic-типа живёт на одном узле кластера.
- Binding связывает exchange → queue через binding key (или набор заголовков). Между одной парой может быть несколько привязок с разными ключами.
Типы exchange
| Тип | Правило | Когда берут |
|---|---|---|
direct | binding key совпадает с routing key посимвольно | Адресная доставка: очередь на задачу, приоритеты через разные ключи |
topic | Ключ режется точками на слова; * - ровно одно слово, # - ноль или больше | Событийная маршрутизация: order.*.eu, audit.#. Дефолтный выбор в 90 % случаев |
fanout | Ключ игнорируется, копия во все привязанные очереди | Broadcast: инвалидация кэшей, конфиг-апдейты |
headers | Матчинг по заголовкам, x-match: any|all | Редко: когда критериев несколько и они не укладываются в одну строку. Медленнее topic |
Проговори отдельно: fanout означает не «одно сообщение всем», а «копию в каждую очередь». Если три сервиса подписаны на fanout через одну общую очередь, выйдет не рассылка, а конкуренция: сообщение достанется ровно одному. Веерная рассылка на N подписчиков = N очередей.
Топология в проде
Объявления идемпотентны, но с проверкой: повторный QueueDeclare с
другими аргументами не «обновит» очередь, а закроет канал с
PRECONDITION_FAILED. Поэтому то, что меняется (DLX, TTL, лимиты длины),
в проде задают через policy: политики применяются к очередям по regexp и меняются
без передекларации:
rabbitmqctl set_policy dlx-all "^q\\.orders\\." \
'{"dead-letter-exchange":"dlx.orders","message-ttl":600000,"max-length":100000}' \
--apply-to queues
В никуда. Не в DLX (DLX работает на выходе из очереди, а в очередь сообщение не
попало) и не в «unroutable»: оно просто отбрасывается, а Publish вернёт
nil. Ловят двумя способами: mandatory=true плюс
ch.NotifyReturn(...), чтобы брокер вернул сообщение продюсеру,
или alternate-exchange на обменнике, через который «непонятная почта» стекается
в отдельную очередь. Метрика для алерта:
rabbitmq_channel_messages_unroutable_dropped_total.
autoAck=true, requeue=true на постоянной ошибке и отсутствие
ограничения на число попыток.Ack, nack, reject
basic.ack- «обработано, удаляй». Только после этого сообщение исчезает из очереди. До ack оно в состоянии unacked: невидимо для других консьюмеров, но не удалено.basic.nack(multiple, requeue)- «не смог». Сrequeue=trueвернётся в очередь, сrequeue=falseуйдёт в DLX (или пропадёт, если DLX не настроен).multiple=trueотносится ко всем тегам до указанного включительно.basic.rejectпоявился раньше nack и умеет то же самое, но только для одного сообщения.- Разрыв канала автоматически возвращает все unacked сообщения этого канала в очередь. На этом и держится базовая гарантия at-least-once: упавший консьюмер ничего не теряет, но всё повторит.
ch.Consume(q, "", true, ...) означает «считай доставленным
в момент отправки». Брокер удаляет сообщение, ещё не зная, дошло ли оно по сети,
и уж точно не зная, обработалось ли. Вдобавок autoAck отключает prefetch, и брокер
зальёт в консьюмера всю очередь до OOM. Законный сценарий один: поток,
который не жалко (метрики, телеметрия, логи).
Prefetch
basic.qos(prefetchCount=N) работает как кредитное окно: «не отправляй больше N
неподтверждённых этому консьюмеру». Без него брокер раздаёт по кругу, но
мгновенно, и вся очередь оседает в памяти первого подключившегося
консьюмера, а второй под простаивает. Ориентиры: долгая обработка - 1, быстрые
мелкие сообщения - 100–300, пул из N воркеров - N или 2N, «не знаю» - 10–50.
У большого prefetch есть обратная сторона - head-of-line blocking: набранные в буфер
сообщения уже не перераспределятся на новый под, а метрика длины очереди при этом
покажет ноль.
Requeue
nack(requeue=true) кладёт сообщение обратно примерно на исходную
позицию, то есть почти в голову. При постоянной ошибке и одном консьюмере
выходит горячий цикл на 100 % CPU, а не ретрай, и очередь стоит. В AMQP нет счётчика
попыток: поле redelivered булево. Считать попытки можно через
x-death[0].count (заполняет сам брокер при dead-lettering), своим
заголовком с перепубликацией или внешним счётчиком в Redis по message_id.
DLX
Обычный exchange, указанный очереди через x-dead-letter-exchange.
Сообщение попадает туда по четырём причинам: rejected (nack/reject
с requeue=false), expired (истёк TTL), maxlen
(переполнение x-max-length), delivery_limit (quorum-очередь
исчерпала x-delivery-limit). Через связку «wait-очередь с TTL и без
консьюмеров + DLX обратно» строят отложенные ретраи; ступени делают отдельными
очередями, потому что TTL в classic-очереди проверяется только у головы.
Ошибки делят на транзиентные, где ретрай поможет (сеть, таймаут, дедлок БД),
и постоянные, где он не поможет никогда (невалидный JSON, нет обязательного поля,
бизнес-правило нарушено). Постоянные отправляют сразу в DLQ, транзиентные
в retry-лестницу 5s → 30s → 5m, а после исчерпания попыток тоже в DLQ. И обязательно
ставят алерт на непустую DLQ: очередь, в которую никто не смотрит, равна
/dev/null с задержкой.
delivery_mode=2 у сообщения и
publisher confirms у продюсера. Забыть одно - потерять всё; классический вопрос-ловушка
«сделали очередь durable, почему сообщения пропали после рестарта».Кто за что отвечает
- durable exchange сохраняет определение обменника после рестарта. Без него
публикация после перезапуска закроет канал с
NOT_FOUND. - durable queue переносит через рестарт определение очереди, а не сообщения: очередь после рестарта на месте, но окажется пустой, если сообщения были transient.
- persistent message (
DeliveryMode = 2) - брокер обязан записать тело на диск. Transient-сообщение живёт в памяти. При нехватке RAM его могут выгрузить на диск, но это экономия памяти, а не долговечность, и рестарт его всё равно убьёт. - С publisher confirms продюсер узнаёт, что брокер принял сообщение. Без них
Publishпросто пишет в TCP-сокет: ошибки не будет, гарантии тоже.
Что значит confirm
Брокер шлёт basic.ack продюсеру, когда сообщение принято всеми
очередями, куда оно смаршрутизировалось, и (для persistent) сброшено на диск;
для quorum-очереди ack приходит после репликации на большинство узлов Raft-группы. Между
вызовом Publish и fsync есть окно в сотни миллисекунд, и падение брокера
в нём съедает сообщение. Синхронно ждать каждый confirm нельзя (round-trip на
сообщение), поэтому держат окно неподтверждённых: map
deliveryTag → message, вычёркиваем по Ack (не забыв про флаг
multiple: он подтверждает всё до тега включительно), переотправляем
по Nack и по таймауту.
Цена и что выбирают сегодня
Persistent + confirms снижают пропускную способность в разы: каждый publish упирается
в диск. Поэтому долговечность включают выборочно: платежи и заказы -
persistent и confirms, телеметрия и «пользователь открыл экран» - transient
и fire-and-forget. И помни, что classic mirrored queues
(ha-mode) устарели и удалены в RabbitMQ 4.0. Реплицируют сегодня через
quorum queues на Raft, где ack означает запись на большинство узлов. Они всегда
durable, умеют x-delivery-limit против poison message, но не поддерживают
transient-режим, exclusive-очереди и global prefetch, а Raft-лог занимает диск до snapshot.
Нет, гарантия тут одна: «брокер подтвердил, значит записал». Потерять сообщение по-прежнему можно на трёх других участках: продюсер упал до publish (лечится transactional outbox), консьюмер сделал ack до фактической обработки (лечится ack после коммита работы), весь узел с очередью потерян безвозвратно (лечится quorum-очередями и репликацией). Хороший ответ проходит по всей цепочке и не останавливается на флажке durable.
Что следует из модели
- Replay. В Kafka историю перечитывают штатным
seekна офсет (пересобрать проекцию, залить новый сервис историей). В RabbitMQ прочитанного больше нет: чтобы «перечитать», надо было заранее завести ещё одну очередь на тот же exchange. - Много потребителей. В Kafka N consumer groups читают один и тот же лог, данные хранятся один раз. В RabbitMQ у каждого подписчика своя очередь, то есть N копий сообщения в памяти и на диске.
- Гранулярность. Consumer group в Kafka коммитит офсет, то есть «прочитано всё до». Пропустить одно «плохое» сообщение, не сдвинув курсор, нельзя, отсюда retry-топики. RabbitMQ подтверждает каждое сообщение отдельно, в любом порядке - отсюда per-message ретраи, DLX, приоритеты.
- Параллелизм и порядок. В Kafka потолок консьюмеров в consumer group = число партиций, зато порядок внутри ключа гарантирован. В RabbitMQ консьюмеров может быть сколько угодно, но сквозного порядка нет уже при двух.
- Маршрутизация. У Kafka её практически нет: топик и
hash(key), фильтрует консьюмер. У RabbitMQ есть topic-паттерны, headers, alternate exchange, consistent-hash, и логика «кому это надо» живёт в брокере.
Развилка выбора
| Требование в задаче | Ответ |
|---|---|
| Нужен replay, аналитика, CDC, несколько независимых потребителей потока | Kafka |
| Сотни тысяч сообщений в секунду, поток однородный | Kafka |
| Строгий порядок по бизнес-ключу (по клиенту, по заказу) | Kafka, ключ = этот идентификатор |
| Фоновые задачи, воркеры, «сделай и забудь» | RabbitMQ |
| Ретраи с задержкой, приоритеты, TTL, сложная маршрутизация по атрибутам | RabbitMQ |
| Request/reply поверх брокера | RabbitMQ (или NATS) |
| Неравномерная длительность задач, надо докинуть 50 воркеров под пик | RabbitMQ или share group в Kafka 4.2+: consumer group упрётся в число партиций |
| Маленькая команда, нет SRE, «лишь бы работало» | RabbitMQ или managed-Kafka |
«Держать оба нормально: события домена в Kafka, потому что их читают несколько команд и иногда переигрывают; фоновые задачи в RabbitMQ, потому что там нужны приоритеты и отложенные ретраи. И граница размывается: у RabbitMQ появились streams с офсетами, у Kafka retry-топики закрывают часть сценариев очередей, а с 4.2 появились и настоящие очереди, share groups. Но модель хранения у них по-прежнему разная, и выбирать надо по ней».
limits), и как очередь задач (workqueue).Core NATS
Один статический бинарник на Go, десятки мегабайт памяти, задержки в десятки
микросекунд, кластеризация через gossip и супер-кластеры между регионами. Маршрутизация
строится на subjects вида orders.eu.created с шаблонами
* (ровно одно слово) и > (весь хвост). Субъекты не объявляют
заранее, их можно заводить по одному на сущность. Такого не выдержит ни
Kafka (топик = каталоги и файлы), ни RabbitMQ (очередь = процесс Erlang).
У соседей из коробки нет двух вещей. Это queue groups, где подписчики с одинаковым именем группы делят поток (competing consumers без объявления очередей), и request/reply с автоматическим временным inbox-subject для ответа. Платить за это приходится отсутствием хранения: at-most-once, никаких ack, а без подписчика сообщение просто отбрасывается.
JetStream
- Stream захватывает набор subject-ов и хранит их в file или memory storage
с репликацией на Raft. Политика
limits- как Kafka (держим, пока влезает поmax_msgs/max_bytes/max_age),interestудаляет, когда все подписчики подтвердили,workqueueработает как очередь: сообщение удаляется после ack одного консьюмера. - Consumer - именованная позиция: durable или ephemeral, push или pull,
AckPolicy(none/all/explicit),AckWait,MaxDeliver, готовая лестницаBackOffдля ретраев,FilterSubject.MaxAckPendingздесь вместо prefetch. - Дедупликация публикации: заголовок
Nats-Msg-Idплюсduplicate_windowна стриме, и брокер сам отбросит повтор внутри окна. В Kafka ради этого включают идемпотентного продюсера, в RabbitMQ пишут руками. - KV и Object store поверх стрима: key-value с
Watchна изменения и хранилище блобов. Нередко закрывают потребность, ради которой иначе завели бы Redis.
Как позиционировать
JetStream садится между Kafka и RabbitMQ: он даёт персистентность и ретраи, как RabbitMQ, и офсеты с перечитыванием, как Kafka, а эксплуатировать его заметно дешевле обоих. Берут, когда нужен лёгкий «нервный узел» для микросервисов, request/reply и много subject-ов, когда важен edge/IoT или мультирегион. Не берут, когда нужна экосистема Kafka (Connect, Streams, ksqlDB, готовые коннекторы) или когда команда уже умеет эксплуатировать Kafka и не хочет второй брокер.
Так и скажи, но всё равно назови разделение «core = at-most-once без хранения,
JetStream = persistence с политиками retention» и одну отличительную черту NATS:
дедупликацию по Nats-Msg-Id из коробки или queue groups
без объявления очередей. Этого хватит, чтобы закрыть вопрос: проверяют
не опыт, а понимание, зачем существует третий вариант.
Что стоит запомнить
- RabbitMQ exchange маршрутизирует по binding и routing key в одну или несколько очередей.
- Manual ack и prefetch ограничивают незавершённую работу consumer.
- Publisher confirm говорит о подтверждении брокером публикации, а mandatory return сообщает о невозможности маршрутизации.
Частые ошибки
- Durable queue, persistent message и publisher confirm решают разные части сохранности.
- Nack с requeue=true при постоянной ошибке может создать горячий бесконечный цикл.
- Confirm не заменяет идемпотентность: publisher может не узнать исход после потери ответа и повторить публикацию.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Разбери prefetch
В RabbitMQ очередь содержит 1000 сообщений, у consumer выставлен prefetch=10 и есть 3 активных consumers. Сколько сообщений максимум брокер выдаст им неподтверждёнными, если считать лимит на каждого consumer?
if err := ch.Qos(10, 0, false); err != nil { return err }
msgs, err := ch.Consume(q, "", false, false, false, false, nil)
Ответ и разбор
До 30 сообщений: 10 каждому из трёх consumers. Это ограничивает число сообщений в полёте и память обработчиков, но не гарантирует общую справедливость при разной длительности работы.
Код-ревью
2. Исправь бесконечный requeue
При любой ошибке handler вызывает Nack(false, true). Для постоянной ошибки это создаёт немедленную повторную доставку. Предложи разделение ошибок и путь повторов.
if err := handle(d.Body); err != nil {
_ = d.Nack(false, true)
}if err := handle(d.Body); err != nil {
_ = d.Nack(false, true) // redeliver immediately
}
Ответ и разбор
Различай транзиентную и постоянную ошибку. Для временной направляй сообщение в retry queue с TTL/backoff и последующим возвратом; для постоянной или превышения лимита отправляй в DLQ с причиной. Ack исходное сообщение только после успешной публикации в retry/DLQ, иначе при неудаче маршрутизации его можно потерять.
Применение
3. Проверь четыре условия публикации
Требуется пережить рестарт RabbitMQ и обнаружить сообщение, которое не попало ни в одну очередь. Какие настройки/механизмы нужны помимо одного persistent flag?
Ответ и разбор
Нужны durable exchange/queue, persistent delivery mode для сообщения, publisher confirms и публикация с mandatory=true для возврата unroutable сообщения. Эти гарантии независимы: persistent message в недолговечной очереди не сохраняет очередь, а confirm не доказывает, что сообщение попало в нужную очередь без проверки возврата.
Применение
4. Выбери брокер для двух сценариев
Сценарий A: аналитика должна перечитывать историю с разных offset. Сценарий B: команды задач с per-message ack и сложной маршрутизацией по типу. Какой брокер естественнее и почему?
Ответ и разбор
Kafka естественна для A: сохраняемый лог читают независимые группы с разными offset. RabbitMQ естественен для B: exchange и bindings маршрутизируют сообщения в очереди, а delivery ack относится к отдельному сообщению. Оба могут быть настроены иначе, но эти модели напрямую соответствуют потребностям сценария.