voidlearn
 

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, куда стекается вся «непонятная почта».

Один и тот же publish, четыре типа exchange - куда попадёт сообщение Зелёная стрелка - привязка совпала, копия сообщения кладётся в очередь. Красный крест - не совпала, очередь не получает ничего. direct - binding key должен совпасть с routing key буквально, посимвольно routing key payment direct binding: payment q.pay.main binding: payment q.pay.audit - тоже копия binding: refund q.refunds - ничего не получит topic - ключ режется точками на слова; * = ровно одно слово, # = ноль или больше routing key order.created.eu topic binding: order.*.eu q.eu.orders binding: order.# q.all.orders binding: *.created q.created - два слова против трёх fanout - routing key не смотрится вообще, копия уходит во все привязанные очереди routing key любой fanout binding: ключ игнорируется q.cache.invalidate binding: ключ игнорируется q.search.index binding: ключ игнорируется q.audit.log headers - вместо ключа смотрят заголовки; x-match = all (все) или any (хотя бы один) headers: type=pdf, lang=ru headers all: type=pdf + lang=ru q.render.ru any: type=pdf q.pdf.any all: type=pdf + lang=en q.render.en - lang не тот
Четыре типа exchange на одном примере. Exchange берёт routing key (или заголовки) и по таблице привязок решает, в какие очереди положить копии. Одному консьюмеру две копии не придут: копия кладётся в очередь, и уже очередь отдаёт её одному из своих подписчиков.

Механика routing key на примерах

Чаще всех работает topic: почти в любом проекте хватает одного topic-обменника на домен. Ключ состоит из слов через точку, это иерархия длиной до 255 байт. Спецсимволов всего два: * заменяет ровно одно слово, # - ноль или больше слов.

Binding keyorder.createdorder.created.euorder.paid.eu.b2buser.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-соединения.

Где теряет сообщения автоack (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 не ограничен, и хуже дефолта не придумать: первый подключившийся консьюмер выгребает всю очередь себе в память, остальные простаивают.

basic.qos не вызван - prefetch не ограничен брокер отдаёт всё, что есть, первому же готовому каналу очередь пусто всё уже роздано консьюмер A 10 unacked в памяти консьюмер B простаивает 0 работы Падение A вернёт в очередь все 10 сразу; второй под куплен зря, а память A растёт с размером очереди. basic.qos(prefetchCount = 2) брокер держит не больше 2 неподтверждённых на консьюмера очередь 6 ждут своей очереди консьюмер A 2 в работе консьюмер B 2 в работе Работа делится поровну, память консьюмера ограничена, падение возвращает в очередь максимум 2 сообщения.
Нагрузку на консьюмера в RabbitMQ регулирует только prefetch. Без него распределение «честное» лишь формально: брокер раздаёт по кругу, но мгновенно, и вся очередь оседает в памяти первого подключившегося.

Как подобрать значение? Каждое подтверждение стоит сетевого round-trip, поэтому prefetch=1 даёт идеально ровное распределение и низкий throughput: консьюмер простаивает, пока ack идёт туда, а следующее сообщение обратно. Ориентиры такие:

Профиль задачиprefetchПочему
Долгая обработка (секунды и минуты): конвертация видео, отчёт, вызов внешнего API1Round-trip в единицы миллисекунд на фоне секунд не виден, зато задача не «залипнет» за спиной другой
Быстрая обработка (единицы миллисекунд), много мелких сообщений100–300Иначе сеть станет узким местом раньше, чем CPU
Пул из N воркеров внутри одного процессаN или 2NДержать все воркеры занятыми и один-два в очереди на вход, не больше
Неизвестно10–50Безопасный старт: и память ограничена, и сеть не пилит round-trip на каждое
Head-of-line blocking при большом prefetch

Память тут не единственная цена. Сообщения из буфера консьюмера уже не будут перераспределены: их не увидит новый под, который ты только что поднял под пик. При 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Что произошлоТипичная причина в коде
rejectedbasic.reject или basic.nack с requeue=falseКонсьюмер решил, что ретрай не поможет
expiredИстёк TTL сообщения: свой (expiration) или общий для всей очереди (x-message-ttl)Задержка в retry-очереди или протухшие данные
maxlenОчередь упёрлась в x-max-length / x-max-length-bytesПерегрузка; при overflow=drop-head вытесняется самое старое
delivery_limitQuorum-очередь: превышен x-delivery-limit перед доставкойPoison message, брокер сам отправляет его в DLX

При каждом прохождении брокер дописывает запись в заголовок x-death. Там лежит массив структур с полями queue, reason, count, exchange, routing-keys, time. Другого штатного счётчика попыток в AMQP нет: x-death[0].count честно говорит, сколько раз сообщение уже возвращалось через dead-lettering.

Три способа потерять сообщение на DLX
  • 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-лестница: nack(requeue=false) → wait-очередь с TTL → DLX → обратно в работу Ожидание создаётся тем, что у wait-очереди нет консьюмеров: единственный способ покинуть её - истечь по TTL. x.orders topic exchange q.orders рабочая очередь консьюмер ack - сообщение исчезло nack(requeue=false), x-death.count = 0 q.retry.5s ttl 5s · нет консьюмеров истёк TTL reason = expired dlx.retry rk = orders.retry возврат в x.orders, попытка №2 count >= 3 → сразу в DLQ q.retry.30s ttl 30s · попытка №3 q.orders.dlq разбор руками, алерт Число ступеней = числу попыток: 5s → 30s → 5m. Экспоненту делают отдельными очередями, потому что per-message TTL в classic-очереди работает только для головы очереди - сообщение с TTL 5s не обгонит стоящее перед ним с TTL 5m.
Задержку в RabbitMQ покупают через TTL, таймера здесь нет. Ступени делают отдельными очередями, потому что per-message TTL очередь не переупорядочивает: сообщения истекают строго по порядку, а не по величине своего TTL.
На head-of-line у TTL новички спотыкаются чаще всего

Хочется сделать «одну retry-очередь и класть в каждое сообщение свой expiration». Получится система, где сообщение с задержкой 1 секунда, попавшее за сообщение с задержкой 10 минут, выйдет через 10 минут. Classic-очередь проверяет TTL только у головы: она FIFO, и «протухшее в середине» физически не может выйти раньше. Решений два: лестница из очередей с фиксированным TTL (по одной на ступень) или плагин rabbitmq_delayed_message_exchange, который держит отложенные сообщения в Mnesia и публикует по таймеру.

Durable, persistent, confirms: четыре независимых условия

«Сообщение переживёт рестарт брокера» требует не одной галочки, а конъюнкции четырёх условий, и каждое отвечает за свой кусок пути. Провалить достаточно одно.

Что настраиваемГдеЧто будет, если забыть
durable=true у exchangeExchangeDeclareПосле рестарта обменника нет; публикация в него закрывает канал с NOT_FOUND
durable=true у очередиQueueDeclareОпределение очереди не восстановится; очередь исчезает вместе со всем содержимым
DeliveryMode = 2 (persistent)у каждого сообщенияТело живёт только в памяти. Очередь durable - но пустая после рестарта
Publisher confirmsch.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 {
    // брокер не принял: публикуем заново, дубликаты тут норма
}
Что именно означает confirm

Для 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: параллелизм = число партиций, порядок по ключу сохраняется p0 p1 p2 consumer 1 consumer 2 consumer 3 consumer 4 Четвёртый не получит ничего: партиций три. Чтобы вырасти - увеличить партиции, а это меняет hash(key) % N и ломает порядок по ключу на границе изменения. Уменьшить нельзя вообще. Зато порядок внутри ключа гарантирован всегда. RabbitMQ: параллелизм не ограничен, порядок теряется сразу q.tasks одна очередь worker 1 worker 2 worker 3 worker 40 Сорок воркеров - просто сорок подов, ничего не надо перенастраивать: брокер раздаёт по кругу с оглядкой на prefetch. Но сообщения обрабатываются параллельно, поэтому сквозного порядка нет уже при двух консьюмерах.
Kafka покупает порядок ценой потолка параллелизма; RabbitMQ покупает неограниченный параллелизм ценой порядка. На этой развилке и делают выбор, остальное из неё следует.
СвойствоKafkaRabbitMQ
МодельРаспределённый лог с офсетами, читатель хранит позициюОчереди + маршрутизатор, сообщение исчезает после 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
Суть: продюсер публикует не в очередь, а в exchange с routing key. Exchange ничего не хранит - это чистая функция «ключ + заголовки → список очередей», а правило подписки называется binding. Отсюда и гибкость RabbitMQ, и его главная ловушка: не совпало ни с одной привязкой - сообщение исчезло молча.

Пять сущностей

  • Connection - TCP+TLS-соединение до брокера, дорогое, открывается один раз на процесс.
  • Channel - логический подканал внутри соединения. Все команды идут через него. Не потокобезопасен: в Go заводят по каналу на горутину. Ошибка протокола закрывает канал, и приложение должно уметь его пересоздать.
  • Exchange - маршрутизатор без состояния. Четыре типа плюс безымянный default exchange (""), к которому каждая очередь автоматически привязана по своему имени. Поэтому «hello world» с publish("", "my-queue", body) и работает без объявления обменника.
  • Queue - единственное место, где сообщение реально лежит. FIFO, у classic-типа живёт на одном узле кластера.
  • Binding связывает exchange → queue через binding key (или набор заголовков). Между одной парой может быть несколько привязок с разными ключами.

Типы exchange

ТипПравилоКогда берут
directbinding 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.

Суть: четыре механизма, которые вместе образуют жизненный цикл сообщения у консьюмера. Ack удаляет, nack решает судьбу, prefetch ограничивает, сколько можно держать в руках, DLX ловит всё, что выбыло не по-хорошему. Главные ошибки: 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: упавший консьюмер ничего не теряет, но всё повторит.
autoAck=true теряет сообщения тихо

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 с задержкой.

Суть: это разные флаги на разных уровнях, и нужны все четыре: durable exchange, durable queue, 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.

Суть: не «что быстрее», а разные модели хранения. Kafka - упорядоченный лог, из которого ничего не удаляется по факту чтения; читатель хранит свою позицию. RabbitMQ - очереди, из которых сообщение исчезает после ack, плюс мощный маршрутизатор перед ними. Одной фразой: лента фактов против раздачи задач.

Что следует из модели

  • 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. Но модель хранения у них по-прежнему разная, и выбирать надо по ней».

Суть: Core NATS - сверхлёгкая шина без хранения (at-most-once, нет подписчика - сообщение исчезло), с subject-маршрутизацией, queue groups и встроенным request/reply. JetStream - слой персистентности поверх, который умеет вести себя и как лог (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 и не хочет второй брокер.

Если опыта с NATS нет

Так и скажи, но всё равно назови разделение «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 относится к отдельному сообщению. Оба могут быть настроены иначе, но эти модели напрямую соответствуют потребностям сценария.