3. Параллелизм обработки сообщений
«Сделать программу быстрой очень просто: достаточно перестать делать то, что делать не нужно, а то, что делать необходимо, разложить по свободным ядрам процессора — не забывая о законе сохранения порядка.»
Узкое горлышко синхронного консьюмера
В предыдущей статье 2. Консьюмеры и graceful shutdown мы спроектировали надежный консьюмер, способный корректно выдерживать любые сигналы выключения от операционной системы. Однако в его базовой архитектуре кроется фундаментальное ограничение: последовательная (синхронная) обработка.
Представьте типичную производственную ситуацию: брокер (например, кластер Apache Kafka) отдает сообщения со скоростью 10 000 сообщений в секунду. При этом бизнес-обработка одного события (сохранение заказа в PostgreSQL, расчет налогов, поход по HTTP в стороннюю CRM) занимает честные 50 миллисекунд.
Математика безжалостна:
$$\text{Throughput} = \frac{1}{0.050\text{ с}} = 20\text{ msg/s}$$
Один синхронный поток вычитки способен переварить всего 20 сообщений в секунду! Остальные 9 980 сообщений каждую секунду будут накапливаться в партициях брокера, порождая лавинообразно растущее отставание — Lag.
В экосистемах PHP или Python эту проблему решают «в лоб» — запуском сотен независимых процессов операционной системы через Supervisor или масштабированием подов Kubernetes. В языке Go, обладающем уникальным рантаймом с кооперативными горутинами, у инженеров есть гораздо более элегантные и дешевые инструменты конкурентности. Однако именно здесь кроются самые опасные архитектурные грабли.
Fan-Out: Почему не стоит запускать горутину на каждое сообщение?
Первое искушение начинающего Go-разработчика формулируется просто: «Горутины весят всего 2 килобайта! Зачем что-то придумывать? Будем писать go process(msg) на каждое пришедшее сообщение!».
В продакшене этот наивный подход (unbounded go process()) гарантированно приводит к отказу сервиса:
flowchart TD
subgraph AntiPattern["Антипаттерн: Неограниченный Fan-Out"]
direction TB
Broker["Входящий поток брокера<br>(100 000 msg/s)"] --> Loop["Цикл for msg := range"]
Loop --> G1["go process(msg 1)"]
Loop --> G2["go process(msg 2)"]
Loop --> G3["go process(msg 100 000...)"]
G1 & G2 & G3 --> DB_Pool["Пул базы данных pgx / sql.DB<br>(Лимит: 30 соединений)"]
DB_Pool -.->|"Исчерпание пула"| Deadlock["Зависание тысяч горутин"]
Deadlock -.->|"Разрастание стеков"| OOM["Linux OOM Killer (SIGKILL)"]
end
classDef err fill:#1e1e2e,stroke:#f43f5e,stroke-width:2px,color:#f8fafc;
class AntiPattern err;
Worker Pool: Идиоматичный подход
Канонический и безопасный способ параллелизации обработки в Go — паттерн Worker Pool (Пул воркеров фиксированного размера).
Мы инициализируем строго контролируемое количество горутин-обработчиков на этапе старта приложения и распределяем между ними входящие сообщения через буферизованный канал.
flowchart LR
Broker[("Брокер сообщений")] -->|"Вычитка"| Dispatcher["Главный цикл консьюмера"]
Dispatcher -->|"jobs <- msg<br>(Блокировка при переполнении)"| Queue[("Канал jobs<br>(Буфер = workerCount)")]
Queue -->|"msg"| W1["Воркер 1<br>(Горутина 1)"]
Queue -->|"msg"| W2["Воркер 2<br>(Горутина 2)"]
Queue -->|"msg"| WN["Воркер N<br>(Горутина N)"]
W1 --> Ack1["БД / Ack"]
W2 --> Ack2["БД / Ack"]
WN --> AckN["БД / Ack"]
classDef bnd fill:#0f172a,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
classDef wrk fill:#1e293b,stroke:#10b981,stroke-width:2px,color:#f8fafc;
class Dispatcher,Queue bnd;
class W1,W2,WN wrk;
Этот паттерн обеспечивает естественное аппаратное противодавление: если все $N$ воркеров заняты тяжелыми вычислениями, буфер канала заполняется. Операция jobs <- msg в главном цикле блокируется, консьюмер перестает вычитывать новые сообщения из сетевого сокета брокера, брокер притормаживает отправку по TCP, и система работает на пределе своей реальной пропускной способности, не перегружая память.
Реализация Worker Pool
package workerpool
import (
"context"
"log/slog"
"os/signal"
"sync"
"syscall"
"time"
)
type Message interface {
Ack() error
Nack(requeue bool) error
}
type Consumer struct {
brokerMessages <-chan Message
handler func(ctx context.Context, msg Message) error
logger *slog.Logger
}
func (c *Consumer) Run(ctx context.Context, workerCount int) error {
ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer cancel()
// Канал с буфером сглаживает микро-задержки между вычиткой и обработкой
jobs := make(chan Message, workerCount)
var wg sync.WaitGroup
// 1. Старт фиксированного пула воркеров
for i := 0; i < workerCount; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
c.worker(ctx, workerID, jobs)
}(i)
}
c.logger.Info("Worker Pool успешно запущен", "workers", workerCount)
// 2. Главный цикл вычитки (Диспетчер)
for {
select {
case <-ctx.Done():
c.logger.Warn("Остановка: закрываем канал jobs и ожидаем завершения воркеров...")
close(jobs) // Закрытие канала сигнализирует воркерам о выходе из range
wg.Wait() // Ожидание завершения всех активных задач
return nil
case msg, ok := <-c.brokerMessages:
if !ok {
close(jobs)
wg.Wait()
return nil
}
// 3. Отправка в пул. Если воркеры заняты — здесь консьюмер заблокируется!
select {
case jobs <- msg:
case <-ctx.Done():
// Если контекст отменился во время ожидания свободного воркера,
// возвращаем сообщение в брокер, чтобы не потерять его
_ = msg.Nack(true)
}
}
}
}
func (c *Consumer) worker(ctx context.Context, id int, jobs <-chan Message) {
// Цикл range автоматически завершится, когда канал jobs будет закрыт
for msg := range jobs {
msgCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
if err := c.handler(msgCtx, msg); err != nil {
c.logger.Error("Ошибка обработки воркером", "worker_id", id, "err", err)
_ = msg.Nack(false) // Ошибка: отправляем в DLQ или логируем
} else {
_ = msg.Ack()
}
cancel() // Освобождаем ресурсы таймера контекста
}
}
Under the Hood: Планировщик G-M-P и Кэши CPU
Как грамотно рассчитать параметр workerCount? Это фундаментальный вопрос Mechanical Sympathy (сочувствия к физическому процессору).
Поведение консьюмера жестко зависит от типа исполняемой нагрузки:
1. CPU-bound нагрузка (Математика, JSON, Сжатие, Хэширование)
Если задача воркера — распарсить сложный JSON, распаковать GZIP или проверить криптографическую подпись, работа упирается исключительно в арифметико-логические устройства (ALU) процессора.
В этом случае количество воркеров должно быть строго равно runtime.GOMAXPROCS(0) (количеству физических или выделенных контейнеру логических ядер CPU).
2. IO-bound нагрузка (Сеть, Базы данных, Внешние API)
Если воркер большую часть времени ожидает ответа от диска PostgreSQL или внешнего HTTP-сервера, горутина паркуется в runtime.netpoller с нулевым потреблением процессорного времени. В этом сценарии количество воркеров можно безопасно увеличивать до 50, 200 или 500 (главное — чтобы не захлебнулся пул соединений с базой данных).
Проблема порядка (Ordering)
При распараллеливании вычислений мы немедленно сталкиваемся с фундаментальной дилеммой: что происходит с порядком сообщений?
Если брокер передал сообщения A, B и C, относящиеся к одному банковскому счету, в строгой последовательности:
A: Пополнение счета на 10 000 руб.B: Покупка товара на 8 000 руб.C: Снятие остатка 2 000 руб.
Если раскидать сообщения A, B, C по параллельным воркерам классического Worker Pool:
- Воркер 2 обработает сообщение
Bза 5 мс. - Воркер 1 зависнет на сетевом запросе по
Aна 100 мс.
Итог: Сообщение B попытается списаться со счета раньше, чем придет пополнение A! Транзакция упадет с ошибкой «Недостаточно средств», а бизнес-логика окажется сломанной.
Поведение очередей различается:
- В RabbitMQ: Если консьюмер использует
Prefetch > 1, параллельные воркеры неизбежно нарушат порядок фиксации. Если обработкаAзавершится ошибкой и уйдет вNack(requeue=true), брокер вернет его в очередь, но уже позади других сообщений. - В Kafka: Порядок сообщений гарантируется только внутри одной конкретной партиции. Если консьюмер вычитывает партицию батчем и раскидывает ее элементы по воркерам пула — гарантия порядка внутри партиции полностью разрушается.
Как сохранить порядок: Key-based Hash Worker Pool
Если порядок критичен для связанных бизнес-сущностей (пользователь, заказ, счет), классический Worker Pool неприменим. Инженерное решение — Пул воркеров с хэш-маршрутизацией по ключу (Key-based / Hash Worker Pool).
Мы извлекаем ключ партицирования из сообщения (например, UserID или AccountID) и отправляем сообщение строго в тот выделенный канал, за которым закреплен конкретный воркер:
$$\text{workerID} = \text{hash}(\text{key}) \pmod{\text{workerCount}}$$
flowchart LR
subgraph Routing["Детерминированная маршрутизация"]
Msg1["Message: AccountID=123"] --> Hash{"fnv32(key) % N"}
Msg2["Message: AccountID=456"] --> Hash
Msg3["Message: AccountID=123"] --> Hash
end
subgraph Channels["Индивидуальные очереди воркеров"]
Hash -->|"Канал 0"| Ch0[("Channel 0")]
Hash -->|"Канал 1"| Ch1[("Channel 1")]
end
subgraph Workers["Выделенные горутины"]
Ch0 --> W0["Worker 0<br>(Строго последовательно: AccountID=123)"]
Ch1 --> W1["Worker 1<br>(Параллельно: AccountID=456)"]
end
classDef rtt fill:#0f172a,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
classDef chn fill:#1e293b,stroke:#f59e0b,stroke-width:2px,color:#f8fafc;
classDef wrk fill:#1e1e2e,stroke:#10b981,stroke-width:2px,color:#f8fafc;
class Msg1,Msg2,Msg3,Hash rtt;
class Ch0,Ch1 chn;
class W0,W1 wrk;
package keypool
import (
"context"
"hash/fnv"
)
type KeyedMessage interface {
Key() []byte
Body() []byte
}
func fnv32(key []byte) uint32 {
h := fnv.New32a()
_, _ = h.Write(key)
return h.Sum32()
}
func (c *Consumer) RunKeyedPool(ctx context.Context, workerCount int) error {
// Создаем персональный канал для каждого воркера
workerChannels := make([]chan KeyedMessage, workerCount)
for i := 0; i < workerCount; i++ {
workerChannels[i] = make(chan KeyedMessage, 100)
go c.keyedWorker(ctx, i, workerChannels[i])
}
for {
select {
case <-ctx.Done():
for _, ch := range workerChannels {
close(ch)
}
return nil
case msg, ok := <-c.brokerMessages:
if !ok {
return nil
}
// Детерминированная привязка сообщения к воркеру
key := msg.Key()
workerID := fnv32(key) % uint32(workerCount)
select {
case workerChannels[workerID] <- msg:
case <-ctx.Done():
return nil
}
}
}
}
func (c *Consumer) keyedWorker(ctx context.Context, id int, jobs <-chan KeyedMessage) {
for msg := range jobs {
// Все сообщения по AccountID=123 обрабатываются строго последовательно,
// гарантируя корректность баланса и отсутствие race conditions!
c.process(ctx, msg)
}
}
Этот подход воспроизводит архитектурный гений партицирования Apache Kafka прямо в оперативной памяти нашего сервиса: сообщения с одинаковыми ключами (AccountID=123) выстраиваются в строгую очередь в рамках одной горутины, а сообщения с разными ключами (AccountID=456) обрабатываются полностью параллельно на соседних ядрах CPU.
Data Races в обработчиках
Параллельная обработка требует абсолютной изоляции памяти. Если воркеры обращаются к общим изменяемым структурам данных (in-memory кэш, счетчики метрик, сессионные мапы) без синхронизации, программа неизбежно упадет.
Итог
- Неограниченный Fan-Out (
go process()) — архитектурный яд, приводящий к OOM-падениям, исчерпанию пулов баз данных и выбиванию процессора из колеи. - Worker Pool на каналах — канонический способ управления параллелизмом, гарантирующий жесткий контроль нагрузки и естественный механизм противодавления (Backpressure).
- Размер пула рассчитывается исходя из природы нагрузки: строго
GOMAXPROCSдля CPU-bound задач и сотни горутин для IO-bound операций. - Параллельная обработка разрушает очередность доставки. Если порядок транзакций критичен, используйте Key-based Worker Pool с детерминированным хэшированием по ключу бизнес-сущности.
- Любая разделяемая память между воркерами обязана защищаться мьютексами или примитивами
sync.Map.
Распараллелив обработку, мы увеличили пропускную способность системы в сотни раз. Однако в конкурентной среде повторные доставки при сетевых сбоях неизбежны. Как гарантировать, что повторное выполнение операции не спишет деньги дважды? Разберем это в следующей лекции: 4. Idempotent handlers.