4. Request Reply паттерн
Синхронный RPC поверх асинхронного брокера
«Распределенная система — это система, в которой сбой компьютера, о существовании которого вы даже не подозревали, может вывести из строя вашу собственную машину. Простота и быстрое падение при ошибках — наша единственная защита».
— Лесли Лэмпорт
Анатомия паттерна: синхронный диалог в мире асинхронного Pub/Sub
В классической парадигме Publish/Subscribe отправитель («fire-and-forget») публикует сообщение в шину данных и немедленно продолжает работу. Он не знает и не желает знать, сколько потребителей получат событие (ноль, один или сотня) и когда именно они его обработают.
Однако в реальных микросервисных архитектурах постоянно возникает необходимость в двустороннем диалоге (Request-Reply / RPC):
- «Дай актуальный профиль пользователя с ID 42»;
- «Заблокируй 100 рублей на счете клиента и верни номер транзакции»;
- «Проверь доступность складского остатка перед созданием заказа».
Большинство инженеров по привычке выбирают для этой задачи HTTP REST или gRPC. Но это тянет за собой целый ворох инфраструктурных сложностей: необходимость в Service Discovery (Consul, Eureka, k8s DNS), клиентской балансировке нагрузки, Sidecar-прокси (Envoy, Istio), управлении TLS-сертификатами для каждого пода и борьбе с таймаутами при холодном старте соединений.
NATS предлагает изящную альтернативу: реализацию полноценного высокопроизводительного RPC поверх базового механизма Publish/Subscribe.
Почтовая аналогия Request-Reply:
Отправляя письмо с вопросом в компанию, вы вкладываете внутрь
конверт с вашим обратным адресом и наклеенной маркой (Reply Subject).
Получатель читает вопрос, пишет ответ, запечатывает его в ваш
обратный конверт и опускает в ближайший почтовый ящик.
Как работает Request-Reply в NATS
Ключевая идея NATS Request-Reply — асинхронный транспорт для синхронных вызовов.
Каждое сообщение протокола NATS на транспортном уровне содержит опциональное поле reply-to. Клиент генерирует уникальный эфемерный субъект для ответа и передает его серверу вместе с телом запроса:
- Клиент создает временный reply subject (внутренний уникальный почтовый ящик, inbox).
- Клиент подписывается на этот reply subject и ожидает ответ.
- Клиент отправляет request на request subject, указывая reply subject в протокольном заголовке.
- Сервер (обработчик) получает запрос, выполняет бизнес-логику и отправляет ответ на указанный reply subject.
- Клиент получает ответ и завершает ожидание, после чего удаляет временную подписку.
sequenceDiagram
autonumber
participant C as Клиент (Requester)
participant N as NATS Server (Broker)
participant R as Сервис-обработчик (Responder)
Note over C: Генерирует Inbox: _INBOX.b7a1...
C->>N: SUB _INBOX.b7a1... (ожидание ровно 1 ответа)
C->>N: PUB api.get.user _INBOX.b7a1... {"id": "42"}
N->>R: MSG api.get.user _INBOX.b7a1... {"id": "42"}
Note over R: Обработка запроса в БД
R->>N: PUB _INBOX.b7a1... {"id": "42", "name": "Alice"}
N->>C: MSG _INBOX.b7a1... {"id": "42", "name": "Alice"}
Note over C: Завершение вызова (Request unblocks)
Эволюция Inbox: под капотом библиотеки nats.go
Пример реализации
Basic Request-Reply
Ниже приведен законченный рабочий пример на Go, демонстрирующий клиент и сервер:
package main
import (
"encoding/json"
"fmt"
"log"
"time"
"github.com/nats-io/nats.go"
)
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Created string `json:"created_at"`
}
func main() {
nc, err := nats.Connect(nats.DefaultURL)
if err != nil {
log.Fatal(err)
}
defer nc.Close()
// 1. Запускаем обработчик запросов (сервер)
go startUserServer(nc)
// Даем серверу миллисекунду на регистрацию подписки
time.Sleep(50 * time.Millisecond)
// 2. Выполняем синхронные запросы (клиент)
makeRequests(nc)
}
func startUserServer(nc *nats.Conn) {
// Подписываемся на субъект запросов
_, err := nc.Subscribe("api.get.user", func(msg *nats.Msg) {
userID := string(msg.Data)
// Формируем полезную нагрузку ответа
user := User{
ID: userID,
Name: "John Doe",
Email: "john@example.com",
Created: time.Now().Format(time.RFC3339),
}
response, err := json.Marshal(user)
if err != nil {
log.Printf("Marshal error: %v", err)
return
}
// Отправляем ответ на временный reply subject клиента
if msg.Reply != "" {
err = nc.Publish(msg.Reply, response)
if err != nil {
log.Printf("Failed to send reply: %v", err)
}
}
})
if err != nil {
log.Fatal(err)
}
fmt.Println("User server responder started on [api.get.user]...")
select {} // Удерживаем фоновую горутину
}
func makeRequests(nc *nats.Conn) {
for i := 1; i <= 3; i++ {
userID := fmt.Sprintf("user-%d", i)
// nc.Request блокирует горутину до получения ответа или истечения таймаута
resp, err := nc.Request("api.get.user", []byte(userID), 2*time.Second)
if err != nil {
log.Printf("Request failed: %v", err)
continue
}
var user User
if err := json.Unmarshal(resp.Data, &user); err != nil {
log.Printf("Failed to parse response: %v", err)
continue
}
fmt.Printf("Received user: %+v\n", user)
}
}
Timeout и Error Handling: защита от зависания
В распределенных системах запросы обязаны быть жестко ограничены во времени. Сетевой сбой, падение сервиса или блокировка в СУБД не должны подвешивать вызывающий сервис навечно.
В NATS предусмотрено два критически важных сценария ошибок:
nats.ErrTimeout— прошел интервал ожидания, но ответ так и не пришел.nats.ErrNoResponders— революционная оптимизация NATS! Если в кластере прямо сейчас нет ни одного живого подписчика на целевой субъект, сервер NATS не заставляет клиента ждать 5 секунд по таймауту, а мгновенно возвращает специальный 503-статус (No Responders).
func robustRequestExample(nc *nats.Conn) {
userID := "test-user"
// Устанавливаем жесткий таймаут в 5 секунд
resp, err := nc.Request("api.get.user", []byte(userID), 5*time.Second)
if err != nil {
switch err {
case nats.ErrTimeout:
log.Printf("Request timeout after 5 seconds: сервис не ответил вовремя")
case nats.ErrNoResponders:
log.Printf("No service available to handle request: нет активных обработчиков в кластере!")
default:
log.Printf("Request failed: %v", err)
}
return
}
fmt.Printf("Success response: %s\n", string(resp.Data))
}
Concurrent Requests: параллельная обработка запросов
Благодаря асинхронной природе протокола NATS и мультиплексированию входящих ответов по Go-каналам, одно TCP-соединение nats.Conn может обслуживать десятки тысяч одновременных горутин без взаимной блокировки конвейера:
func concurrentRequests(nc *nats.Conn) {
const numRequests = 10
responses := make(chan *nats.Msg, numRequests)
errors := make(chan error, numRequests)
// Запускаем пул параллельных горутин
for i := 0; i < numRequests; i++ {
go func(requestID int) {
resp, err := nc.Request(
"api.get.user",
[]byte(fmt.Sprintf("user-%d", requestID)),
3*time.Second,
)
if err != nil {
errors <- err
return
}
responses <- resp
}(i)
}
// Асинхронно собираем результаты
for i := 0; i < numRequests; i++ {
select {
case resp := <-responses:
fmt.Printf("Got response: %s\n", string(resp.Data))
case err := <-errors:
fmt.Printf("Got error: %v\n", err)
}
}
}
Request-Reply vs HTTP
Инженеры часто задают вопрос: зачем использовать NATS Request-Reply, если есть общепринятый HTTP/gRPC? Сравним их архитектурные свойства:
| Характеристика | HTTP Request | NATS Request-Reply |
|---|---|---|
| Транспорт | TCP / HTTP/1.1 / HTTP/2 | Высокоэффективный бинарно-текстовый NATS протокол |
| Connection reuse | HTTP/1.1 keep-alive, HTTP/2 multiplexing | Одно постоянное персистентное TCP-соединение (nats.Conn) |
| Load balancing | Внешний L7/L4 прокси (Nginx, Envoy, AWS ALB) | Встроенный в брокер через Queue Groups (nc.QueueSubscribe) |
| Service discovery | Внешний реестр (Consul, k8s Headless Services, DNS) | Не требуется: маршрутизация по субъектам (Subject-based) |
| Serialization | Любая (чаще JSON/gRPC Protobuf over HTTP) | Любая произвольная последовательность байт ([]byte) |
| Performance | Высокий оверхед на парсинг HTTP-заголовков | Экстремально низкий оверхед (микросекунды) |
| Circuit breaking | Внешние библиотеки (Hystrix, go-resilience) | Встроенная поддержка через nats.ErrNoResponders |
// HTTP Request: требует резолва DNS, создания/извлечения соединения из пула, формирования HTTP-заголовков
resp, err := http.Get("http://user-service/api/user/123")
// NATS Request-Reply: запись пакета в уже открытый TCP-сокет по имени логического субъекта
resp, err := nc.Request("api.get.user", []byte("123"), 2*time.Second)
Горизонтальное масштабирование и балансировка через Queue Groups
Одна из сильнейших сторон Request-Reply в NATS — автоматическая балансировка нагрузки из коробки. Если вы запустите 10 реплик микросервиса user-service и подпишете их с использованием QueueSubscribe, NATS сам распределит входящие RPC-запросы между ними по алгоритму Round-Robin:
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
C["Client: nc.Request('users.get')"]:::entry -->|"1. Запрос"| S["NATS Server"]
subgraph QueueGroup["Queue Group: 'user-service-workers'"]
W1["Инстанс 1"]:::process
W2["Инстанс 2 (Выбран)"]
W3["Инстанс 3"]:::process
end
S -->|"2. Round-Robin доставка"| W2
W2 -->|"3. Ответ на _INBOX"| S
S -->|"4. Возврат клиенту"| C
// На каждом инстансе сервиса запускается код:
nc.QueueSubscribe("api.get.user", "user-service-workers", func(msg *nats.Msg) {
// Обработка ровно одним экземпляром из группы
nc.Publish(msg.Reply, []byte(`{"status": "ok"}`))
})
Вам больше не нужны Kubernetes Ingress-контроллеры или сервисные сетки (Service Mesh) для балансировки внутреннего межсервисного трафика!
Advanced Pattern: Request-Reply с Middleware
Как и в веб-фреймворках (Gin, Echo), в NATS обработчики можно оборачивать в цепочки промежуточного ПО (middleware) для логирования, сбора метрик Prometheus, трейсинга OpenTelemetry и аутентификации:
func createAuthenticatedRequestHandler(nc *nats.Conn, next nats.MsgHandler) nats.MsgHandler {
return func(msg *nats.Msg) {
// 1. Проверяем токен авторизации из заголовка
token := msg.Header.Get("Authorization")
if token != "secret-token-123" {
if msg.Reply != "" {
_ = nc.Publish(msg.Reply, []byte(`{"error": "unauthorized"}`))
}
return
}
// 2. Замеряем время выполнения
start := time.Now()
// 3. Передаем управление бизнес-логике
next(msg)
// 4. Логируем результат
duration := time.Since(start)
log.Printf("[METRIC] Subject: %s handled in %v", msg.Subject, duration)
}
}
// Использование middleware
userHandler := func(msg *nats.Msg) {
_ = nc.Publish(msg.Reply, []byte(`{"user": "Alice"}`))
}
_, err = nc.Subscribe("api.get.user", createAuthenticatedRequestHandler(nc, userHandler))
Request-Reply с Context: современный идиоматичный Go
Начиная с современных версий библиотеки nats.go, доступен нативный метод nc.RequestWithContext(ctx, subject, data). Он идеально интегрируется с отменой операций, таймаутами родительских контекстов и трассировкой Go:
import (
"context"
"time"
)
func requestWithContext(ctx context.Context, nc *nats.Conn, subject string, data []byte) (*nats.Msg, error) {
// Если клиентский контекст уже содержит дедлайн или отмену,
// RequestWithContext корректно прервет ожидание без утечки горутин
return nc.RequestWithContext(ctx, subject, data)
}
Для понимания того, как контекст управлял жизненным циклом запроса до появления нативного API, полезна кастомная реализация через горутины и каналы:
func legacyRequestWithContext(ctx context.Context, nc *nats.Conn, subject string, data []byte) (*nats.Msg, error) {
result := make(chan struct {
msg *nats.Msg
err error
}, 1)
go func() {
msg, err := nc.Request(subject, data, 30*time.Second)
result <- struct {
msg *nats.Msg
err error
}{msg, err}
}()
select {
case res := <-result:
return res.msg, res.err
case <-ctx.Done():
return nil, ctx.Err()
}
}
Performance Considerations: наносекунды против миллисекунд
При прямом сравнении на одном и том же оборудовании NATS Request-Reply демонстрирует потрясающую производительность:
func benchmarkRequestReply() {
nc, _ := nats.Connect(nats.DefaultURL)
// Прогрев (Warm up)
for i := 0; i < 100; i++ {
_, _ = nc.Request("api.ping", []byte("ping"), time.Second)
}
// Замер производительности
start := time.Now()
const requests = 10000
for i := 0; i < requests; i++ {
_, err := nc.Request("api.ping", []byte("ping"), time.Second)
if err != nil {
log.Fatal(err)
}
}
duration := time.Since(start)
avgLatency := duration.Nanoseconds() / int64(requests)
fmt.Printf("Total: %v, Avg latency: %dns per request\n", duration, avgLatency)
// В локальной сети / localhost: ~50-90µs на запрос (против ~1-5ms для чистого HTTP REST)
}
Ограничения и Best Practices
1. Не используйте для high-throughput операций
Request-Reply по своей природе заставляет клиента ожидать ответа (RTT — Round Trip Time). Если вам требуется обрабатывать 500 000 событий в секунду (логирование, сбор метрик, финансовый клиринг), используйте чистый асинхронный Publish или потоки JetStream. Синхронный Request-Reply идеален для низколатентных точечных запросов и команд, но губителен для пакетного ingestion данных.
2. Обработка ошибок
Всегда явным образом обрабатывайте ошибки:
nats.ErrTimeout(таймаут ответа);nats.ErrNoResponders(отсутствие обработчиков);- ошибки маршалинга полезной нагрузки.
Никогда не игнорируйте возвращаемое значение ошибкиnc.Request().
3. Service Discovery
Вместо жестко закодированных или плоских идентификаторов используйте осмысленные иерархические пространства имен субъектов:
// Хорошо: Семантически понятные и масштабируемые subject'ы
"service.users.api.get"
"service.orders.api.create"
// Плохо: Жестко закодированные или бессистемные имена
"get_user_by_id_123"
"service_abc_def_ghi"
Когда использовать Request-Reply
- Synchronous microservice calls — когда вызывающему сервису необходим немедленный ответ для завершения клиентской транзакции.
- Service discovery & Health checks — регулярная проверка жизнеспособности компонентов и опрос конфигурации без выделенного координатора.
- Real-time data fetching — мгновенное извлечение состояния из кэша или распределенного хранилища в памяти.
- Command/query separation (CQRS) — отправка команд на модификацию данных, требующих явного подтверждения или возврата присвоенного UUID.
Итог
- Request-Reply в NATS реализует синхронный межсервисный RPC поверх асинхронной событийно-ориентированной архитектуры.
- Временные обратные адреса (Inboxes) позволяют клиенту получать ответы напрямую без создания постоянных очередей.
- Встроенное мультиплексирование в Go-клиенте исключает нагрузку на брокер при создании сотен тысяч кратковременных запросов.
- Интеграция с Queue Groups дает бесплатную балансировку запросов между репликами микросервисов без Nginx, Envoy и Consul.
В следующей лекции — 5. JetStream. Persistence и stream processing — мы познакомимся с подсистемой хранения NATS JetStream, разобрав гарантии доставки At-least-once, консенсус Raft и стриминг данных.