---
title: "Продюсер: батчи и производительность"
description: "Производительность kafka producer на Go: BatchSize и BatchTimeout в Writer, батчи по партициям, цена Async, RequiredAcks и Stats как приборы тюнинга."
course: "Kafka в Go: продюсеры и консьюмеры на kafka-go"
url: "https://golangmaster.ru/courses/kafka-go/lessons/10-producer-batchi/"
published: "2026-09-19"
updated: "2026-09-29"
minutes: 20
---

# Продюсер: батчи и производительность

*Курс «Kafka в Go: продюсеры и консьюмеры на kafka-go», урок 10 из 25. ~20 минут. Обновлено 29.09.2026. Источник: https://golangmaster.ru/courses/kafka-go/lessons/10-producer-batchi/*

Kafka batch producer копит сообщения по партициям и уезжает одним запросом — главный рычаг производительности kafka go, без которого продюсер с поодиночными отправками не выдержит даже средний поток. Разберём BatchSize, BatchTimeout, Async и RequiredAcks в Writer, посчитаем цену каждой настройки и увидим, где асинхронность молча теряет события.

Нагрузочный тест, который сводит с ума: сервис телеметрии шлёт тысячу метрик в секунду, Writer создан по четвёртому уроку, ошибок нет, а CPU процесса красный и брокер потеет. Разбор показывает тысячу produce-запросов в секунду — по одному на сообщение: каждый WriteMessages с одиночным сообщением едет до брокера и обратно, платя сетевым round-trip и конвейером брокера за каждое событие отдельно. Умножьте на десять сервисов — и половина мощности Kafka уходит на обработку оверхеда. Лечение известно с времён первых брокеров: накапливать сообщения и отправлять пачкой, один запрос на сотню событий. В kafka-go накопление встроено в Writer и управляется тремя полями конфига — BatchSize, BatchTimeout и BatchBytes, — а пятый урок уже показал, что пачка разлетается по партициям через балансировщик. Здесь разберём рычаги по одному: что каждый из них меняет, где пределы и какую цену платит Async — самый быстрый и самый опасный режим продюсера.

### Батчи kafka batch producer: как Writer копит сообщения

Writer в kafka-go не шлёт сообщения поодиночке, даже когда WriteMessages вызывается с одним событием. Внутри каждый Writer держит на каждую топик-партицию аккумулятор: пришедшие сообщения докладываются в текущий батч, полный батч уходит в очередь на отправку, а неполный ждёт — либо новых сообщений, либо таймаута. Пороги задаются в WriterConfig: BatchSize — цель по числу сообщений в батче, по умолчанию 100; BatchBytes — потолок в байтах, дефолт 1048576, то есть мегабайт; BatchTimeout — срок дожития неполного батча, по умолчанию секунда. Аккумуляторы живут между вызовами WriteMessages: пять вызовов по двадцать сообщений в одну партицию соберутся в стобалльный батч. Пороги — не единственные ручки: Writer сам ретраит доставку до десяти попыток по умолчанию, с бэкоффом от ста миллисекунд до секунды, поэтому короткое моргание сети батч переживает без вашего участия. Для kafka batch producer это означает, что оптимум зависит от трафика: на живом потоке батчи наполняются сами, а редкие события ждут таймаута, чтобы не зависнуть в памяти навсегда. Формула простая: пропускная способность растёт с размером батча, задержка растёт с ожиданием его наполнения, и между ними всегда стоит ваш профиль трафика.

### Writer с явными порогами батчей

```go
writer := kafka.NewWriter(kafka.WriterConfig{
        Brokers:      []string{"localhost:9092"},
        Topic:        "metrics",
        Balancer:     &kafka.RoundRobin{},
        BatchSize:    500,             // цель: 500 сообщений в батче
        BatchTimeout: 50 * time.Millisecond, // но не дольше 50 мс
        BatchBytes:   1 << 20,         // потолок батча в байтах
})
defer writer.Close()

msgs := make([]kafka.Message, 0, 500)
for i := 0; i < 500; i++ {
        msgs = append(msgs, kafka.Message{
                Key:   []byte(fmt.Sprintf("metric-%d", i%10)),
                Value: []byte(fmt.Sprintf("value=%d", i)),
        })
}

err := writer.WriteMessages(ctx, msgs...)
log.Printf("flushed: %v", err)
```

Три поля решают всё: BatchSize 500 копит полтысячи сообщений, BatchTimeout 50 миллисекунд не даёт хвосту висеть дольше этого срока, BatchBytes страхует от гигантских батчей. Пачка из 500 сообщений уходит одним вызовом WriteMessages и — при одной партиции — одним produce-запросом. Ключи здесь декоративные: для метрик порядок не нужен, поэтому RoundRobin из пятого урока, а при живом трафике аккумуляторы Writer'а сожмут и эту пачку с соседними.

### BatchSize и BatchTimeout: два рычага одной цены

Рычаги работают в паре, и настраивать их по одному — ошибка. BatchSize задаёт цель: батч уходит, как только набрал это число сообщений. BatchTimeout — предохранитель: неполный батч не живёт дольше срока, поэтому редкий трафик не копится вечно. Поднимите BatchSize до тысячи на потоке из десяти событий в секунду — и почти каждый батч будет уезжать по таймауту, добавляя секунду задержки каждому сообщению без выигрыша в запросах. Опустите до десяти на потоке из десяти тысяч — и Writer износит сеть тысячей запросов, хотя мог бы десятком. Здоровая конфигурация держит два сценария: пиковый трафик наполняет батчи по размеру, ночной провал дожимает их по таймауту. Практический приём — мерить фактический размер батчей, а не догадываться: Stats отдаёт BatchSize и BatchBytes как summary-статистики с минимумом, средним и максимумом, и перекос сразу видно. Дефолты — 100 сообщений и секунда — разумны для сервисных потоков; тюнинг начинается там, где либо задержка критична, либо счёт запросов бьёт по брокеру, и всегда по измерениям, а не по вкусу.

### Проверка: редкий трафик и большой батч

**Вопрос:** BatchSize: 1000, BatchTimeout: time.Second. Трафик — одно сообщение в секунду. Как уйдут сообщения?

- ✅ По одному в секунду: неполный батч дожимает таймаут
- Пачками по 1000: батч дождётся наполнения сколько бы ни ждал
- Мгновенно: каждое сообщение уходит отдельным запросом
- Ни разу: Writer будет копить до перезапуска

BatchTimeout страхует неполные батчи: сообщение ждёт не наполнения до 1000, а истечения секунды, и уезжает один в батче. Полный BatchSize срабатывает только при достаточном трафике — на редком потоке таймаут становится фактическим таймером отправки.

### WriteMessages пачкой: один вызов, много партиций

Второй рычаг производительности — сам вызов: WriteMessages принимает вариадик сообщений, и одна пачка едет эффективнее, чем серия одиночных вызовов. Внутри Writer раскладывает сообщения по партициям через балансировщик, собирает на каждую свой батч и отправляет параллельно. Ошибка возвращается с индексом виновника: MessageError хранит позицию сбойного сообщения в исходном срезе, так что частичная обработка возможна. Особый случай — сообщение, превышающее лимит: если событие больше BatchBytes, WriteMessages возвращает MessageTooLargeError, не отправив ничего, — ожидание поведения такое, что вы отбросите слишком крупное сообщение и повторите отправку остального. Повтор упростит само исключение: ошибка несёт поля Message и Remaining — виновника и пачку без него, — так что пересборка среза не нужна. Пачка заодно экономит на блокировках: один вызов с тысячей сообщений проходит путь планирования один раз, а не тысячу. Отсюда правило сервисного кода: собирайте события в срез и отправляйте одним вызовом за тик — таймер или размер среза, что наступит раньше, — а Writer'овые батчи наложатся сверху и сожмут трафик до брокера.

> **Живое место: Async, который молча терял события.** История из аудита: сервис метрик перевели на Async: true ради CPU, через месяц аналитика заметила дыры в графиках. Код был безупречен по логике — err игнорировать было даже нечего: при Async WriteMessages никогда не блокируется и не возвращает ошибок отправки, документация предупреждает об этом прямо. Ошибки при этом не исчезают: они учитываются в статистике writer.Stats(), поле Errors. Лечение: горутина, раз в секунду читающая Stats и поднимающая счётчик, — а лучше интерфейс с фоновым подтверждением через Completion для критичных потоков. Правило из истории: Async — не бесплатное ускорение, а обмен гарантией на задержку; брать его стоит только там, где потеря события легальна, — метрики, телеметрия, кэш-инвалидация. Для платежей и заказов асинхронный Writer — враньё самому себе: события будут уходить в пустоту ровно в день, когда брокер мигнёт.

### Меню: Async и ловля ошибок через Stats

```go
asyncWriter := kafka.NewWriter(kafka.WriterConfig{
        Brokers: []string{"localhost:9092"},
        Topic:   "metrics",
        Async:   true, // WriteMessages не ждёт и ошибок не возвращает
})

// мгновенный возврат: err здесь всегда nil при Async
err := asyncWriter.WriteMessages(ctx,
        kafka.Message{Value: []byte("tick=1")})
fmt.Println("err:", err)

// ошибки не пропали — они в статистике
go func() {
        for range time.Tick(time.Second) {
                s := asyncWriter.Stats()
                log.Printf("writes=%d errors=%d", s.Writes, s.Errors)
        }
}()
```

Async превращает WriteMessages в постановку в очередь: вызов возвращается мгновенно, ошибки доставки уходят в Stats, поле Errors, а не вызывающему. Горутина с таймером — минимальный мониторинг асинхронного Writer'а: без неё недоставленные записи невидимы. Для критичных потоков этот режим запрещён: нет ни возврата ошибки, ни способа узнать судьбу конкретного сообщения без Completion.

> **Completion: судьба пачки после отправки.** Второй способ узнать судьбу сообщений — поле Completion в WriterConfig: функция вызывается после попытки доставки каждой пачки и получает срез сообщений с ошибкой или без. В синхронном режиме Completion вызывается до возврата WriteMessages, в асинхронном — из фоновых горутин Writer'а, и это единственное место, где асинхронная отправка отчитывается о результате. Сообщения из колбэка приходят с заполненными Topic, Partition и Offset — фактическими адресами записи у брокера, — причём все сообщения одного вызова уехали в одну партицию. Две оговорки из документации: Close блокируется до отработки колбэков, а паника внутри Completion гасит весь процесс — обёртка с recover обязательна.

### Проверка: куда уходят ошибки при Async

**Вопрос:** Async: true, брокер недоступен, WriteMessages вызван 50 раз. Что увидит код?

- ✅ nil из каждого вызова: ошибки копятся в Stats().Errors
- Ошибку из 51-го вызова: Writer переполнится и признает сбой
- Панику Writer: Async не переживает недоступность брокера
- Ошибку из каждого вызова, но с задержкой

Документация Async честна: WriteMessages никогда не блокируется, а ошибки игнорируются, потому что вызывающий не получает возвращаемого значения. Единственное место, где недоставленные записи остаются видимыми, — счётчики writer.Stats().

### Сколько produce-запросов сделает Writer?

```go
writer := kafka.NewWriter(kafka.WriterConfig{
        Brokers:      []string{"localhost:9092"},
        Topic:        "metrics",   // одна партиция
        BatchSize:    100,
        BatchTimeout: time.Second,
})

for i := 0; i < 250; i++ {
        writer.WriteMessages(ctx,
                kafka.Message{Value: []byte("x")})
}
// WriteMessages с Async: false
```

**Что выведет программа?**

- ✅ 3 запроса: два полных батча и хвост по таймауту
- 250 запросов: каждый WriteMessages едет до брокера сам
- 1 запрос: Writer сольёт всё в один батч
- 0 запросов: батчи копятся до Close

Аккумулятор партиции копит сообщения между вызовами: сотня — батч уезжает, ещё сотня — второй, хвост из 50 ждёт BatchTimeout и уходит третьим. Одиночные вызовы WriteMessages не означают одиночные запросы: батчинг встроен в Writer.

### RequiredAcks и цена надёжности на пути батча

Третий рычаг — сколько подтверждений ждёт продюсер. Поле RequiredAcks в WriterConfig задаёт уровень acks: значение минус один — ожидание всех синхронизированных реплик, единица — подтверждение лидера, ноль — fire-and-forget. Важная деталь kafka-go: NewWriter при нулевом значении подставляет RequireAll — историческое поведение конструктора, поэтому дефолтный Writer ждёт весь ISR и является самым надёжным, а не самым быстрым. Константы для поля — RequireNone, RequireOne и RequireAll, они же ноль, единица и минус единица: в конфиге пишите их по именам, чтобы нулевой RequiredAcks не читался как «ноль подтверждений». Снижение до RequireOne уменьшает задержку на пути подтверждения, но ослабляет гарантию: при падении лидера до репликации последние записи теряются — trade-off, который вы уже оценивали в девятом уроке, только теперь со стороны продюсера. Батч усиливает эффект любой настройки: одна секунда ожидания на батч из пятисот сообщений — это два миллисекунды на событие, против секунды на каждое при одиночной отправке. Для потоков, где критична пропускная способность, а не задержка, крупный батч с RequireAll часто быстрее мелкого с RequireOne — мерите и выбираете сами: Stats отдаёт WriteTime и Retries по факту.

### Правила тюнинга батчей одним списком

- Меряйте фактические размеры батчей через Stats: summary с минимумом, средним и максимумом честнее догадок.
- Держите пару размер-таймаут согласованной: цель по наполнению плюс предохранитель по времени.
- На редком трафике таймаут становится фактическим таймером отправки — проверяйте задержку на провалах, а не только на пике.
- Пачку для WriteMessages собирайте на стороне сервиса: срез за тик экономит путь планирования и блокировки.
- Async — только там, где потеря легальна; для остального синхронный Writer с Completion или мониторингом Stats.
- Сообщение крупнее BatchBytes возвращается MessageTooLargeError до отправки: поле Remaining даст пачку без виновника.

### Рычаги производительности Writer одним столом

| Настройка | Дефолт | Что меняет |
| --- | --- | --- |
| BatchSize | 100 | Цель по сообщениям в батче: меньше запросов при большем размере |
| BatchTimeout | 1 секунда | Срок дожития неполного батча: потолок задержки на тихом трафике |
| BatchBytes | 1048576 | Потолок батча в байтах; крупнее — MessageTooLargeError |
| Async | false | Мгновенный возврат без ошибок; судьба сообщений — только в Stats |
| RequiredAcks | RequireAll у NewWriter | Число подтверждений: весь ISR против одного лидера |

### Локальный замер батчей: как увидеть выигрыш

Про формат честно: песочница сеть не поднимает, поэтому замеры делаются локально против брокера из Docker — третий урок ещё пригодится. Против брокера прогоните одинаковый поток метрик через Writer с дефолтами и с вашими порогами и сравните Stats: счётчик Writes покажет число produce-запросов, BatchSize-статистика — фактические размеры батчей, WriteTime — время записи. Разница между тысячей запросов и пятью видна невооружённым глазом и в цифрах, и в нагрузке CPU. Интерактивы ниже тренируют механику накопления на программах в чистом Go: тот же аккумулятор, те же пороги, тот же таймаут — без сети, зато мгновенно и с предсказуемым выводом, который совпадает с поведением брокера на тех же числах.

### Соберите батч-продюсер

Допишите поля конфига, задающие размер и таймаут батча, и метод отправки пачки.

```go
writer := kafka.NewWriter(kafka.WriterConfig{
        Brokers:      []string{"localhost:9092"},
        Topic:        "metrics",
        ___:          500,
        ___:          50 * time.Millisecond,
})

err := writer.___(ctx, msgs...)
log.Printf("flushed: %v", err)
```

Подсказка: Размер батча — BatchSize, срок неполного батча — BatchTimeout, а пачка уезжает через WriteMessages.

Правильные ответы в пропусках: `BatchSize`, `BatchTimeout`, `WriteMessages`

### Соберите асинхронный Writer с мониторингом

Расставьте строки: конфиг с Async, мгновенная отправка, вывод nil-ошибки, горутина со Stats, лог счётчиков.

Правильный порядок строк:

1. `writer := kafka.NewWriter(kafka.WriterConfig{Brokers: []string{"localhost:9092"}, Topic: "metrics", Async: true})`
2. `defer writer.Close()`
3. `err := writer.WriteMessages(ctx, kafka.Message{Value: []byte("tick=1")})`
4. `fmt.Println("err:", err)`
5. `go func() {`
6. `    for range time.Tick(time.Second) {`
7. `        s := writer.Stats()`
8. `        log.Printf("errors=%d", s.Errors)`
9. `    }`
10. `}()`

### Карточки: батчи и производительность

- **BatchSize** — Цель по числу сообщений в батче, дефолт 100. Батч уезжает при наполнении; на редком трафике срабатывает таймаут.
- **BatchTimeout** — Срок дожития неполного батча, дефолт 1 секунда. Предохранитель от вечного ожидания наполнения на тихом потоке.
- **BatchBytes** — Потолок батча в байтах, дефолт 1048576. Сообщение крупнее лимита возвращает MessageTooLargeError без отправки.
- **Async: true** — WriteMessages не блокируется и не возвращает ошибок отправки; судьба сообщений видна только в writer.Stats().Errors.
- **RequiredAcks** — Уровень подтверждений. NewWriter при нуле подставляет RequireAll — ожидание всего ISR, самый надёжный дефолт.
- **writer.Stats()** — Счётчики Writes, Errors, Retries и summary по размерам батчей и времени записи — фактическая картина вместо догадок.

Продюсер теперь масштабируется вместе с потоком: батчи собираются сами, пороги стоят по измерениям, а асинхронность — осознанный выбор с мониторингом ошибок, а не копипаста. Из урока в код уезжают два решения: пороги под измеренный профиль трафика и честный ответ на вопрос, можно ли этому потоку терять события, — первое числом, второе словом Async. Дальше по курсу: [урок про ошибки продюсера](/courses/kafka-go/lessons/12-oshibki-producer/) разберёт, что делать с непосланным — ретраи, таймауты и цену RequiredAcks на падениях; а [четырнадцатый урок про Reader API](/courses/kafka-go/lessons/14-reader-api/) вернёт нас на читательскую сторону, к сырым оффсетам без группы. Для консьюмера той же природы — задержка против пропускной способности — держите в голове MinBytes и MaxBytes из шестого урока: зеркальные рычаги fetch-батчей. Замеряйте свой Writer локально против брокера из Docker — пять минут на прогон Stats заменят вечер споров о настройках.

## Коротко: что запомнить

- Writer копит батчи по партициям между вызовами: пороги — BatchSize, BatchBytes и BatchTimeout.
- Дефолты: 100 сообщений, мегабайт и секунда; тюнинг начинается по измерениям Stats, а не по вкусу.
- Пачка в WriteMessages эффективнее серии одиночных вызовов: путь планирования проходится один раз.
- Неполный батч дожимает таймаут: редкий трафик не висит в памяти и не ждёт наполнения до вечности.
- Async даёт мгновенный возврат без ошибок отправки; единственный источник правды — Stats().Errors.
- NewWriter при RequiredAcks 0 подставляет RequireAll: дефолтный Writer надёжен, а не быстр.
- Completion отчитывается о судьбе каждой пачки: в Async это единственный канал фактических адресов и ошибок доставки.
- Замеры батчей — локально против брокера в Docker; песочница показывает аккумулятор на чистом Go.

## Домашнее задание

Против брокера из третьего урока снимите три профиля одного потока: 1000 метрик Writer'ом с дефолтами, с BatchSize: 1000 и с Async: true. Для каждого запишите из Stats число Writes, средний размер батча и ошибки. Затем уроните брокер docker stop и повторите прогон на Async: nil из вызовов против ненулевого Errors в Stats — зафиксируйте это как обоснование запрета Async для платёжных потоков. Финал: поднимите BatchSize до 10000 на тихом потоке из 5 событий в секунду и объясните по графику WriteTime, почему это стало хуже. И последнее: добавьте Completion-колбэк в асинхронный профиль и убедитесь, что он получает упавшие пачки с err != nil и заполненными адресами партиций.

## Вопросы и ответы по уроку

### Какой BatchSize выбрать: больше всегда лучше?

Нет: размер батча выигрывает только при трафике, который наполняет его до таймаута. Ориентир — фактическая статистика из Stats: средний размер батча должен быть близок к цели на пике, а на провалах дожиматься таймаутом. Начните с дефолта 100, удваивайте, пока число Writes падает заметно, и остановитесь, когда выигрыш исчезнет, а задержка на тихом трафике станет заметной. И не двигайте BatchSize без BatchTimeout: поднятая цель на редком потоке обернётся задержкой в полный интервал.

### Почему при Async WriteMessages возвращает nil на упавшем брокере?

Потому что при Async вызов лишь ставит сообщения в очередь аккумулятора: отправка и подтверждение происходят в фоне, и результат доставки вызывающему не передаётся. Документация предупреждает об этом прямо: ошибки игнорируются, потому что вызывающий не получает возвращаемое значение. Видимая цена — только счётчики Stats, поэтому асинхронный Writer без мониторинга Stats — чёрный ящик.

### Что делать с MessageTooLargeError?

Ошибка означает, что одно сообщение превышает BatchBytes или лимит брокера, и WriteMessages возвращается, не отправив ничего. Проверьте размер сообщения до отправки: либо поднимите лимиты, либо дробите событие, либо переводите крупные payload-ы в ссылки на объектное хранилище. Повторная отправка той же пачки упрётся в ту же ошибку — виновника нужно исключить из пачки. Исключение делает за вас поле Remaining из самой ошибки: отправьте его повторно без пересборки.
