Routing key и шаблоны

Routing key — это публичный контракт вашей шины событий. Его проектируют один раз, а живут с ним годами.

Routing key — строка до 255 байт, которую продюсер прикладывает к сообщению. В topic-обменнике она делится точками на слова, и по этим словам брокер решает, каким очередям сообщение подходит.

Технически в routing key можно положить что угодно: abc, задача-7, пустую строку. Брокеру всё равно. Но ключ — это то, на что подписываются другие команды. Поменяли схему — сломали всех потребителей разом, причём тихо: они просто перестанут получать сообщения, без единого исключения в логах. Поэтому к именованию ключей стоит относиться как к версионированию публичного API.

Как устроено сравнение

В topic-обменнике ключ режется по точкам на слова. Слово — это любая последовательность символов между точками: буквы, цифры, дефисы, подчёркивания. Точка — единственный разделитель.

  • * в binding key заменяет ровно одно слово;
  • # заменяет ноль или больше слов;
  • всё остальное сравнивается побайтово, с учётом регистра: Orders.Created и orders.created — разные ключи.

Чтобы это перестало быть магией, давайте реализуем алгоритм сравнения на чистом Python и прогоним по нему набор случаев. Логика брокера ровно такая:

def match(pattern_words, key_words):
    if not pattern_words:
        return not key_words          # шаблон кончился — ключ тоже должен кончиться
    head = pattern_words[0]
    if head == "#":
        # '#' съедает 0, 1, 2 ... слов — пробуем все варианты
        for i in range(len(key_words) + 1):
            if match(pattern_words[1:], key_words[i:]):
                return True
        return False
    if not key_words:
        return False                  # слова кончились, а шаблон требует ещё
    if head == "*" or head == key_words[0]:
        return match(pattern_words[1:], key_words[1:])
    return False


def topic_match(pattern, key):
    return match(pattern.split("."), key.split("."))


cases = [
    ("logs.*.error", "logs.auth.error"),
    ("logs.*.error", "logs.error"),
    ("logs.*.error", "logs.auth.db.error"),
    ("logs.#", "logs.auth.db.error"),
    ("logs.#", "logs"),
    ("orders.*.created", "orders.eu.created"),
    ("#", "anything.at.all"),
]

for pattern, key in cases:
    print(f"{pattern} vs {key}: {topic_match(pattern, key)}")

Результат:

logs.*.error vs logs.auth.error: True
logs.*.error vs logs.error: False
logs.*.error vs logs.auth.db.error: False
logs.# vs logs.auth.db.error: True
logs.# vs logs: True
orders.*.created vs orders.eu.created: True
# vs anything.at.all: True

Две строки здесь стоят отдельного внимания. logs.*.error не поймал logs.error — звёздочка требует слово, а не разрешает его отсутствие. И logs.# поймал просто logs — решётка честно матчит ноль слов. Именно на этих двух случаях спотыкаются чаще всего.

Как строить осмысленные ключи

Работающая схема почти всегда выглядит как иерархия от общего к частному, слева направо. Слева — то, по чему будут фильтровать грубо (домен, сервис), справа — детали (тип события, статус).

СхемаПример ключаПолезные подписки
домен.сущность.событиеorders.order.createdorders.#, *.*.created
сервис.уровеньauth.error*.error, auth.#
домен.регион.событиеpayments.eu.refundedpayments.eu.#, *.*.refunded
сущность.действие.статусinvoice.send.failedinvoice.#, #.failed

Дальше — правила, которые экономят месяцы боли.

1. Фиксируйте количество сегментов

Если у вас orders.order.created (три слова), пусть все события домена будут трёхсловными: orders.order.paid, orders.shipment.sent. Тогда шаблон orders.*.created работает предсказуемо. Как только в одном месте появится orders.created, а в другом orders.eu.order.created — все существующие подписки начнут дырявить.

2. Не кладите в ключ идентификаторы

Соблазн написать order.4821.created велик, но это тупик: чтобы подписаться на конкретный заказ, придётся создавать binding на каждый ID — а их миллионы. Идентификаторы, суммы, e-mail — это тело сообщения. Ключ — только метаданные маршрутизации, то есть ответ на вопрос «кому это в принципе может быть интересно».

3. Ключ должен дублировать то, что уже есть в теле

В теле события пусть будет поле "event": "order.created", и оно же — в routing key. Потребитель, получив сообщение, не должен зависеть от того, по какому binding'у оно приехало: очередь может быть подписана на десяток шаблонов сразу.

4. Держите словарь событий

Обычный markdown-файл в репозитории, где перечислены все ключи с описанием. Звучит скучно, спасает буквально всех. Без него через год никто не сможет ответить, чем order.done отличается от order.completed — а в проде живут оба.

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

Topic-маршрутизация в RabbitMQ реализована деревом слов, а не перебором binding'ов и не регулярками. Брокер спускается по дереву, разбирая routing key слово за словом, и собирает множество подошедших очередей. Практические следствия:

  • Стоимость растёт от числа binding'ов, а не от числа сообщений. Тысяча binding'ов на обменнике — нормально, десятки тысяч — уже стоит померить.
  • # в середине шаблона дороже всего. logs.#.error валиден, но заставляет брокер перебирать варианты. Работает — да, но обычно это признак, что схема ключей спроектирована неудачно.
  • Лимит 255 байт. Именно байт, не символов: кириллица в ключе съедает по два байта на букву. Ещё одна причина писать ключи латиницей.
  • Сообщение доедет в очередь один раз, даже если подошло сразу трём binding'ам этой очереди.

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

  • Ждут, что logs.* поймает logs.app.error. Не поймает: звёздочка — это одно слово, а не «всё остальное». Нужен logs.#.
  • Публикуют с шаблоном в ключе. basic_publish(routing_key="orders.#") отправит сообщение с буквальным ключом orders.#, где решётка — обычный символ. Такое сообщение, скорее всего, не подойдёт ни одному binding'у и молча исчезнет. Шаблоны — только в queue_bind.
  • Идентификаторы в ключе. Плодят binding'и, ломают подписки, взрывают память брокера. ID — в теле.
  • Переименовали ключ «просто для порядка». Это ломающее изменение контракта, и падает оно не у вас, а у трёх соседних команд. Мигрируют так: продюсер какое-то время публикует событие под старым и новым ключом, потребители переезжают, старый ключ выпиливают.
  • Разный регистр и разделители. Order.Created, order_created и order.created — три разных мира. Договоритесь на lower-case + точки и вбейте это в линтер или в общую библиотеку публикации.
  • Подписались на # и фильтруют в коде. Тогда вы платите за трафик, за память очереди и за CPU потребителя ради работы, которую брокер сделал бы бесплатно. Если фильтрация в консюмере — норма, значит, ключи спроектированы не под ваши подписки.

Итоги

  • Routing key — контракт между командами; проектируйте его как публичный API.
  • Иерархия от общего к частному, фиксированное число сегментов, lower-case и точки.
  • * — ровно одно слово, # — ноль или больше. logs.* и logs.# — совсем не одно и то же.
  • Шаблоны допустимы только в binding key; при публикации ключ всегда конкретный.
  • Идентификаторы и полезные данные — в теле сообщения, не в ключе.
  • Лимит — 255 байт; ключи пишем латиницей.
  • Ведите словарь событий: он дешевле, чем археология по логам через год.
Проверьте себя
1. Какой binding key поймает и logs.auth.error, и logs.auth.db.slow, и просто logs?
Alogs.*
Blogs.#
Clogs.*.*
D#.logs
2. Продюсер вызывает basic_publish(exchange="app.topic", routing_key="orders.#"). Что произойдёт?
AСообщение уйдёт во все очереди, чьи binding key начинаются с orders
BБрокер вернёт ошибку: шаблоны в routing key запрещены
CСообщение уйдёт с буквальным ключом orders.#, скорее всего не подойдёт ни одному binding'у и будет отброшено
DRabbitMQ заменит # на пустую строку и доставит сообщение по ключу orders