3. Subject based routing
Иерархии, wildcards и механика префиксных деревьев
«Имя переменной, функции или канала должно просто и ясно говорить о том, чем они являются. Грамотные пространства имен делают сложные архитектуры самодокументируемыми».
— Брайан Керниган
Что такое Subject?
В большинстве традиционных брокеров сообщений адресация данных жестко привязана к предварительно создаваемым тяжеловесным сущностям:
- В Apache Kafka вы обязаны заранее объявить топик (
Topic), выбрать количество партиций и фактор репликации. Имена топиков образуют плоское пространство имен (orders_v1,user_events). - В RabbitMQ маршрутизация требует предварительного объявления обменников (
Exchange), очередей (Queue) и биндингов (Binding) с ключами маршрутизации.
NATS подошел к маршрутизации совершенно иначе: здесь нет необходимости предварительно создавать топики или настраивать конфигурации на сервере!
Subject (Субъект) в NATS — это динамическая иерархическая строка, состоящая из буквенно-цифровых токенов (tokens), разделенных точками (.).
Каждый токен представляет собой смысловой уровень иерархии (пространство имен, домен, сущность, действие, регион). Это напоминает структуру путей в файловой системе Unix (/var/log/nginx/access.log) или URL в HTTP, но работает со скоростью процессорных регистров непосредственно в оперативной памяти брокера.
Примеры выразительных subject'ов в реальных системах:
orders.new
orders.completed
orders.cancelled
users.create
users.update.profile
users.delete
news.sports.football.nfl
news.sports.basketball.nba
news.tech.software
payments.credit.card.visa
payments.debit.bank.transfer
Продюсер может в любую миллисекунду отправить сообщение в абсолютно любой субъект, например metrics.sensor.temperature.europe.rack-42, даже если этот субъект никогда раньше не упоминался в системе. Если подписчиков нет — сообщение мгновенно исчезает. Если есть — доставляется за доли микросекунды.
Иерархическая структура и Wildcards
Главная мощь субъектов NATS раскрывается благодаря механизму сопоставления с шаблонами (Pattern Matching). Подписчик может подписаться не только на один конкретный субъект, но и на целое поддерево событий с помощью двух простых подстановочных знаков (Wildcards):
-
Одиночный wildcard
*(PWC — Partial Wildcard):- Заменяет строго один токен между точками.
- Подписка
orders.*перехватит:orders.new,orders.completed,orders.cancelled. - Но не перехватит:
orders.payment.failed(здесь два токена послеorders) и не перехватит простоorders(ноль токенов).
-
Жадный wildcard
>(FWC — Full Wildcard):- Заменяет один или более любых токенов до самого конца строки.
- Может стоять только в самом конце выражения подписки.
- Подписка
orders.>перехватит:orders.new,orders.payment.processing,orders.refund.initiated.approvedи вообще любую вложенность внутри пространстваorders.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph Publisher["Событие от продюсера: news.sports.football.nfl"]
direction TB
Msg["Subject: news.sports.football.nfl"]:::process
end
subgraph MatchCheck["Проверка совпадения шаблонов (Pattern Matching)"]
direction TB
M1["Шаблон: news.sports.*.*"]:::process
M2["Шаблон: news.sports.>"]:::process
M3["Шаблон: news.sports.*"]:::process
M4["Шаблон: news.>"]:::process
end
subgraph Verdicts["Результат доставки"]
V1["✅ Доставлено (ровно 2 токена)"]
V2["✅ Доставлено (жадный суффикс)"]
V3["❌ Игнорировано (ожидался 1 токен, пришло 2)"]
V4["✅ Доставлено (все новости)"]
end
Msg --> M1 --> V1
Msg --> M2 --> V2
Msg --> M3 --> V3
Msg --> M4 --> V4
Сравнение поведения масок:
news.sports.*— матчитnews.sports.football,news.sports.basketball, но не матчитnews.sports.football.nfl.news.sports.>— матчитnews.sports.football,news.sports.football.nflи всё, что глубже.*.payment.*— матчитorders.payment.status,users.payment.method.
Механизм маршрутизации
Когда publisher отправляет сообщение в NATS, сервер должен за считанные наносекунды найти всех подписчиков, чьи фильтры соответствуют данному конкретному субъекту.
Если бы NATS использовал регулярные выражения (RegExp) или линейный перебор массива подписчиков $O(N)$, производительность деградировала бы уже при 10 000 клиентах.
Вместо этого nats-server использует специализированное многолучевое префиксное дерево — Sublist (Radix Trie), оптимизированное под механическую симпатию к кэш-линиям CPU:
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph "NATS Server Sublist Trie (В оперативной памяти)"
direction TB
Root["Root Node"]:::process
Root --> N_orders["node: 'orders'"]
Root --> N_users["node: 'users'"]
Root --> N_news["node: 'news'"]
N_orders --> O_new["node: 'new' (Sub: Client A)"]
N_orders --> O_comp["node: 'completed'"]
N_orders --> O_star["node: '*' (Sub: Audit Service)"]
N_orders --> O_gt["node: '>' (Sub: Data Lake Ingest)"]
N_users --> U_update["node: 'update'"]
U_update --> U_gt["node: '>' (Sub: Notification Service)"]
N_news --> NW_sports["node: 'sports'"]
NW_sports --> NW_star["node: '*' (Sub: Sports App)"]
end
Как устроен Sublist в Go-исходниках nats-server
Внутри ядра NATS (server/sublist.go) маршрутизация реализована через структуру sublist:
- Каждая вершина дерева содержит хэш-таблицу точных совпадений литералов (
nodes map[string]*node), ссылку на поддерево частичной маски*(pwc *node) и список подписчиков полной маски>(fwc *node). - При поступлении сообщения сервер разбивает субъект на токены прямо на срезе байтов без аллокаций памяти (
zero-allocation tokenization). - Поиск выполняется за один проход по дереву: алгоритм рекурсивно спускается по точной ветке, одновременно собирая подписчиков из узлов
*и>. - Subscription Cache (Кэш сопоставлений): Сервер кэширует результаты сопоставлений для часто повторяющихся субъектов в быстрой хэш-таблице (с атомарной инвалидацией при добавлении новых подписчиков). Благодаря этому повторные сообщения маршрутизируются за время $O(1)$!
Пример кода: Использование Subject Routing
Продемонстрируем работу точных подписок и подписок с масками на Go с использованием официального пакета github.com/nats-io/nats.go:
package main
import (
"fmt"
"log"
"time"
"github.com/nats-io/nats.go"
)
func main() {
// Подключение к серверу NATS
nc, err := nats.Connect(nats.DefaultURL)
if err != nil {
log.Fatalf("connection failed: %v", err)
}
defer nc.Close()
// 1. Подписка на точный субъект: только новые заказы
_, err = nc.Subscribe("orders.new", func(msg *nats.Msg) {
fmt.Printf("Specific Order Handler: %s\n", string(msg.Data))
})
if err != nil {
log.Fatal(err)
}
// 2. Подписка на все заказы первого уровня вложенности (wildcard *)
_, err = nc.Subscribe("orders.*", func(msg *nats.Msg) {
fmt.Printf("Wildcard Order Handler: %s (subject: %s)\n",
string(msg.Data), msg.Subject)
})
if err != nil {
log.Fatal(err)
}
// 3. Жадная подписка на все события профиля пользователя любой глубины (wildcard >)
_, err = nc.Subscribe("users.update.>", func(msg *nats.Msg) {
fmt.Printf("User Update Handler: %s (full path: %s)\n",
string(msg.Data), msg.Subject)
})
if err != nil {
log.Fatal(err)
}
// Убедимся, что сервер зарегистрировал подписки
_ = nc.Flush()
// Публикация тестовых событий
_ = nc.Publish("orders.new", []byte("New order #123"))
_ = nc.Publish("orders.completed", []byte("Order #123 completed"))
_ = nc.Publish("users.update.profile.email", []byte("Email updated"))
_ = nc.Publish("users.update.settings.theme", []byte("Theme changed"))
// Даем время на доставку
time.Sleep(100 * time.Millisecond)
}
Вывод консоли:
Specific Order Handler: New order #123
Wildcard Order Handler: New order #123 (subject: orders.new)
Wildcard Order Handler: Order #123 completed (subject: orders.completed)
User Update Handler: Email updated (full path: users.update.profile.email)
User Update Handler: Theme changed (full path: users.update.settings.theme)
Сравнение с другими брокерами
NATS vs Kafka
- Apache Kafka: Использует плоские имена топиков (
flat namespace). Например,orders_new,orders_completed,user_updates. Нельзя подписаться «на все топики с префиксом orders» без сложного динамического regex-подключения в Consumer (Pattern.compile("orders_.*")), которое создает огромную нагрузку на метаданные кластера. - NATS: Иерархические субъекты спроектированы под динамическую фильтрацию изначально.
// Kafka style (flat)
producer.send(new ProducerRecord<>("orders_new", data));
producer.send(new ProducerRecord<>("orders_completed", data));
// NATS style (hierarchical)
nc.Publish("orders.new", data);
nc.Publish("orders.completed", data);
NATS vs RabbitMQ
- RabbitMQ: Маршрутизация строится вокруг концепций
Exchange,QueueиBinding. Чтобы привязать очередь к обменнику типаtopic, вы должны отправить брокеру декларативные команды по протоколу AMQP. Каждая новая очередь требует выделения памяти на сервере Erlang. - NATS: Прямое сопоставление
Subject $\to$ Subscription. Маршрутизация эфемерна, не требует создания очередей и выполняется за наносекунды в оперативной памяти.
// RabbitMQ (упрощенно)
channel.publish(exchange="amq.topic", routingKey="orders.new", body=data);
// NATS
nc.Publish("orders.new", data);
Advanced Routing Patterns
1. Multi-level Wildcards
С помощью масок можно элегантно фильтровать события по горизонтальным срезам архитектуры:
// Подписка на ВСЕ финансовые операции любой глубины
nc.Subscribe("finance.>", handler) // finance.payments.card, finance.reports.daily, etc.
// Подписка на все операции оплаты во всех подсистемах (cross-cutting concern)
nc.Subscribe("*.payment.*", handler) // orders.payment.status, users.payment.method
2. Queue Groups (Load Balancing на субъектах)
По умолчанию NATS рассылает сообщение всем подписчикам (fan-out). Но что, если нам нужно распределить нагрузку между пулом из 5 одинаковых воркеров?
Для этого NATS предоставляет Queue Groups:
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
P["Publisher: orders.new"]:::network --> S["NATS Server"]
subgraph QueueGroup["Queue Group: 'processor_group'"]
W1["Worker 1"]:::accent
W2["Worker 2"]:::accent
W3["Worker 3"]:::accent
end
S -->|"Балансировка Round-Robin<br>(ровно одному воркеру!)"| W2
// Эти воркеры разделят входящий поток между собой
for i := 0; i < 3; i++ {
workerID := i
_, err := nc.QueueSubscribe("orders.new", "processor_group", func(msg *nats.Msg) {
fmt.Printf("Worker %d processing order: %s\n", workerID, string(msg.Data))
})
if err != nil {
log.Fatal(err)
}
}
Performance Implications
Производительность сопоставления субъектов в NATS опирается на следующие инженерные принципы:
- Subject matching выполняется целиком в оперативной памяти брокера с использованием специализированных префиксных деревьев (
trie/radix tree/sublist). - Wildcards требуют параллельного сопоставления нескольких путей в префиксном дереве (ветки точного совпадения,
*и>), что требует чуть больше тактов CPU, чем тривиальный хэш-поиск. Однако благодаря механизмуSubscription Cache(кэш результатов сопоставления для горячих субъектов) эта разница практически нивелируется. - Deep subject hierarchies (большое количество токенов) увеличивают глубину обхода дерева, поэтому общепринятым стандартом в продакшене считается глубина в 3–6 уровней.
- Масштабируемость: NATS способен маршрутизировать десятки миллионов сообщений в секунду даже при наличии сотен тысяч уникальных субъектов и сложных wildcard-масок.
Best Practices: стандарты именования субъектов
Архитектурная дисциплина в именовании субъектов определяет долговечность вашей системы:
1. Subject Naming Convention (Соглашения об именовании)
Рекомендуемый базовый шаблон: <область>.<сущность>.<действие/статус>
// Хорошо: структурировано, понятно, расширяемо
"payment.processing.success"
"user.authentication.failed"
"inventory.stock.low"
// Плохо: слишком абстрактно или слишком узко
"event" // Ужасно: нет контекста
"user.authentication.failed.attempt.number.12345.timestamp.12345678" // Ужасно: переменные данные в теле субъекта
2. Wildcard Usage (Использование масок)
// Хорошо: разумное ограничение скоупа
"orders.*" // Все прямые события заказов
"users.update.*" // Все типы обновлений профиля
// Избегайте: слишком широкие маски без необходимости
">" // Прослушивание ВСЕГО брокера — допустимо только для аудита/логирования!
3. Namespace Management (Управление пространствами имен и мультиарендность)
// Разделение по окружениям или командам
"prod.billing.invoices.created"
"staging.billing.invoices.created"
"tenant-100500.orders.new"
Use Cases for Subject Routing
Иерархические субъекты находят широкое применение в типовых распределенных паттернах:
- Microservices Communication:
service-name.action.resource(например,orders-service.create.order). - Event Sourcing:
aggregate-type.event-type(например,account.credited,account.debited). - Monitoring & Observability:
service.environment.metric-type(например,auth-api.production.latency-p99). - Multi-tenant Systems:
tenant-id.service.event(например,tenant-42.billing.invoice-issued).
Итог
- Subject-based routing — фундаментальная архитектурная особенность NATS, исключающая необходимость предварительного создания очередей и топиков.
- Иерархическая структура позволяет организовывать потоки событий в логические пространства имен с минимальной связностью компонентов.
- Поддержка масок (
*и>) обеспечивает гибкую подписку на любые горизонтальные и вертикальные срезы системы. - Дерево Sublist в оперативной памяти гарантирует миллионы операций маршрутизации в секунду с микросекундными задержками.
В следующей статье — 4. Request Reply паттерн — мы изучим, как NATS реализует синхронный межсервисный RPC поверх асинхронного pub/sub с помощью динамических inbox-субъектов.