6. Routing patterns
В статье 2. Exchanges. Direct, Fanout, Topic, Headers мы подробно исследовали базовые механизмы маршрутизации AMQP 0-9-1 и увидели, как обменники направляют трафик на основе Routing Key. В лекции 5. Prefetch и QoS мы настроили безопасное потребление сообщений, защитив сервисы на Go от исчерпания оперативной памяти и аварийного завершения по OOM.
Теперь настало время объединить эти фундаментальные блоки в целостную систему. В реальной промышленной разработке изолированные очереди и простые обменники используются редко. Архитекторы объединяют их в специализированные Топологии (Topologies) или Паттерны маршрутизации (Routing Patterns), каждый из которых решает конкретную инженерную задачу распределенного взаимодействия.
1. Work Queues (Competing Consumers / Конкурирующие воркеры)
Это базовый и наиболее распространенный шаблон асинхронной обработки задач.
Архитектурная задача: У вас есть единый интенсивный поток ресурсоемких фоновых задач (например, конвертация видео, генерация PDF-отчетов или отправка email-рассылок), и вы хотите горизонтально масштабировать их выполнение между $N$ независимыми экземплярами сервиса на Go.
graph LR
subgraph Publisher ["Продюсер"]
P["API Gateway"]
end
subgraph Broker ["RabbitMQ"]
Ex["Direct Exchange<br>(или Default '')"]
Q["Queue: 'tasks.email'<br>(Один Erlang-процесс)"]
Ex -->|"Routing Key: 'email'"| Q
end
subgraph Workers ["Пул воркеров (Competing Consumers)"]
C1["Go Worker 1<br>(Prefetch: 5)"]
C2["Go Worker 2<br>(Prefetch: 5)"]
C3["Go Worker 3<br>(Prefetch: 5)"]
end
P -->|"Basic.Publish"| Ex
Q -->|"Push (Round-Robin)"| C1
Q -->|"Push (Round-Robin)"| C2
Q -->|"Push (Round-Robin)"| C3
classDef pub fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
classDef ex fill:#312e81,stroke:#818cf8,stroke-width:2px,color:#f8fafc;
classDef q fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f8fafc;
classDef sub fill:#1e293b,stroke:#f59e0b,stroke-width:2px,color:#f8fafc;
class P pub;
class Ex ex;
class Q q;
class C1,C2,C3 sub;
Механика работы и Mechanical Sympathy
Брокер распределяет сообщения между всеми активными подписчиками по алгоритму Round-Robin (циклический опрос). Однако этот опрос интеллектуален: он жестко координируется параметром prefetch_count. Если у Worker 1 локальный буфер неподтвержденных сообщений заполнен, планировщик RabbitMQ пропускает его и передает очередную задачу свободному Worker 2.
2. Publish / Subscribe (Широковещательный веер / Fanout)
Архитектурная задача: Одно доменное событие должно быть гарантированно доставлено сразу нескольким несвязанным между собой подсистемам (Broadcast).
Классический пример: Пользователь загрузил новое видео в сервис.
- Подсистема №1 должна извлечь аудиодорожку для транскрибации.
- Подсистема №2 должна нарезать серию превью-кадров (Thumbnails).
- Подсистема №3 должна разослать push-уведомления подписчикам канала.
graph TD
subgraph Publisher ["Издатель"]
P["Video Upload Service"]
end
subgraph FanoutEx ["Fanout Exchange: 'video.uploaded'"]
Distributor["Широковещательный узел"]
end
subgraph Queues ["Изолированные очереди подсистем"]
Q1["Queue: video.audio_extractor"]
Q2["Queue: video.thumbnails"]
Q3["Queue: video.push_notifications"]
end
subgraph Consumers ["Воркеры подсистем"]
C1["Audio Transcriber"]
C2["FFmpeg Resizer"]
C3["Push Service"]
end
P -->|"Basic.Publish"| Distributor
Distributor ==> Q1
Distributor ==> Q2
Distributor ==> Q3
Q1 --> C1
Q2 --> C2
Q3 --> C3
classDef pub fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
classDef ex fill:#312e81,stroke:#818cf8,stroke-width:2px,color:#f8fafc;
classDef q fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f8fafc;
classDef sub fill:#1e293b,stroke:#f59e0b,stroke-width:2px,color:#f8fafc;
class P pub;
class Distributor ex;
class Q1,Q2,Q3 q;
class C1,C2,C3 sub;
3. Content-Based Routing (Иерархическое дерево топиков)
Архитектурная задача: Создание гибкой шины доменных событий (Event-Driven Architecture), где микросервисы динамически подписываются только на строго интересующие их срезы данных без изменения кода продюсеров.
Конвенция именования ключей маршрутизации
Промышленным стандартом для Topic Exchange является сегментированная структура ключа через точку:
<domain>.<entity>.<action>.<version>
Например: billing.invoice.paid.v1 или auth.user.login_failed.v2.
flowchart TD
subgraph TopicHub ["Topic Exchange: 'events.topic'"]
Trie["Префиксное дерево маршрутизации (Trie)"]
end
P1["Billing Service: billing.invoice.paid.v1"] --> TopicHub
P2["Billing Service: billing.invoice.cancelled.v1"] --> TopicHub
P3["Auth Service: auth.user.created.v1"] --> TopicHub
TopicHub -->|"Binding: 'billing.invoice.paid.*'"| Q1["Queue: Financial Analytics<br>(Только оплаченные счета)"]
TopicHub -->|"Binding: 'billing.invoice.*.v1'"| Q2["Queue: Invoice History<br>(Любые действия с инвойсами)"]
TopicHub -->|"Binding: '#'"| Q3["Queue: Global Audit Log<br>(Абсолютно все события компании)"]
classDef pub fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
classDef ex fill:#312e81,stroke:#818cf8,stroke-width:2px,color:#f8fafc;
classDef q fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f8fafc;
class P1,P2,P3 pub;
class Trie ex;
class Q1,Q2,Q3 q;
Накладные расходы на вычисление маршрута
Таблица правил привязок для Topic Exchange компилируется в памяти брокера в префиксное дерево (Trie).
- Обход дерева выполняется для каждого входящего фрейма.
- Чрезмерно длинные ключи (более 5–6 уровней вложенности) и обилие спецсимволов
#кратно увеличивают расход тактов CPU. - Рекомендация: Держите глубину топиков в пределах 3–4 уровней.
4. Wire Tap (Неинвазивный аудит) и Alternate Exchange
В распределенных системах регулярно возникает потребность в «прослушке» или аудиторском логировании сообщений, которые по ошибке не смогли найти своего адресата (Unroutable Messages).
По умолчанию RabbitMQ молча удаляет сообщения, для которых в Exchange не нашлось ни одного совпадающего биндинга. Для предотвращения потерь данных протокол AMQP 0-9-1 предлагает изящный паттерн — Alternate Exchange (AE).
graph TD
subgraph Publisher ["Отправитель"]
P["Microservice"]
end
subgraph Topo ["RabbitMQ Топология"]
MainEx["Main Exchange: 'orders.topic'<br>(С аргументом alternate-exchange)"]
NormalQ["Queue: 'orders.process'<br>(Binding: 'orders.created')"]
AltEx["Alternate Exchange: 'unroutable.fanout'<br>(AE)"]
AuditQ["Queue: 'unroutable.audit'<br>(Хранилище заблудившихся сообщений)"]
MainEx -->|"Совпал ключ: 'orders.created'"| NormalQ
MainEx -.->|"Ключ: 'order.baddata'<br>(Unroutable / Нет совпадений)"| AltEx
AltEx --> AuditQ
end
P --> MainEx
classDef pub fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
classDef ex fill:#312e81,stroke:#818cf8,stroke-width:2px,color:#f8fafc;
classDef q fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f8fafc;
classDef ae fill:#831843,stroke:#f43f5e,stroke-width:2px,color:#fdf2f8;
class P pub;
class MainEx ex;
class NormalQ,AuditQ q;
class AltEx ae;
Конфигурация Alternate Exchange на Go:
// Декларация основного обменника со ссылкой на Alternate Exchange
args := amqp.Table{
"alternate-exchange": "audit.fanout", // Куда перенаправлять нераспределяемые сообщения
}
err := ch.ExchangeDeclare(
"main.topic", // name
"topic", // kind
true, // durable
false, // auto-delete
false, // internal
false, // no-wait
args, // параметры
)
5. Request-Reply (Синхронный RPC через RabbitMQ)
Иногда асинхронный брокер используется для синхронного по своей семантике вызова (Remote Procedure Call). Например: фронтенд-шлюз на Go отправляет запрос на тяжелый математический расчет в Python-воркер и блокируется до получения результата.
sequenceDiagram
autonumber
participant Client as "Go RPC Client"
participant Broker as "RabbitMQ"
participant Server as "Worker Service (RPC Server)"
Note over Client: Инициализация: Создание ОДНОЙ очереди ответов (replyQ)
Client->>Broker: Basic.Publish (RPC Task)<br>ReplyTo: "client-replies-node1"<br>CorrelationId: "uuid-9901"
Broker->>Server: Basic.Deliver (Task Payload)
Server->>Server: Расчет результата (CPU-bound)
Server->>Broker: Basic.Publish (Результат)<br>Exchange: "", RoutingKey: "client-replies-node1"<br>CorrelationId: "uuid-9901"
Broker->>Client: Basic.Deliver (Ответ)
Note over Client: Поиск канала в sync.Map по CorrelationId -> Разблокировка горутины
Архитектурная ловушка новичков (The Ephemeral Queue Trap)
Создавать новую эксклюзивную очередь (Exclusive Queue) на каждый входящий HTTP-запрос — фатальная архитектурная ошибка.
Создание очереди в кластере RabbitMQ — это тяжелая распределенная транзакция в метабазе Mnesia с синхронизацией по сети. При нагрузке в 200–300 RPS брокер захлебнется от постоянного создания и удаления очередей в ядре Erlang.
Правильный паттерн: Пул каналов и мультиплексирование через sync.Map
Инстанс клиента создает ровно одну очередь ответов на весь сервис. Внутренние горутины регистрируют свои каналы ожидания через sync.Map и channels по ключу CorrelationId.
Идиоматичный Go-код промышленного RPC-клиента:
package rpc
import (
"context"
"fmt"
"sync"
"time"
"github.com/google/uuid"
amqp "github.com/rabbitmq/amqp091-go"
)
// RPCClient реализует потокобезопасный мультиплексированный клиент RPC
type RPCClient struct {
ch *amqp.Channel
replyQ string
requests sync.Map // map[string]chan []byte (CorrelationId -> Go-канал)
}
// NewRPCClient инициализирует долгоживущую очередь для ответов и фоновый слушатель
func NewRPCClient(ch *amqp.Channel) (*RPCClient, error) {
// 1. Создаем ОДНУ очередь для ВСЕХ ответов данного инстанса приложения
q, err := ch.QueueDeclare(
"", // Имя сгенерирует брокер (например, amq.gen-Xa81...)
false, // durable: очередь временная
true, // auto-delete: удалить при отключении сервиса
true, // exclusive: доступна только этому соединению
false, // no-wait
nil,
)
if err != nil {
return nil, fmt.Errorf("queue declare error: %w", err)
}
client := &RPCClient{
ch: ch,
replyQ: q.Name,
}
// 2. Запускаем фоновый consumer для прослушивания ответов
msgs, err := ch.Consume(
q.Name,
"", // consumer-tag
true, // autoAck: для временных RPC ответов допустим autoAck
false, // exclusive
false, // noLocal
false, // noWait
nil,
)
if err != nil {
return nil, fmt.Errorf("consume error: %w", err)
}
go client.listenForReplies(msgs)
return client, nil
}
// listenForReplies непрерывно читает поток ответов и пробуждает горутины
func (c *RPCClient) listenForReplies(msgs <-chan amqp.Delivery) {
for d := range msgs {
if chWaitAny, ok := c.requests.LoadAndDelete(d.CorrelationId); ok {
chWait := chWaitAny.(chan []byte)
chWait <- d.Body // Будим горутину, заблокированную в Call()
close(chWait)
}
}
}
// Call отправляет RPC-запрос и синхронно ожидает ответ с учетом контекста
func (c *RPCClient) Call(ctx context.Context, targetQueue string, payload []byte) ([]byte, error) {
corrID := uuid.NewString()
waitCh := make(chan []byte, 1) // Буферизованный канал во избежание блокировки слушателя
// Регистрируем ожидание по CorrelationId
c.requests.Store(corrID, waitCh)
defer c.requests.Delete(corrID) // Защита от утечек памяти при таймауте
// Публикуем задачу
err := c.ch.PublishWithContext(ctx,
"", // exchange (прямая отправка через default exchange)
targetQueue, // routing key (имя очереди сервера)
false,
false,
amqp.Publishing{
ContentType: "application/json",
CorrelationId: corrID, // Уникальный идентификатор запроса
ReplyTo: c.replyQ, // Куда сервер обязан прислать ответ
Body: payload,
Timestamp: time.Now().UTC(),
},
)
if err != nil {
return nil, fmt.Errorf("rpc publish failed: %w", err)
}
// Ожидаем ответ либо срабатывание таймаута контекста
select {
case <-ctx.Done():
return nil, ctx.Err()
case res := <-waitCh:
return res, nil
}
}
Итог
- Work Queues (Competing Consumers): Оптимальны для параллельного распределения задач между воркерами. Помните: хронологический порядок обработки сообщений при этом не сохраняется.
- Publish / Subscribe (Fanout): Идеален для рассылки широковещательных событий нескольким сервисам. Работает с околонулевым оверхедом по памяти благодаря Zero-Copy в BEAM.
- Content-Based Routing (Topic): Базовый каркас для событийно-ориентированных микросервисов. Проектируйте структуру топиков не глубже 3–4 уровней.
- Wire Tap & Alternate Exchange: Защищают систему от невидимой потери сообщений на этапе маршрутизации при опечатках в ключах или сбоях конфигурации.
- AMQP RPC: Требует строгой дисциплины. Никогда не создавайте очереди на каждый HTTP-запрос — используйте одну общую очередь ответов на процесс и мультиплексирование через
CorrelationId.
Мы научились собирать гибкие топологии и отлавливать потерянные сообщения на этапе публикации. Но что делать с теми сообщениями, которые успешно дошли до очереди, но были отвергнуты воркером из-за некорректных данных или исчерпания лимита ретраев?
В следующей статье 7. Dead letter exchanges мы глубоко исследуем спасительный механизм Dead Letter Exchange (DLX) и разберем архитектуру очередей брака.