Модуль 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) снова всё меняет: параллелить можно только между ключами.

Три уровня масштабирования:

  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
}
  1. Инстансы сервиса: consumer group / competing consumers. Следите, чтобы партиций было >= ожидаемых подов (с запасом на выкатку).
  2. Партиции топика: меняйте до роста или планируйте разрыв порядка по ключам (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

КодекСжатиеСкоростьКогда
none1xмаксимумлатентность дороже сети/диска, крошечные сообщения
gzip~3–4xмедленный CPUэкономия сети важнее CPU, совместимость
snappy~2xбыстрыйкомпромисс «по умолчанию» у многих
zstd~3–5xбыстрыйлучший выбор при наличии поддержки (уровни 1–19)

Замеряйте на своих payload'ах: сжатие на JSON с повторяющимися ключами заметно выше, чем на уже сжатом (protobuf+binary). И помните: сжимается батч — маленькие батчи обнуляют выгоду сжатия.

go
w := &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. Профиль узких мест: где уперлись?

Методика за четыре шага — «сначала измерь, потом крути ручки»:

  1. Разложите латентность по хопам. Продюсер: время publish (app → ack). Брокер: queue depth, write latency, CPU/IO. Консьюмер: время от доставки до ack. Один из трёх отрезов аномально велик — там и копаем.
  2. Брокер: CPU (сжатие, TLS, репликация), диск (fsync, seq write — смотрите не на MB/s, а на flush latency), сетевые retransmits. Kafka: under-replicated partitions; RabbitMQ: memory/disk alarms; NATS: pending bytes стримов.
  3. Сеть: p99 RTT клиент→брокер (ping/tcptraceroute в регионе), throughput канала, ошибки. TLS-handshake-шторм (много коротких соединений) — см. пул соединений (2.1).
  4. Обработка: pprof на консьюмере.
go
import _ "net/http/pprof"

go func() {
	log.Println(http.ListenAndServe("localhost:6060", nil)) // /debug/pprof
}()
bash
go 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. Бенчмарк-стенд: честные цифры для трёх брокеров

Правила честного бенчмарка (иначе — маркетинг):

  1. Одинаковый профиль нагрузки для всех: payload, key-распределение, rps-профиль (ступени, а не «максимум с порога»), число партиций/очередей соизмеримо.
  2. Прогрев (JIT/кэши страниц/Raft-лидеры) и замер после прогрева.
  3. Длительность: минуты, не секунды — ловим не только steady-state, но и деградацию (компакция, ротация сегментов, fsync-пики).
  4. Замеряем end-to-end: публикация → обработка (не «продюсер выстрелил и убежал»). Фиксируем p50/p95/p99, throughput, потери при kill-тестах.
  5. Одна и та же аппаратная база (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
2Prefetch/MaxAckPending настроен по формуле, а не «200 из туториала»1.3/4.2
3Батчинг+сжатие замерены на реальном payload3.2
4Lag, DLQ-rate, p99, in-flight — дашборд + алерты2.6
5Load-тест в CI на ключевых порогах (SLO-профиль)3.5
6Capacity-план: запас 2x по throughput, диск на retention × 23.4
7Graceful shutdown проверен под нагрузкой (не только юнит-тестом)2.7
8Репликация/кворум: отказ одного узла — без потерь и пауз > N сек1.5/1.8/4.5
9Идемпотентность всех консьюмеров подтверждена тестами сбоев1.2 / 1.7
10Аутентификация+TLS+ACL включены, порты не наружу2.x

🧩 Quiz "Проверь себя"

1. Как выбрать число воркеров консьюмера при IO-bound обработке?
2. linger.ms = 20 вместо 0: что происходит с throughput и латентностью?
3. Payload — сжатый protobuf. Выбор кодека?
4. publish-латентность выросла на всех инстансах одновременно, консьюмеры в норме. Первый подозреваемый?
5. Консьюмер lag растёт, брокер здоров, сеть ровная. Где искать?
6. Какие пункты обязательны в продакшен-чек-листе? — выберите все подходящие
7. Worker pool из 32 горутин на одной партиции: что сломали?
8. Consumer group из 12 партиций, запущено 24 инстанса. Что не так и как правильно?
9. В нагрузочном прогоне важнее всего замерить:
10. Почему честный бенчмарк должен длиться минуты, а не секунды?

✅ Итоги модуля

После прохождения ты должен уметь:

  • Считать ёмкость: партиции vs воркеры vs инстансы — и не создавать инстансы, которым не хватит партиций.
  • Параллелить обработку, не ломая порядок по ключу (shard pool).
  • Тюнинговать продюсера: батч, linger, кодек — с замером на своём payload, а не «так в блоге написано».
  • Тюнинговать консьюмера: prefetch/poll/MaxAckPending по формуле и профилю обработки (CPU vs IO).
  • Диагностировать узкое место за 4 шага: хопы → брокер → сеть → pprof.
  • Проводить честный бенчмарк трёх брокеров и оформлять результаты с конфигурацией.
  • Прогнать сервис по чек-листу продакшен-готовности.

Шпаргалка модуля

РучкаКуда крутитьКак проверить
Партиции>= инстансы × запасраспределение lag по инстансам
Prefetch/MaxAckPendingобработка_сек × rate × 1.5skew in-flight между воркерами
Батч/linger10–20 мс для стримовpublish p99 vs throughput
Сжатиеzstd (или none на сжатом)байт/сек и CPU% продюсера
ВоркерыCPU: ~GOMAXPROCS; IO: десятки+pprof + БД-пул stat
Бенчмаркступени rps, минуты, end-to-endp99 + kill-тест

🔗 Мостик к Модулю 4

Всё пройдено: язык распределённых систем, три брокера, альтернативы, паттерны, MVP, безопасность, производительность. Осталась последняя миля — финальная битва: квиз-баттл на 50 вопросов по всем модулям и защита проекта «OrderFlow» с типовыми вопросами «почему выбрал X». Проверим, осталось ли что-то из пробелов.