Модуль 3. Масштабирование и производительность: цифры вместо мнений
Цель модуля: научиться масштабировать консьюмеров и тюнинговать продюсеров осознанно — и главное, научиться находить, где уперлась система: в брокера, сеть или обработку. Финал — единый бенчмарк-стенд для трёх брокеров и чек-лист продакшен-готовности.
Перед началом: Модули 1–2 этого курса и брокерские курсы трека (prefetch и батчинг), MaxAckPending (4.2), метрики (6.6). Инструменты: Vegeta/k6, pprof, Grafana, бенчмарк-стенд из Модуля 1. Трудоёмкость: ~9–11 ч (теория 3 ч · практика 6 ч · квиз 1 ч).
📖 3.1. Горизонтальное масштабирование консьюмеров
Партиции vs воркеры: жёсткая арифметика
Kafka-модель: параллелизм consumer group = число партиций. 12 партиций — максимум 12 активных инстансов; 24-й под — мёртвый груз. JetStream-модель: durable pull consumer позволяет воркерам делить сообщения свободнее (в пределах MaxAckPending), RabbitMQ competing consumers — тоже. Но порядок по ключу (6.1) снова всё меняет: параллелить можно только между ключами.
Три уровня масштабирования:
- Воркеры внутри процесса: worker pool на одну партицию/очередь — с шардированием по ключу (иначе сломаем порядок).
go// партиция читается одним фетчером, ключи раздаются фиксированным шардам const shards = 8 type ShardPool struct { chs []chan Message } func NewShardPool(n int, handle func(context.Context, Message) error) *ShardPool { p := &ShardPool{chs: make([]chan Message, n)} for i := range p.chs { p.chs[i] = make(chan Message, 64) go func(ch chan Message) { for m := range ch { // внутри шарда — строго последовательно handle(context.Background(), m) } }(p.chs[i]) } return p } func (p *ShardPool) Submit(m Message) { idx := fnv32a(m.Key) % uint32(len(p.chs)) // ключ → шард: порядок сохранён p.chs[idx] <- m }
- Инстансы сервиса: consumer group / competing consumers. Следите, чтобы партиций было >= ожидаемых подов (с запасом на выкатку).
- Партиции топика: меняйте до роста или планируйте разрыв порядка по ключам (3.2). У RabbitMQ аналогичное решение — шардированные очереди (consistent-hash exchange на N очередей) вместо одной гигантской.
Правило ёмкости: сначала выжмите одно сообщение до предела (9.3), потом размножайте воркеры, потом инстансы, потом партиции. Обратный порядок чаще всего умножает проблемы, а не throughput.
📖 3.2. Тюнинг продюсера: батчи, linger, сжатие
Батчинг и linger
Формула латентности продюсера: latency ≈ linger + сеть + брокер-ack.
Каждая ручка двигает равновесие throughput ↔ латентность:
| Параметр | Больше | Меньше |
|---|---|---|
| linger/batch timeout | крупнее батчи, выше throughput, ниже rps к брокеру, +латентность | латентность ниже, оверхед выше |
| размер батча | лучше сжатие | чаще отправка |
| in-flight запросов | выше параллелизм | риск переупорядочивания без идемпотентности |
Сжатие: gzip vs snappy vs zstd
| Кодек | Сжатие | Скорость | Когда |
|---|---|---|---|
| none | 1x | максимум | латентность дороже сети/диска, крошечные сообщения |
| gzip | ~3–4x | медленный CPU | экономия сети важнее CPU, совместимость |
| snappy | ~2x | быстрый | компромисс «по умолчанию» у многих |
| zstd | ~3–5x | быстрый | лучший выбор при наличии поддержки (уровни 1–19) |
Замеряйте на своих payload'ах: сжатие на JSON с повторяющимися ключами заметно выше, чем на уже сжатом (protobuf+binary). И помните: сжимается батч — маленькие батчи обнуляют выгоду сжатия.
gow := &kafka.Writer{ Addr: kafka.TCP("kafka:9093"), Topic: "orders.events", Balancer: &kafka.Hash{}, RequiredAcks: kafka.RequireAll, BatchSize: 500, BatchTimeout: 20 * time.Millisecond, Compression: kafka.Zstd, Async: false, Completion: func(messages []kafka.Message, err error) { /* метрики */ }, }
Ещё два рычага продюсера: идемпотентность + ретраи (безопасные повторы, 3.4) и «публикация за пределами запроса» — когда HTTP-ответ не обязан ждать confirm, ставьте acks-политику по SLO события, а не по привычке.
📖 3.3. Тюнинг консьюмера: prefetch/poll sizing и параллелизм
RabbitMQ: prefetch (2.3) — старт 10, дальше по формуле
обработка_сек × msgs/sec × 1.5; слишком высокое значение собирает все
сообщения в одного воркера (skew), слишком низкое — роняет throughput
на RTT.
Kafka: размер фетча (MinBytes/MaxBytes) и количество записей за poll —
золотая середина «глубокий фетч, но poll чаще max.poll.interval».
Обработка дольше интервала — асинхроньте или уменьшайте пачку (6.3).
JetStream: MaxAckPending — ваш prefetch; соотнесите с числом горутин
обработки: MaxAckPending >= workers × batch, иначе воркеры будут
голодать.
Параллельная обработка в Go: профиль обработки решает всё —
- CPU-bound → воркеры ≈ GOMAXPROCS, никаких сотен горутин;
- IO-bound (HTTP, БД) → воркеры = десятки–сотни, лимитируйте пул соединений БД (иначе упрётесь в Postgres max_connections);
- смешанный → разделите пайплайн на стадии с отдельными пулами.
📖 3.4. Профиль узких мест: где уперлись?
Методика за четыре шага — «сначала измерь, потом крути ручки»:
- Разложите латентность по хопам. Продюсер: время publish (app → ack). Брокер: queue depth, write latency, CPU/IO. Консьюмер: время от доставки до ack. Один из трёх отрезов аномально велик — там и копаем.
- Брокер: CPU (сжатие, TLS, репликация), диск (fsync, seq write — смотрите не на MB/s, а на flush latency), сетевые retransmits. Kafka: under-replicated partitions; RabbitMQ: memory/disk alarms; NATS: pending bytes стримов.
- Сеть: p99 RTT клиент→брокер (
ping/tcptracerouteв регионе), throughput канала, ошибки. TLS-handshake-шторм (много коротких соединений) — см. пул соединений (2.1). - Обработка: pprof на консьюмере.
goimport _ "net/http/pprof" go func() { log.Println(http.ListenAndServe("localhost:6060", nil)) // /debug/pprof }()
bashgo tool pprof -http=: http://localhost:6060/debug/pprof/profile?seconds=30 # CPU flamegraph; для аллокаций — /debug/pprof/heap
Типовые находки в Go-консьюмерах: JSON-декод на каждое сообщение из
одного и того же payload (кэшируйте схему/декодер), аллокации в hot loop
(переиспользуйте буферы), блокировки на общих мапах (шардите — 9.1),
медленный БД-пул (инспектируйте db.Stat()).
Решающая таблица:
| Симптом | Скорее всего | Первая проверка |
|---|---|---|
| publish-латентность растёт, консьюмеры справляются | брокер/сеть | broker CPU/IO, RTT |
| lag растёт при здоровом брокере | обработка | pprof, БД-пул |
| throughput скачет по инстансам | skew: prefetch/партиции | распределение in-flight |
| p99 у продюсера периодические пики | батч+linger или GC | настройка батча, runtime-метрики |
📖 3.5. Бенчмарк-стенд: честные цифры для трёх брокеров
Правила честного бенчмарка (иначе — маркетинг):
- Одинаковый профиль нагрузки для всех: payload, key-распределение, rps-профиль (ступени, а не «максимум с порога»), число партиций/очередей соизмеримо.
- Прогрев (JIT/кэши страниц/Raft-лидеры) и замер после прогрева.
- Длительность: минуты, не секунды — ловим не только steady-state, но и деградацию (компакция, ротация сегментов, fsync-пики).
- Замеряем end-to-end: публикация → обработка (не «продюсер выстрелил и убежал»). Фиксируем p50/p95/p99, throughput, потери при kill-тестах.
- Одна и та же аппаратная база (docker-compose на одной машине — норм для курса, но не для capacity planning).
bash# ступенчатая нагрузка: 100 → 500 → 1000 rps for rate in 100 500 1000; do echo "POST http://localhost:8080/orders" | \ vegeta attack -duration=120s -rate=${rate}/s -body=order.json > results_${rate}.bin vegeta report results_${rate}.bin done
Оформляйте результаты таблицей + графиком и обязательно прикладывайте конфигурацию (сообщение, брокеры, настройки, железо). Число без контекста — не результат.
📖 3.6. Чек-лист продакшен-готовности
| # | Проверка | Модуль |
|---|---|---|
| 1 | Партиций >= плановых инстансов × запас | 3.1 |
| 2 | Prefetch/MaxAckPending настроен по формуле, а не «200 из туториала» | 1.3/4.2 |
| 3 | Батчинг+сжатие замерены на реальном payload | 3.2 |
| 4 | Lag, DLQ-rate, p99, in-flight — дашборд + алерты | 2.6 |
| 5 | Load-тест в CI на ключевых порогах (SLO-профиль) | 3.5 |
| 6 | Capacity-план: запас 2x по throughput, диск на retention × 2 | 3.4 |
| 7 | Graceful shutdown проверен под нагрузкой (не только юнит-тестом) | 2.7 |
| 8 | Репликация/кворум: отказ одного узла — без потерь и пауз > N сек | 1.5/1.8/4.5 |
| 9 | Идемпотентность всех консьюмеров подтверждена тестами сбоев | 1.2 / 1.7 |
| 10 | Аутентификация+TLS+ACL включены, порты не наружу | 2.x |
🧩 Quiz "Проверь себя"
✅ Итоги модуля
После прохождения ты должен уметь:
- Считать ёмкость: партиции vs воркеры vs инстансы — и не создавать инстансы, которым не хватит партиций.
- Параллелить обработку, не ломая порядок по ключу (shard pool).
- Тюнинговать продюсера: батч, linger, кодек — с замером на своём payload, а не «так в блоге написано».
- Тюнинговать консьюмера: prefetch/poll/MaxAckPending по формуле и профилю обработки (CPU vs IO).
- Диагностировать узкое место за 4 шага: хопы → брокер → сеть → pprof.
- Проводить честный бенчмарк трёх брокеров и оформлять результаты с конфигурацией.
- Прогнать сервис по чек-листу продакшен-готовности.
Шпаргалка модуля
| Ручка | Куда крутить | Как проверить |
|---|---|---|
| Партиции | >= инстансы × запас | распределение lag по инстансам |
| Prefetch/MaxAckPending | обработка_сек × rate × 1.5 | skew in-flight между воркерами |
| Батч/linger | 10–20 мс для стримов | publish p99 vs throughput |
| Сжатие | zstd (или none на сжатом) | байт/сек и CPU% продюсера |
| Воркеры | CPU: ~GOMAXPROCS; IO: десятки+ | pprof + БД-пул stat |
| Бенчмарк | ступени rps, минуты, end-to-end | p99 + kill-тест |
🔗 Мостик к Модулю 4
Всё пройдено: язык распределённых систем, три брокера, альтернативы, паттерны, MVP, безопасность, производительность. Осталась последняя миля — финальная битва: квиз-баттл на 50 вопросов по всем модулям и защита проекта «OrderFlow» с типовыми вопросами «почему выбрал X». Проверим, осталось ли что-то из пробелов.