---
title: "Topic: гибкая маршрутизация по шаблону"
description: "Topic exchange в amqp091-go: ключи со звёздочкой и решёткой, шаблоны привязок, дизайн routing key. Гибкая маршрутизация rabbitmq routing с примерами кода."
course: "RabbitMQ в Go: очереди сообщений на amqp091-go"
url: "https://golangmaster.ru/courses/rabbitmq/lessons/13-topic-exchange/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Topic: гибкая маршрутизация по шаблону

*Курс «RabbitMQ в Go: очереди сообщений на amqp091-go», урок 13 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/rabbitmq/lessons/13-topic-exchange/*

Иерархии ключей — order.created.eu, user.login.mobile — разруливает topic exchange: привязки те же, что в direct, но сравнение идёт с шаблоном из звёздочек и решёток. В этом уроке — гибкая маршрутизация rabbitmq routing на amqp091-go: синтаксис ключей, правила масок, словарь routing key между командами и цена гибкости. Разберём, почему маска order.* пропускает order.created.eu и когда topic оказывается прямым обменником с лишней нагрузкой.

Одиннадцатый урок оставил direct там, где меток немного и все известны. Первые недели хватает: email, push, sms. Потом продюсер хочет различать заказы и платежи, потом города, потом severity — и ключи размножаются: order.email, payment.email, order.email.moscow. Каждая новая грань данных требует новых привязок на стороне всех подписчиков, и рано или поздно кто-то забывает добавить ключ, а потребитель молча перестаёт получать часть потока. Topic exchange решает это сменой правила сравнения: привязка становится шаблоном, и одна маска покрывает целое семейство ключей. Потребитель подписывается на «все события заказов» одной привязкой order.#, продюсер продолжает уточнять ключ сколько угодно — маршрут переживает рост. Промышленные примеры вокруг: нотификации фильтруют user.*, мониторинг слушает *.error, мультирегиональные сервисы режут поток по суффиксу региона. В этом уроке разбираем синтаксис, звёздочки и решётки, ошибки масок и то место, где гибкость превращается в нечитаемую топологию.

### Topic exchange и ключ из слов

Ключ в topic exchange — это слова, разделённые точками: order.created, user.login.mobile, report.sales.eu.weekly. Брокер словарь не навязывает: это строки до 255 байт, точки — разделители, а согласованность слов — контракт между издателями и подписчиками. Хорошая практика: от общего к частному — домен, сущность, действие, уточнения: order.created.eu или order.cancelled.reason.timeout. Обменник объявляется третьим типом из четырёх: ch.ExchangeDeclare("events", "topic", true, false, false, false, nil) — durable, идемпотентность и PRECONDITION_FAILED при смене типа работают как у direct из одиннадцатого урока. Привязка связывает очередь с шаблоном: QueueBind("audit", "#", "events", false, nil). В management UI topic-обменник узнаётся по полю routing key в форме тестовой публикации: вбиваете реальный ключ вроде order.created и видите список очередей, куда ушла копия.

### Звезда и решётка: правила шаблонов

Шаблонов два, и в них путаются, пока не проговоришь правила словами. Звёздочка * заменяет ровно одно слово: шаблон order.* совпадёт с order.created и order.cancelled, но не с order.created.eu — на месте звёздочки стоит одно слово, а их два. Решётка # заменяет ноль, одно или несколько слов: order.# накрывает order.created, order.created.eu и даже order.cancelled.reason.timeout. Составные шаблоны комбинируют: *.created ловит order.created и user.created, но не report.created.eu. Крайние случаи полезно помнить: одиночный # — это fanout, он пропустит всё; шаблон без звёздочек и решёток — это direct, точное совпадение. То есть topic при желании эмулирует обоих соседей, и в маленьких проектах этим пользуются, чтобы не плодить обменники. Регистр, как и в direct, учитывается: Order.Created и order.created — разные ключи.

### Три подписки на один поток событий

```go
package main

import (
        "context"
        "log"
        "os"
        "os/signal"

        amqp "github.com/rabbitmq/amqp091-go"
)

type sub struct {
        queue string
        mask  string
}

func main() {
        conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
        if err != nil {
                log.Fatal(err)
        }
        defer conn.Close()

        ch, err := conn.Channel()
        if err != nil {
                log.Fatal(err)
        }

        err = ch.ExchangeDeclare("events", "topic", true, false, false, false, nil)
        if err != nil {
                log.Fatal(err)
        }

        subs := []sub{
                {queue: "audit.all", mask: "#"},
                {queue: "email.orders", mask: "order.*"},
                {queue: "analytics.users", mask: "user.#"},
        }
        for _, s := range subs {
                if _, err := ch.QueueDeclare(s.queue, true, false, false, false, nil); err != nil {
                        log.Fatal(err)
                }
                if err := ch.QueueBind(s.queue, s.mask, "events", false, nil); err != nil {
                        log.Fatal(err)
                }
        }

        ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
        defer stop()

        msgs, err := ch.ConsumeWithContext(ctx, "email.orders", "email-worker",
                false, false, false, false, nil)
        if err != nil {
                log.Fatal(err)
        }

        for d := range msgs {
                log.Printf("маршрут %s: %s", d.RoutingKey, d.Body)
                if err := d.Ack(false); err != nil {
                        log.Println("ack failed:", err)
                }
        }
}
```

Три подписки на один поток: аудит забирает всё через решётку, письма — события заказов первого уровня через order.*, аналитика — всё про пользователей через user.#. Delivery несёт RoutingKey — реальный ключ публикации, по нему удобно строить счётчики и логи. Публикация user.login.mobile уйдёт в audit.all и analytics.users и минует email.orders.

### Попадания шаблонов: привязка против ключа

| Привязка | order.created | order.created.eu | user.login |
| --- | --- | --- | --- |
| order.* | попадание | мимо: два слова на звёздочке | мимо |
| order.# | попадание | попадание | мимо |
| *.created | попадание | мимо | мимо |
| # | попадание | попадание | попадание |
| order.created | попадание | мимо: точное сравнение | мимо |

> **Маска уже, чем кажется: order.* против order.#.** Кейс: подписчик отчётов привязал order.*, ожидая все события заказов, а городская выгрузка слала ключи order.created.eu и order.paid.eu — отчёты собирали долю потока и молчали. Звёздочка замещает ровно одно слово, а не «что угодно после префикса»; для ветвящейся иерархии нужна решётка: order.#. Второй подводный камень — решётка в начале шаблона: привязки вида #.paid вынуждают брокер проверять ключи по-особенному и на больших объёмах дорожают; рекомендация документации — оставлять решётку в хвосте шаблона. Третья ловушка — пустые слова: ключ order..paid с двумя точками подряд не совпадёт с order.paid, а заметить двойную точку в логах труднее, чем опечатку. Проверяйте маски тестовыми публикациями с реальными ключами, а не только с идеальными.

### Rabbitmq routing: дизайн ключей и контракт команд

Гибкость rabbitmq routing перенесла сложность с топологии на словарь: команды договариваются о словах ключа, и без реестра не обойтись. Работающая схема: издатель владеет префиксом своего домена — заказы пишут order.*, платежи payment.*, — а подписчики выбирают маски, не спрашивая издателя. Ключ не должен нести данные, которые живут в теле: user.id.1042 создаёт бесконечное множество привязок, идентификатор едёт в payload из пятнадцатого урока. Точки-разделители образуют иерархию, и важно не ссыпать в ключ все грани сразу: user.login.mobile.eu говорит и о канале, и о регионе — при добавлении новой грани чужие маски могут молча сломаться, как в warn-врезке. Словарь меняют аддитивно: новое слово — в конец ключа, старые не переименовывают — вместо этого публикуется новый ключ, и маски переводят постепенно. Delivery.RoutingKey помогает контролю: по нему собирают счётчики потока и ловят незнакомые префиксы. Когда словарь растёт быстрее, чем команды успевают договориться, выручает реестр событий с примерами ключей: он же становится документацией для новичка и входом тестов на маски.

### Издатель уточняет ключ, привязки не меняются

```go
ctx := context.Background()
events := []string{
        "order.created",
        "order.created.eu",
        "user.login.mobile",
        "report.sales.weekly",
}
for _, key := range events {
        err := ch.PublishWithContext(ctx, "events", key, false, false,
                amqp.Publishing{
                        ContentType:  "text/plain",
                        Body:         []byte("payload of " + key),
                        DeliveryMode: amqp.Persistent,
                })
        if err != nil {
                log.Fatal(err)
        }
}
```

Издатель уточняет ключ свободно: грань региона появилась во второй строке, и привязки менять не пришлось — order.# уже её ловит, а order.* честно пропустит. Так и растёт поток событий: уточнение ключей не ломает старых подписчиков, если они выбирали маски, а не точные ключи.

### Topic или direct: цена гибкости

Тема не бесплатна: сопоставление ключа с шаблоном дороже точного сравнения, и брокер выполняет его для каждой привязки на каждое сообщение; на единицах очередей это незаметно, на сотнях активных масок с ветвистыми шаблонами — уже метрика. Direct выигрывает скоростью и читаемостью: множество ключей конечно и видно в UI целиком. Спорное наблюдение из ревью: гибкость topic провоцирует на маски ради масок — привязка вида *.paid.*.error читается хуже, чем честный список ключей direct, и разбор такой топологии превращается в разгадывание. Критерий простой: если потребители выбирают срезы потока с иерархией — topic оправдан; если набор меток стабилен и конечен — direct честнее. Поглощение соседей — ещё одна ловушка: topic умеет всё, но один обменник на все случаи жизни смешивает потоки с разной судьбой, а сменить тип у обменника потом нельзя; чаще выигрывают несколько обменников под разные классы трафика. Проверка простая: выпишите свои маски и подставьте три реальных ключа из продакшн-логов; если половина совпадений мимо или маски дублируют друг друга, топология просит упрощения.

### Проверка маршрутизации на живом брокере

Топологию topic проверяют руками быстрее, чем чтением кода. На странице обменника в management UI есть форма Publish message: вбейте routing key и тело, нажмите publish — и в списке привязок видно, куда ушла копия. Привязки каждой очереди перечислены на её странице в секции Bindings, а консольный rabbitmqctl list_bindings показывает таблицу «обменник — очередь — ключ» целиком: удобно для выгрузки и ревью. На больших схемах заводят подзорную очередь с привязкой # и читают её как дамп потока: видно всё, что публикуется, включая ключи, о которых подписчики не знали. Из той же практики — счётчик по Delivery.RoutingKey на потребителе: внезапно появившийся префикс означает, что издатель расширил словарь, и пора пересматривать маски, пока кто-то не потерял часть потока молча.

> **Код урока — локально, интерактивы — на странице.** Маршрутизация по шаблонам живёт в брокере, а песочнице доступны только fmt, strings, strconv, math, sort, errors и time — без сети, поэтому код урока запускается локально против Docker-контейнера из второго урока. Почувствовать правила звёздочки и решётки можно и в песочнице: разбейте ключ strings.Split по точкам и сравните количество слов с маской — этого хватает для тренировки. Интерактивы ниже проверят шаблоны, дедупликацию копий и порядок сборки топологии.

### Правила topic

- Ключ — слова через точки; словарь слов — контракт между издателем и подписчиками.
- Звёздочка заменяет ровно одно слово, решётка — ноль или больше; решётку оставляют в хвосте шаблона.
- Одиночный # ведёт себя как fanout, шаблон без масок — как direct.
- Издатель уточняет ключ свободно, подписчики выбирают маски; новые слова добавляют в конец.
- Ключ не дублирует payload: идентификаторы и суммы живут в теле сообщения.
- Гибкость стоит сопоставления на каждой доставке: без иерархии берите direct.

Противовесы этой гибкости: [direct из одиннадцатого урока](/courses/rabbitmq/lessons/11-obmenniki-direct/) с точным сравнением и [fanout из двенадцатого](/courses/rabbitmq/lessons/12-fanout-exchange/) с полной рассылкой — topic их поглощает, но не заменяет по читаемости. Ключи без тела — половина контракта: [пятнадцатый урок про JSON](/courses/rabbitmq/lessons/15-json-serializaciya/) расскажет о формате payload, а [шестнадцатый про dead letter](/courses/rabbitmq/lessons/16-obrabotka-oshibok/) — куда уходит то, что подписчики не осилили. Дизайн ключей перекликается с [потоками Redis](/courses/go-redis/lessons/15-potoki-streams/): там тоже журнал событий с явными именами потоков. Публикация с ключами осталась в [шестом уроке](/courses/rabbitmq/lessons/06-publikaciya-soobschenij/), мёртвые привязки и прочие антипаттерны — в [двадцать третьем](/courses/rabbitmq/lessons/23-antipatterny-ocheredey/); карта курса — на [странице курса](/courses/rabbitmq/).

### Проверка: звезда и решётка

**Вопрос:** Очередь привязана к topic-обменнику с шаблоном order.*. Какой ключ до неё дойдёт?

- order.created и order.created.eu: звёздочка покрывает хвост любой длины
- ✅ order.created: звёздочка замещает ровно одно слово, ключ из двух слов подходит
- Любой ключ, начинающийся на order: привязка работает как префикс
- Ни один: для звёздочки нужен ключ из трёх слов

Звёздочка замещает ровно одно слово: order.created совпадает, order.created.eu — нет; для ветвящегося хвоста нужна решётка order.#. Префиксного сравнения в topic нет — слова разделяются точками и сопоставляются позиционно.

### Проверка: крайние случаи

**Вопрос:** К topic-обменнику привязаны две очереди: одна с шаблоном #, вторая — с точным ключом user.login. Публикация с ключом user.login. Что произойдёт?

- Сообщение получит только очередь с шаблоном #: точные ключи в topic не работают
- Сообщение получит только очередь с ключом user.login
- ✅ Обе очереди получат по копии: решётка пропускает всё, точное совпадение тоже сработало
- Брокер вернёт ошибку: дублирование маршрутов запрещено

Одиночная решётка пропускает любой ключ — это fanout поверх topic, а шаблон без масок работает как direct. Оба правила совпали на одном ключе, и каждая очередь получила копию; в topic это штатная ситуация, а не конфликт.

### Сколько доставок получит очередь?

```go
// topic exchange "events"
// одна очередь reports с двумя привязками: "*.paid" и "order.#"
published := []string{
        "order.paid",
        "order.paid.eu",
        "cart.paid",
        "order.created",
}
fmt.Println("доставок:", countDeliveries(published))
```

**Что выведет программа?**

- ✅ 4: каждый ключ совпал хотя бы с одной привязкой, дублей на одну очередь нет
- 5: order.paid совпал с обеими привязками и пришёл дважды
- 3: order.created не подходит ни одной маске
- 2: очередь получает только точные совпадения

Хитрость в дедупликации на очередь: даже когда сообщение совпало с двумя привязками одной очереди, RabbitMQ доставит одну копию. order.paid, order.paid.eu и cart.paid закрыла маска *.paid или order.#, order.created закрыл order.# — четыре ключа, четыре доставки.

### Соберите привязки topic

Допишите объявление topic-обменника и две привязки подписчика.

```go
err := ch.___("events", "topic", true, false, false, false, nil)
if err != nil {
        log.Fatal(err)
}

err = ch.QueueBind("audit.all", ___, "events", false, nil)
if err != nil {
        log.Fatal(err)
}

err = ch.QueueBind("email.orders", "order.___", "events", false, nil)
```

Подсказка: Обменник объявляет ExchangeDeclare с типом topic, аудит слушает всё через одиночную решётку, а письма — события первого уровня через звёздочку после order.

Правильные ответы в пропусках: `ExchangeDeclare`, `#`, `*`

### Соберите издателя topic

Расставьте строки в порядке выполнения: соединение, канал, обменник, очередь, привязка с маской, публикация.

Правильный порядок строк:

1. `conn, err := amqp.Dial(url)`
2. `ch, err := conn.Channel()`
3. `err = ch.ExchangeDeclare("events", "topic", true, false, false, false, nil)`
4. `_, err = ch.QueueDeclare("email.orders", true, false, false, false, nil)`
5. `err = ch.QueueBind("email.orders", "order.*", "events", false, nil)`
6. `err = ch.PublishWithContext(ctx, "events", "order.created", false, false, msg)`

### Карточки: topic и маски

- **topic exchange** — Обменник с шаблонной маршрутизацией: ключ сравнивается с маской привязки по словам через точки.
- **звёздочка *** — Замещает ровно одно слово: order.* ловит order.created, но не order.created.eu.
- **решётка #** — Замещает ноль или больше слов: order.# накрывает весь префикс; одиночная # работает как fanout.
- **ключ события** — Слова через точки от общего к частному: order.paid.eu. Словарь — контракт между командами.
- **дедупликация на очередь** — Сообщение, совпавшее с несколькими привязками одной очереди, доставляется один раз.
- **ключ против payload** — Ключ описывает тип события и не несёт данных: идентификаторы и суммы едут в теле.

## Коротко: что запомнить

- Topic сравнивает ключ с шаблоном: звёздочка — одно слово, решётка — хвост любой длины.
- Одиночный # эквивалентен fanout, точный шаблон — direct: topic покрывает соседей.
- Словарь ключей — контракт: издатель владеет префиксом домена, подписчики выбирают маски.
- Новые грани добавляют словами в конец ключа; переименование ломает чужие маски молча.
- Сообщение с несколькими совпавшими привязками одной очереди приходит один раз.
- Маски проверяют тестовой публикацией в UI и счётчиком по Delivery.RoutingKey.
- Без иерархии берите direct: сопоставление шаблонов стоит на каждой доставке.

## Домашнее задание

Соберите topic-обменник events и три очереди с привязками #, order.* и user.#. Публикуйте ключи order.created, order.created.eu, user.login.mobile и report.sales.weekly и сверьте в management UI, сколько копий получила каждая очередь. Затем отправьте order.paid.eu и убедитесь, что order.* её пропустил, а после замены маски на order.# подписка догнала поток. В конце заведите подзорную очередь с # и найдите в ней ключ, о котором ваши подписчики не знали.

## Вопросы и ответы по уроку

### Почему привязка order.* не ловит order.created.eu?

Звёздочка замещает ровно одно слово: на её месте стоит created, а хвосту .eu маски некуда деться. Для ветвящейся иерархии берите решётку order.# — она покрывает и одно, и несколько слов после префикса. Проверить себя быстрее всего тестовой публикацией на странице обменника в management UI.

### Придёт ли сообщение дважды, если оно совпало с двумя привязками одной очереди?

Нет: дедупликация работает на пару «очередь и сообщение» — RabbitMQ доставит одну копию независимо от числа совпавших привязок. По копии получат две разные очереди с одинаковыми шаблонами; внутри одной очереди совпадения схлопываются.

### Стоит ли завести один topic-обменник на весь проект?

Часто да: один поток событий и общий словарь ключей проще держать в голове, а подписчики выбирают маски сами. Но тип обменника сменить нельзя, и смешивать всё подряд не обязательно: трафик с разной судьбой — задачи воркеров и события для аналитики — живёт лучше в отдельных обменниках со своими требованиями к durable и объёму.
