22. Pub Sub, Queue, Stream модели
В предыдущей главе мы выяснили, что Event-Driven Architecture совершает революцию в слабой связанности систем. Однако за словом «события» скрываются принципиально разные топологии передачи данных.
Если разработчик считает, что «брокер сообщений — это просто очередь», он рискует заложить под архитектуру мину замедленного действия. Модели Pub/Sub (Издатель/Подписчик), Queue (Очередь сообщений) и Stream (Потоковый распределённый лог) решают диаметрально противоположные инженерные задачи.
Понимание их внутреннего механического устройства определяет всё: гарантирован ли порядок сообщений, что произойдёт при падении сервера, сможет ли система перемотать время назад для исправления бага и сколько тактов процессора потратит Go-рантайм на обработку входящего сетевого сокета.
В этой главе мы разберём три кита асинхронного транспорта, их реализацию в коде на Go, влияние на аллокатор памяти и сборщик мусора, а также сформулируем чёткие критерии выбора между ними.
Три модели: определения и ключевые отличия
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
subgraph PubSub["Pub/Sub"]
PS_Pub["Издатель"]:::accent -->|"OrderCreated"| PS_Broker["Брокер<br>канал/топик"]
PS_Broker -->|"копия"| PS_Sub1["Подписчик A"]
PS_Broker -->|"копия"| PS_Sub2["Подписчик B"]
end
subgraph Queue["Очередь"]
Q_Pub["Издатель"]:::data -->|"OrderCreated"| Q_Broker["Брокер<br>очередь"]
Q_Broker -->|"одному из"| Q_Con1["Консьюмер 1"]
Q_Broker -->|"одному из"| Q_Con2["Консьюмер 2"]
end
subgraph Stream["Поток"]
ST_Pub["Издатель"]:::process -->|"OrderCreated"| ST_Broker["Брокер<br>партиции лога"]
ST_Broker -->|"все события<br>партиция 0"| ST_Con1["Консьюмер группа A"]
ST_Broker -->|"все события<br>партиция 1"| ST_Con2["Консьюмер группа B"]
end
- Pub/Sub (Publish/Subscribe — Издатель/Подписчик):
Модель радиовещания (Broadcast). Издатель отправляет сообщение в именованный канал (subject или topic). Брокер мгновенно копирует это сообщение абсолютно всем активным подписчикам. Сообщение не сохраняется на диск: если подписчик отключился или моргнула сеть, он безвозвратно теряет всё, что прозвучало в эфире во время его отсутствия (at-most-once). - Queue (Очередь конкурирующих потребителей — Competing Consumers):
Модель распределения задач (Work Queue). Издатель кладёт сообщение в очередь. Брокер доставляет каждое сообщение ровно одному воркеру из пула потребителей. Сообщение надёжно удерживается в очереди до тех пор, пока воркер не пришлёт подтверждение успешной обработки (Acknowledgment, ACK). Если воркер падает, брокер возвращает задачу в очередь и перенаправляет её живому соседу (at-least-once). - Stream (Поток — Неизменяемый лог фиксации / Commit Log):
Гибридная архитектура нового поколения. Сообщения не удаляются после вычитки! Они последовательно дописываются в конец неизменяемого дискового файла (Append-Only Log) и хранятся в соответствии с политикой удержания (Retention Policy — например, 7 дней или бесконечно).
Десятки независимых сервисов (Consumer Groups) могут параллельно читать один и тот же поток на разной скорости, каждый независимо перемещая свой указатель смещения (Offset).
Pub/Sub в Go
Классическим эталоном быстрой Pub/Sub шины является NATS (клиент nats.go). Это легковесная нервная система с микросекундными задержками.
Внутри одного процесса Go (например, внутри модульного монолита) архитектуру Pub/Sub легко смоделировать с помощью каналов и мьютексов:
package pubsub
import "sync"
type Event struct {
Type string
Data any
}
// Broker - легковесный внутрипроцессный Pub/Sub на каналах Go
type Broker struct {
mu sync.RWMutex
subs map[string][]chan Event
}
func NewBroker() *Broker {
return &Broker{
subs: make(map[string][]chan Event),
}
}
func (b *Broker) Subscribe(subject string) <-chan Event {
ch := make(chan Event, 64) // буферизированный канал защищает от микроблокировок
b.mu.Lock()
b.subs[subject] = append(b.subs[subject], ch)
b.mu.Unlock()
return ch
}
func (b *Broker) Publish(subject string, ev Event) {
b.mu.RLock()
defer b.mu.RUnlock()
for _, ch := range b.subs[subject] {
select {
case ch <- ev:
default:
// non-blocking — если подписчик завис, мы сбрасываем сообщение,
// чтобы медленный клиент не парализовал работу всего издателя!
}
}
}
Где применять Pub/Sub:
- Мгновенная рассылка уведомлений в реальном времени (WebSockets, серверные уведомления чатов).
- Внутрипроцессная диспетчеризация событий в модульном монолите.
- Высокоскоростная телеметрия, где потеря 0.01% пакетов менее критична, чем задержка.
Queue в Go
Модель очередей создана для распределения вычислительной работы. Её классический промышленный представитель — RabbitMQ (библиотека streadway/amqp).
Консьюмеры подключаются к очереди, конкурируют за получение сообщений, выполняют бизнес-логику и отправляют подтверждение msg.Ack(false).
package queue
import (
"context"
"log"
"github.com/streadway/amqp"
)
// startWorkers поднимает фиксированный пул конкурирующих потребителей очереди
func startWorkers(ctx context.Context, channel *amqp.Channel, queueName string, workers int, handler func(msg amqp.Delivery)) {
for i := 0; i < workers; i++ {
go func(workerID int) {
// Режим manual ack (autoAck = false)
deliveries, err := channel.Consume(queueName, "", false, false, false, false, nil)
if err != nil {
log.Fatalf("failed to register consumer: %v", err)
}
for {
select {
case <-ctx.Done():
return
case msg, ok := <-deliveries:
if !ok {
return
}
// Выполняем бизнес-логику задачи
handler(msg)
// Подтверждаем удаление задачи из очереди
_ = msg.Ack(false)
}
}
}(i)
}
}
Где применять Очереди:
- Асинхронные задачи фоновой обработки (генерация PDF-выписок, отправка SMS, конвертация видео).
- Обработка команд в архитектурах с CQRS.
- Системы, где каждая задача обязана быть выполнена строго одним воркером.
Stream в Go
Stream — это архитектурная революция последних 15 лет, эталоном которой стали Apache Kafka и современные высокопроизводительные движки вроде Redpanda.
Вместо удаления сообщений после обработки стрим сохраняет их на диске в виде непрерывного упорядоченного лога (Commit Log). Поток разбивается на независимые партиции (Partitions), внутри которых гарантируется абсолютный хронологический порядок:
Лог партиции 0 (на диске):
[Offset 0] -> [Offset 1] -> [Offset 2] -> [Offset 3] -> [Offset 4] (дописываются новые события)
↑ ↑
(Консьюмер аналитики) (Консьюмер биллинга)
В Go для взаимодействия с Kafka используют библиотеки segmentio/kafka-go или confluent-kafka-go:
package stream
import (
"context"
"log"
"github.com/segmentio/kafka-go"
)
// consumePartition читает упорядоченный лог партиции с контролем смещений
func consumePartition(ctx context.Context, reader *kafka.Reader, handler func(msg kafka.Message) error) {
for {
msg, err := reader.ReadMessage(ctx)
if err != nil {
log.Printf("read stream error: %v", err)
break
}
// Выполняем обработку события
if err := handler(msg); err != nil {
log.Printf("processing failed: %v", err)
// При ошибке мы НЕ коммитим offset!
// Мы можем повторить обработку или направить в DLQ.
continue
}
// Атомарно фиксируем прогресс чтения
if err := reader.CommitMessages(ctx, msg); err != nil {
log.Printf("commit failed: %v", err)
}
}
}
Где применять Стримы:
- Системы Event Sourcing (24. Event Sourcing. Хранение событий вместо состояния), где лог событий является единственным источником правды.
- Потоковая аналитика, телеметрия, сбор кликстрима.
- Сценарии, где нескольким независимым сервисам требуется читать один и тот же исторический поток событий на разной скорости с возможностью перемотки назад (Replayability).
Сравнительный анализ
| Характеристика | Pub/Sub | Queue | Stream |
|---|---|---|---|
| Модель адресации | Broadcast: всем активным подписчикам | Competing Consumers: ровно одному воркеру | Всем Consumer Groups независимо |
| Жизненный цикл сообщения | Удаляется мгновенно после передачи | Удаляется после подтверждения (Ack) |
Хранится на диске по таймеру (Retention) |
| Гарантия порядка | Не гарантирован | Обычно FIFO в очереди | Строго гарантирован внутри партиции |
| Перечитывание (Replay) | Невозможно | Невозможно (сообщение стёрто) | Возможно с любого произвольного Offset |
| Семантика доставки | At-most-once | At-least-once (с повторами при сбоях) | At-least-once / Exactly-once |
| Популярные решения в Go | NATS (nats.go), каналы Go |
RabbitMQ (streadway/amqp), AWS SQS |
Apache Kafka, Redpanda (segmentio/kafka-go) |
| Давление на память и GC | Минимальное: сообщения не оседают | Среднее: буферы воркеров | Высокое при пакетном чтении сотен тысяч записей |
Mechanical Sympathy: Go и модели сообщений
Как выбор модели сказывается на железе и рантайме Go?
- Аллокатор памяти и профилирование
pprof:- В Pub/Sub сообщения живут в памяти доли миллисекунды и утилизируются в молодом поколении кучи.
- В Queue сообщение находится в памяти, пока воркер выполняет работу (увеличивая Working Set процесса).
- В Stream консьюмер вычитывает пакеты (batches) из тысяч сообщений за раз. Это приводит к выделению крупных массивов байтов. Профилирование через
pprof(heap profile) позволяет выявить утечки и настроить размер внутренних буферов драйвера.
- Параллелизм горутин:
- В Queue вы можете масштабировать пул воркеров до 500 горутин, и они будут разбирать одну очередь.
- В Stream степень параллелизма ограничена количеством партиций топика. Две горутины из одной consumer group не могут параллельно читать одну и ту же партицию (иначе нарушится строгий порядок сообщений). Если у вас в топике 10 партиций, 11-й воркер будет простаивать!
- Сетевой ввод-вывод:
Все современные клиенты Go используют системный вызовepollчерез netpoller. Чтение сообщений пачками (ReadBatch) резко снижает количество системных вызововread()и прерываний ядра Linux по сравнению с поштучным чтением.
Выбор модели в архитектуре
На практике зрелые распределённые платформы гармонично сочетают все три подхода:
[Внутренний процесс Go] ──(Pub/Sub на каналах)──> Уведомление UI через WebSocket
[Сервис заказов] ──(Stream в Kafka)─────> Долговечный лог OrderCreated для аналитики и аудита
[Фоновый сервис] ──(Queue в RabbitMQ)───> Задача генерации закрывающего PDF-акта
- Нужна максимальная скорость без гарантий и хранения? — Pub/Sub.
- Нужно равномерно распределить тяжелые независимые задачи между воркерами? — Queue.
- Нужен строгий порядок, история изменений и возможность перемотать время назад? — Stream.
Итог
Pub/Sub, Queue и Stream — это три фундаментальные топологии асинхронного мира. Понимание их сильных сторон и ограничений позволяет инженеру проектировать системы, которые не теряют данные при отказах оборудования и выдерживают миллионные нагрузки.
Освоив механику потоков и очередей, мы переходим к фундаментальному паттерну распределённых систем, который разделяет чтение и запись данных на уровне архитектуры: 23. CQRS. Разделение чтения и записи.