Exchange, очередь и binding

Разбираемся, почему продюсер в RabbitMQ физически не может положить сообщение в очередь — и почему это не недоработка, а главная идея брокера.

Exchange (обменник) — точка входа сообщений в брокер. Сам он ничего не хранит: его работа — сверить routing key сообщения со связками (binding) и положить копию сообщения в каждую подошедшую очередь.

Если вы приходите в RabbitMQ из мира Redis-списков, то первый рефлекс — искать метод «положить в очередь orders». Такого метода нет. В API есть basic_publish(exchange=..., routing_key=...) — и слова «queue» в нём нет вообще. Продюсер отдаёт сообщение обменнику и говорит: «вот ключ, разбирайся сам». Куда оно поедет — решает конфигурация брокера, а не код отправителя.

Зачем такая прослойка

Представьте сервис оплаты. Он публикует событие «платёж прошёл». Сегодня это событие нужно ровно одному потребителю — сервису писем, чтобы отправить чек. Через месяц приходит аналитика: «нам тоже нужны платежи». Ещё через месяц — склад: «по оплаченным заказам надо резервировать товар».

Если бы продюсер писал прямо в очередь, каждый новый потребитель означал бы правку и деплой сервиса оплаты. С обменником не так: вы создаёте новую очередь, привязываете её к тому же exchange — и она немедленно начинает получать копии тех же сообщений. Код продюсера не тронут. Он вообще не знает, сколько у него читателей: ноль, один или семь.

Отсюда практическое правило: продюсер знает про exchange, консюмер знает про свою очередь, и друг про друга они не знают ничего. Это и есть развязка, ради которой брокер ставят.

Три сущности и одна связка

СущностьЧто делаетХранит сообщения?
exchangeпринимает публикации и маршрутизирует ихнет, никогда
queueкопит сообщения до прихода потребителяда, это единственное хранилище
bindingправило «exchange → queue при таком ключе»нет, это просто правило

Binding — самая недооценённая штука в этом списке. Формально это тройка: имя обменника, имя очереди и binding key — образец, с которым сравнивается routing key сообщения. Одна очередь может иметь сколько угодно binding'ов к одному и тому же exchange, и это нормальный приём: «хочу и payment.succeeded, и payment.refunded».

Полный пример: продюсер

import pika

connection = pika.BlockingConnection(pika.ConnectionParameters("localhost"))
channel = connection.channel()

# 1. Обменник — точка входа сообщений
channel.exchange_declare(exchange="payments", exchange_type="direct", durable=True)

# 2. Очередь — место, где сообщения ждут потребителя
channel.queue_declare(queue="email.payments", durable=True)

# 3. Связка: сообщения с ключом payment.succeeded попадут в эту очередь
channel.queue_bind(
    exchange="payments",
    queue="email.payments",
    routing_key="payment.succeeded",
)

# 4. Публикуем — в exchange, а не в очередь!
channel.basic_publish(
    exchange="payments",
    routing_key="payment.succeeded",
    body=b'{"order_id": 4821, "amount": 1990}',
    properties=pika.BasicProperties(
        delivery_mode=2,               # сообщение переживёт рестарт брокера
        content_type="application/json",
    ),
)

print("Отправлено")
connection.close()

Построчный разбор

  • exchange_declare — создаёт обменник, если его нет. Операция идемпотентна: повторный вызов с теми же параметрами ничего не сломает. Тип direct означает «routing key должен точно совпасть с binding key».
  • queue_declare — то же самое для очереди. durable=True говорит: описание очереди сохранить на диск, чтобы после перезапуска брокера она не исчезла.
  • queue_bind — та самая связка. Без неё сообщение доедет до обменника и тихо исчезнет: класть его будет некуда.
  • basic_publish — обратите внимание, что routing_key здесь конкретный (payment.succeeded), а не шаблон. Шаблоны бывают только в binding'ах.
  • delivery_mode=2 — пометить сообщение как persistent. Про это ниже, это классическая ловушка.

Полный пример: консюмер

import pika

connection = pika.BlockingConnection(pika.ConnectionParameters("localhost"))
channel = connection.channel()

# Консюмер объявляет ровно ту же топологию — на всякий случай
channel.exchange_declare(exchange="payments", exchange_type="direct", durable=True)
channel.queue_declare(queue="email.payments", durable=True)
channel.queue_bind(
    exchange="payments",
    queue="email.payments",
    routing_key="payment.succeeded",
)

# Не давать нам новое сообщение, пока не подтвердили предыдущее
channel.basic_qos(prefetch_count=1)

def on_message(ch, method, properties, body):
    print("Отправляю чек:", body.decode())
    ch.basic_ack(delivery_tag=method.delivery_tag)

channel.basic_consume(queue="email.payments", on_message_callback=on_message)

print("Жду сообщения. Ctrl+C для выхода.")
channel.start_consuming()

Почему консюмер повторяет exchange_declare и queue_bind? Потому что порядок запуска сервисов в реальной жизни непредсказуем. Если каждая сторона объявляет топологию, которая ей нужна, система собирается сама, кто бы ни стартовал первым. Приём называется «декларативная топология», и он на порядок надёжнее, чем «настроим руками в админке и будем надеяться».

Как это работает

Путь сообщения выглядит так:

producer --publish(exchange, routing_key)--> [exchange]
                                                |
                              сверка routing_key с binding key
                                                |
                     +--------------------------+--------------------------+
                     v                          v                          v
              [queue email]              [queue analytics]           (нет совпадений)
                     |                          |                          |
                consumer 1                 consumer 2               сообщение отброшено

Ключевые следствия:

  • Обменник не буферизует. Маршрутизация происходит синхронно, в момент публикации. Если подходящих очередей нет — сообщение просто исчезает. Никакой ошибки продюсер по умолчанию не получит.
  • Копия на каждую очередь, но одна. Если сообщение подошло трём очередям, каждая получит свою копию. Если одна очередь подошла по двум разным binding'ам — она всё равно получит ровно одну копию, брокер не дублирует.
  • Пустое имя обменника — это не «без обменника». exchange="" — это default exchange, встроенный direct-обменник, к которому каждая очередь автоматически привязана по своему имени. Именно поэтому в туториалах «hello world» работает basic_publish(exchange="", routing_key="hello") — routing key совпадает с именем очереди. Удобно для примеров, вредно для архитектуры: продюсер снова знает имена очередей.
  • Долговечность собирается из трёх кубиков. Пережить рестарт брокера сообщение сможет, только если durable=True у очереди и delivery_mode=2 у самого сообщения. Забыли одно — потеряли всё, что лежало в очереди.

Частые ошибки

  • Опубликовали раньше, чем создали очередь. Классика: продюсер стартовал первым, отправил тысячу событий, потом поднялся консюмер и создал очередь — пусто. Все сообщения были unroutable и отброшены. Лечится декларацией топологии на обеих сторонах или отдельным шагом миграции топологии при деплое.
  • Забыли queue_bind. Очередь есть, обменник есть, сообщения уходят — а очередь пустая. Проверяйте вкладку Bindings в management-плагине: пустой список binding'ов означает, что очередь никогда ничего не получит (кроме публикаций через default exchange).
  • Ждут ошибку при неудачной маршрутизации. По умолчанию её нет. Хотите знать, что сообщение никуда не доехало — публикуйте с mandatory=True и обрабатывайте возврат, либо настройте обменнику alternate-exchange, куда стекается всё «бездомное».
  • Пересоздали очередь с другими параметрами. queue_declare идемпотентен только при полном совпадении аргументов. Изменили durable или добавили TTL — брокер ответит 406 PRECONDITION_FAILED и закроет канал. Существующую очередь нельзя «доредактировать»: только удалить и создать заново.
  • Считают, что из обменника можно читать. Нельзя. Нет сообщений в обменнике, нет метода «посмотреть, что там». Всё, что вы видите в UI на вкладке Exchanges, — это счётчики и binding'и.
  • Случайные exclusive=True и auto_delete=True. Первый привязывает очередь к соединению (упал клиент — исчезла очередь), второй удаляет очередь, когда отписался последний потребитель. Для рабочих очередей это почти всегда не то, что вам нужно.

Итоги

  • Продюсер публикует в exchange с routing key; про очереди он не знает вообще.
  • Exchange ничего не хранит — он маршрутизирует здесь и сейчас; хранит только очередь.
  • Binding = правило «exchange → queue при таком ключе». Нет binding'а — нет доставки.
  • Сообщение без подходящей очереди отбрасывается молча. Нужен контроль — mandatory или alternate-exchange.
  • Топологию объявляют обе стороны — тогда порядок запуска сервисов перестаёт быть проблемой.
  • Долговечность = durable-очередь + delivery_mode=2. Половина не работает.
Проверьте себя
1. Что произойдёт с сообщением, если в exchange нет ни одного binding'а, подходящего под его routing key?
AБрокер вернёт продюсеру ошибку публикации
BСообщение останется лежать в exchange до появления подходящей очереди
CСообщение будет молча отброшено (если не задан mandatory или alternate-exchange)
DСообщение попадёт в очередь по умолчанию
2. Почему пример basic_publish(exchange="", routing_key="hello") доставляет сообщение в очередь hello?
AПустая строка означает «отправить напрямую в очередь, минуя exchange»
BЭто default exchange — direct-обменник, к которому каждая очередь привязана по своему имени
CRabbitMQ автоматически создаёт fanout-обменник и рассылает всем очередям
DТак работает только с durable-очередями