---
title: "Headers и альтернативный exchange"
description: "Headers exchange в amqp091-go: маршрутизация по заголовкам, x-match any и all. Alternate exchange для промахов: ловушка недоставленных сообщений с примерами."
course: "RabbitMQ в Go: очереди сообщений на amqp091-go"
url: "https://golangmaster.ru/courses/rabbitmq/lessons/14-headers-alternativy/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

# Headers и альтернативный exchange

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

Когда маршрутизация должна смотреть на атрибуты сообщения, а не на строку ключа, приходит headers exchange: сравнение идёт по заголовкам с режимами any и all. Вторая половина урока — про alternate exchange: ловушку для промахов, которая собирает всё, что не нашло очередь. Разберём x-match, таблицы аргументов, связку с mandatory из шестого урока и почему редкий тип обменника всё равно стоит знать.

Direct и topic маршрутизируют строкой ключа, и в большинстве случаев этого хватает. Оставшийся случай выглядит так: маршрут зависит от сочетания атрибутов — формат документа, его тип, версия схемы, приоритет, — и часть атрибутов опциональна. Вшивать это в ключ придётся перечислением комбинаций: report.pdf.v2, report.docx.v2, invoice.pdf.v2 — и с каждым новым атрибутом список растёт умножением. Headers exchange решает задачу иначе: сообщение публикуется с заголовками — картой пар ключ-значение, — а привязка описывает, какие значения она ждёт. Это последний из четырёх типов обменников курса, и он честно редкий; но когда встречается, без него топология превращается в эпос. Встречается он в системах обмена документами, приёмочных шлюзах с версиями схем и в шинах, где правила маршрутизации описывает конфигурация, а не код. Вторая половина урока — про alternate exchange: не тип, а настройка обменника, которая закрывает главный кошмар одиннадцатого урока — сообщения, исчезающие без единой ошибки.

### Headers exchange: маршрутизация по заголовкам

Headers exchange не читает routing key вовсе: он сравнивает заголовки публикации с аргументами привязки. Издатель кладёт атрибуты в поле Headers типа amqp.Table: amqp.Publishing{Headers: amqp.Table{"format": "pdf", "type": "report"}}. Привязка передаёт свои условия аргументами QueueBind — таблицей с обязательным ключом x-match: amqp.Table{"x-match": "all", "format": "pdf", "type": "report"}. Режим x-match решает, считать ли совпадением все заголовки или хотя бы один; это единственный спецключ в таблице, остальные пары — ваши условия. Значения сравниваются с учётом типа: строка "2" и число 2 — разные вещи, и это ловит при миграциях форматов. Обменник объявляется знакомо: ch.ExchangeDeclare("documents", "headers", true, false, false, false, nil) — durable, идемпотентность и PRECONDITION_FAILED при смене типа работают как у direct из одиннадцатого урока, а в management UI тестовая публикация заполняет заголовки таблицей в той же форме.

### x-match any и all: привязка по условиям

Логика x-match — два правила, и их полезно проговорить на примерах. Режим all требует совпадения всех заголовков из привязки: условие x-match all с format pdf и type report пропустит только сообщения, у которых совпали оба; лишние заголовки публикации роли не играют — важен лишь набор, перечисленный в привязке. Режим any требует совпадения хотя бы одного: та же привязка в режиме any возьмёт и «pdf», и «report» по отдельности. Any с единственным заголовком превращается в фильтр по одному признаку — так делают избирательные подписки без topic-словаря. Масок у заголовков нет: совпадение точное, без звёздочек и префиксов; если нужна иерархия значений — это работа для topic из тринадцатого урока. Комбинации получаются выразительными: несколько привязок на одну очередь с разными условиями собирают сложные выборки, а издатель просто публикует заголовки и не знает про фильтры ничего.

### Привязка по заголовкам и публикация

```go
package main

import (
        "context"
        "log"

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

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("documents", "headers", true, false, false, false, nil)
        if err != nil {
                log.Fatal(err)
        }

        if _, err := ch.QueueDeclare("pdf.reports", true, false, false, false, nil); err != nil {
                log.Fatal(err)
        }
        err = ch.QueueBind("pdf.reports", "", "documents", false, amqp.Table{
                "x-match": "all",
                "format":  "pdf",
                "type":    "report",
        })
        if err != nil {
                log.Fatal(err)
        }

        ctx := context.Background()
        err = ch.PublishWithContext(ctx, "documents", "", false, false,
                amqp.Publishing{
                        ContentType: "application/json",
                        Headers: amqp.Table{
                                "format": "pdf",
                                "type":   "report",
                                "source": "crm",
                        },
                        Body: []byte("{}"),
                })
        if err != nil {
                log.Fatal(err)
        }
        log.Println("опубликовано с заголовками format=pdf, type=report")
}
```

Привязка требует оба заголовка: x-match all, format pdf и type report. У публикации три заголовка — лишний source роли не играет, сравнивается только перечисленный в привязке набор. Routing key пуст и не участвует; в management UI тестовую публикацию с заголовками делают в той же форме, где у topic вводили ключ.

### x-match на примерах

| Привязка | Заголовки публикации | Итог |
| --- | --- | --- |
| all: format=pdf, type=report | format=pdf, type=report | попадание |
| all: format=pdf, type=report | format=pdf, type=report, source=crm | попадание: лишние заголовки не мешают |
| all: format=pdf, type=report | format=pdf | мимо: type отсутствует |
| any: format=pdf, type=report | format=docx, type=report | попадание: совпал один из двух |
| any: format=pdf, type=report | format=docx, type=invoice | мимо: ни одного совпадения |

> **Опечатка в заголовке привязки: очередь молчит.** Кейс: очередь отчётов не получала ничего, хотя публикации шли. В привязке стояло Format с большой буквы, а издатель слал format — ключи заголовков сравниваются с учётом регистра, и совпадение было нулевым. Диагноз виден на странице очереди: привязка есть, сообщений нет; лечится сверкой имён заголовков между издателем и привязкой — и константой в общем пакете, а не строками в двух местах. Вторая цена headers — размер: заголовки едут в каждом сообщении, и таблица из десятка пар прибавляет к доставке сотни байт; класть в заголовки данные, нужные только читателю тела, — типичное злоупотребление. Третий нюанс: типы значений сравниваются честно, и после смены в издателе числового format на строковый привязки со старым типом перестают совпадать — без ошибок, просто мимо.

### Alternate exchange: ловушка для промахов

Alternate exchange — настройка основного обменника: всё, что не нашло очередь, брокер переправляет в запасной. Задаётся аргументом при объявлении: ch.ExchangeDeclare("orders", "direct", true, false, false, false, amqp.Table{"alternate-exchange": "orders.unrouted"}) — и сам запасной объявляется раньше, иначе объявление основного упадёт со ссылкой на несуществующую сущность. Дальше механика простая: публикация с ключом, к которому нет привязок, уходит в orders.unrouted; там обычно стоит fanout и одна очередь-ловушка. Это серверное решение проблемы из warn-врезки одиннадцатого урока: не каждый издатель ловит возвраты через mandatory, а брокер централизованно складывает промахи в место, которое вы контролируете. Правила строгие: сообщение, переправленное в запасной и снова не нашедшее очередь, уничтожается — каскада из alternate в alternate не будет; и NotifyReturn не сработает, если переправка состоялась: брокер считает доставку удавшейся.

### Основной обменник с запасным

```go
err = ch.ExchangeDeclare("orders.unrouted", "fanout", true, false, false, false, nil)
if err != nil {
        log.Fatal(err)
}

_, err = ch.QueueDeclare("orders.unrouted.log", true, false, false, false, nil)
if err != nil {
        log.Fatal(err)
}
err = ch.QueueBind("orders.unrouted.log", "", "orders.unrouted", false, nil)
if err != nil {
        log.Fatal(err)
}

err = ch.ExchangeDeclare("orders", "direct", true, false, false, false,
        amqp.Table{"alternate-exchange": "orders.unrouted"})
if err != nil {
        log.Fatal(err)
}

ctx := context.Background()
err = ch.PublishWithContext(ctx, "orders", "email.typo", false, false,
        amqp.Publishing{Body: []byte("проверка маршрута")})
if err != nil {
        log.Fatal(err)
}
```

Порядок важен: запасной обменник и его очередь объявляются до основного, потому что аргумент alternate-exchange ссылается на существующую сущность. Сообщение с ключом email.typo не найдёт привязок в orders и окажется в orders.unrouted.log — с телом и заголовками, готовое к разбору.

### Alternate exchange в бою: мониторинг промахов

Ловушка без наблюдения бесполезна: очередь промахов должна звучать. Минимум — глубина и возраст в дашборде, как у любой очереди из двадцать второго урока; правильнее — алерт на каждое сообщение: в здоровой топологии промахов не бывает, и каждая поимка — инцидент с телом, которое видно в management UI и можно переопубликовать после починки. Связка с обязательными возвратами из шестого урока распределяется по уровням: mandatory с NotifyReturn возвращает недоставленное конкретному издателю — вариант там, где публикация и обработка возврата в одних руках; alternate exchange работает централизованно и не требует дисциплины от каждого автора публикаций. DLX из шестнадцатого урока решает зеркальную задачу — куда уходит сообщение, которое потребитель не осилил, — и механически похож: аргумент при объявлении плюс обменник-приёмник. Разница в направлении: alternate ловит недоставленное до потребителя, dead letter — после. И то и другое настраивают аргументами при объявлении — отсюда и путаница: x-dead-letter-exchange живёт на очереди, alternate-exchange — на обменнике.

### Headers или ключ: честный выбор

Headers exchange — редкий гость в продакшн-схемах, и это надо признать прямо: команды выбирают direct, topic и fanout, а заголовками маршрутизируют единицы. Причины понятны: ключ виден в логах и метриках, выглядит в management UI и на ревью, а заголовки прячутся внутри сообщения; словарь ключей читается, таблица условий — нет. Но есть ситуации, где headers выигрывает честно: атрибутов несколько, часть опциональна, их множество комбинаторно растёт; правила маршрутизации меняются чаще кода; нужна маршрутизация без вынесения служебных данных в ключ, например по версиям форматов. Рабочий критерий: если маршрут описывается одним словом или маской — берите ключ; если требует связки трёх-четырёх флагов с опциями — заголовки перестают быть экзотикой. Alternate exchange — другая история: он не выбирается вместо заголовков, а ставится аргументом на любой обменник любого типа, и в боевых схемах на него стоит смотреть как на обязательную страховку.

> **Код урока — локально, интерактивы — на странице.** Заголовки и переправка живут в брокере, а песочнице доступны только fmt, strings, strconv, math, sort, errors и time — без сети: код урока запускается локально против Docker-контейнера из второго урока. Проверьте себя так: опубликуйте сообщение с ключом, к которому нет привязок, и найдите его в очереди-ловушке; затем уберите alternate-exchange из аргументов и сравните — сообщение исчезнет молча. Интерактивы ниже проверят режимы x-match и порядок объявления топологии.

### Правила headers и alternate exchange

- Headers exchange не читает routing key: сравниваются заголовки публикации и условия привязки.
- x-match all требует всех совпадений, any — хотя бы одного; масок у заголовков нет.
- Ключи заголовков чувствительны к регистру, значения сравниваются с учётом типа.
- Alternate exchange переправляет недоставленное в запасной обменник; второго прыжка не будет — промах уничтожается.
- Запасной обменник объявляют до основного: аргумент ссылается на существующую сущность.
- Очередь-ловушка без алерта бесполезна: каждый промах — инцидент, а не архив.

Связи урока: [одиннадцатый урок](/courses/rabbitmq/lessons/11-obmenniki-direct/) поставил проблему немаршрутизируемых сообщений, [шестой про mandatory и NotifyReturn](/courses/rabbitmq/lessons/06-publikaciya-soobschenij/) — издательскую половину ответа. [Шестнадцатый урок про dead letter](/courses/rabbitmq/lessons/16-obrabotka-oshibok/) построит на той же механике аргументов очередь повторов, [пятнадцатый про JSON](/courses/rabbitmq/lessons/15-json-serializaciya/) разберёт тело сообщения, которое headers-маршрутизация не трогает. Durable-семантику ловушки напомнит [девятый урок](/courses/rabbitmq/lessons/09-durable-persistent/), а [fanout из двенадцатого](/courses/rabbitmq/lessons/12-fanout-exchange/) — типичный тип запасного обменника; карта курса — на [странице курса](/courses/rabbitmq/).

### Проверка: x-match

**Вопрос:** Привязка с аргументами x-match all, format pdf, type report. Публикация с заголовками format=pdf и type=invoice. Дойдёт ли сообщение до этой очереди?

- Да: заголовок format совпал, для all этого достаточно
- ✅ Нет: в режиме all должны совпасть все перечисленные заголовки, а type не совпал
- Да: routing key пуст, поэтому привязка игнорируется
- Нет: публикация вернёт ошибку из-за несовпадения заголовков

Режим all требует совпадения каждого заголовка из привязки: format совпал, type — нет, доставка мимо. Публикация при этом проходит без ошибок: сообщение просто не попадёт в эту очередь, а уйдёт другим привязкам или в alternate exchange.

### Проверка: alternate exchange

**Вопрос:** У обменника orders настроен alternate exchange orders.unrouted. Публикация с ключом, к которому нет привязок в обоих обменниках. Что станет с сообщением?

- Оно вернётся в приложение ошибкой от PublishWithContext
- Оно останется в orders и дождётся привязки
- ✅ Оно будет уничтожено: повторной переправки из запасного обменника не будет
- Оно уйдёт в dead letter queue очереди orders

Брокер переправит сообщение в orders.unrouted, не найдёт там привязок и уничтожит: alternate не каскадируется, вторая переправка не срабатывает. Ошибки публикации не будет — поэтому ловушку в запасном обменнике мониторят алертом, а не только объявляют.

### Где окажется сообщение?

```go
// основной: exchange "orders" типа direct с аргументом
//   {"alternate-exchange": "spare"}
// spare объявлен, очередь spare.log привязана
// в orders привязана очередь main с ключом "email"

err = ch.PublishWithContext(ctx, "orders", "push", false, false,
        amqp.Publishing{Body: []byte("a")})
fmt.Println(err == nil)
```

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

- ✅ nil, а сообщение a окажется в spare.log: ключ push не нашёл привязок и переправился в запасной
- Ошибка: ключ push немаршрутизируем
- nil, но ловушка пуста: без mandatory брокер уничтожил сообщение
- nil, а сообщение a окажется в main: alternate работает только с fanout

Публикация прошла, ключ push не совпал с привязкой основного обменника, и брокер переправил сообщение в запасной, где очередь-ловушка его приняла. Err пуст: с точки зрения издателя доставка состоялась — именно поэтому ловушку держат под отдельным алертом.

### Соберите топологию с ловушкой

Допишите объявление запасного обменника, основного с alternate и привязку ловушки.

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

err = ch.ExchangeDeclare("orders", "direct", true, false, false, false,
        amqp.Table{"___": "spare"})
if err != nil {
        log.Fatal(err)
}

err = ch.QueueBind(___, "", "spare", false, nil)
```

Подсказка: Запасной объявляет ExchangeDeclare, в аргументах основного живёт ключ alternate-exchange, а ловушку привязывает QueueBind с именем очереди.

Правильные ответы в пропусках: `ExchangeDeclare`, `alternate-exchange`, `spare.log`

### Соберите топологию с alternate exchange

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

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

1. `conn, err := amqp.Dial(url)`
2. `ch, err := conn.Channel()`
3. `err = ch.ExchangeDeclare("spare", "fanout", true, false, false, false, nil)`
4. `_, err = ch.QueueDeclare("spare.log", true, false, false, false, nil)`
5. `err = ch.QueueBind("spare.log", "", "spare", false, nil)`
6. `err = ch.ExchangeDeclare("orders", "direct", true, false, false, false, amqp.Table{"alternate-exchange": "spare"})`

### Карточки: headers и ловушка промахов

- **headers exchange** — Обменник, который маршрутизирует по заголовкам сообщения; routing key не читает.
- **x-match** — Режим сравнения привязки: all — совпали все заголовки, any — хотя бы один. Типы и регистр значений учитываются.
- **amqp.Table Headers** — Поле Publishing с заголовками: пары ключ-значение для маршрутизации и служебных данных.
- **alternate exchange** — Аргумент обменника: недоставленное переправляется в запасной. Второго прыжка нет — промах уничтожается.
- **очередь-ловушка** — Durable-очередь в запасном обменнике: собирает промахи, живёт под алертом и разбирается вручную.
- **AE против mandatory** — mandatory возвращает промах издателю через его канал; alternate складывает недоставленное централизованно на брокере.

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

- Headers exchange сравнивает заголовки публикации с условиями привязки; routing key не читается.
- x-match all — все совпадения, any — хотя бы одно; масок у заголовков нет.
- Ключи заголовков чувствительны к регистру, значения — к типу: строка и число различаются.
- Alternate exchange переправляет промахи в запасной обменник; каскада из двух переправок не бывает.
- Ловушку объявляют до основного обменника и держат под алертом: промах — инцидент.
- mandatory возвращает промах издателю, alternate складывает его централизованно на брокере.

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

Соберите headers-обменник documents и две очереди: pdf-отчёты с привязкой all по format=pdf и type=report, черновики с any по тем же заголовкам. Публикуйте сообщения с разным набором заголовков и сверяйте попадания с таблицей из урока. Затем повесьте alternate-exchange на обменник из одиннадцатого урока, отправьте сообщение с незнакомым ключом, найдите его в ловушке и решите, какой алерт поставили бы на её глубину.

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

### Что будет, если у привязки к headers exchange нет ключа x-match?

Брокер применит режим по умолчанию — all: привязка потребует совпадения всех перечисленных заголовков. Полагаться на умолчание не стоит: явный x-match в каждой привязке делает топологию читаемой и спасает от сюрпризов при копировании условий из чужого кода.

### Может ли alternate exchange быть topic или direct?

Может: тип запасного не ограничен, просто чаще всего берут fanout, потому что ловушке нужны все промахи без фильтрации. Если промахи нужно делить — например, по типу ошибки — запасной делают topic и привязывают разные очереди с масками; на механику переправки тип не влияет.

### Сработает ли NotifyReturn, если сообщение ушло в alternate exchange?

Нет: переправка в запасной обменник считается состоявшейся доставкой, и mandatory не вернёт такое сообщение издателю. Если вы хотите ловить промахи на стороне издателя — не настраивайте alternate; если централизованно — настройте ловушку и не рассчитывайте на возвраты.
