Содержание урока · 10 разделов
- Ретраи kafka в Writer: MaxAttempts и бэкофф
- WriteErrors: по одной ошибке на каждое сообщение
- MessageTooLargeError и таймауты пути
- Что делать с непосланным: буфер, outbox, метрика
- Правила обработки ошибок продюсера одним списком
- Ретраи и дубликаты: цена повторов на записи
- Ошибки продюсера одним столом
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Короткий сбой, который проще придумать, чем поймать: брокер перезапустился на триста миллисекунд, в этот момент сервис отправил пачку из пятисот событий. Что стало с пачкой — вопрос, на который у продюсера из четвёртого урока нет готового ответа. Часть сообщений могла уехать до сбоя, часть — ждать своих попыток, часть — не дожить до конца. Хорошая новость: Writer в kafka-go ретраит доставку сам, без вашего участия, и короткое моргание сети чаще всего проходит незамеченным. Плохая новость: когда попытки исчерпаны, ошибки возвращаются вызывающему — и структура у них неочевидная: не одна ошибка на вызов, а по одной на каждое сообщение пачки. Обработать их наобум — значит либо потерять события, либо создать дубликаты. В этом уроке разберём все источники ошибок продюсера по очереди: что Writer ретраит сам и сколько раз, как разобрать WriteErrors по позициям, что делать с сообщением, не влезшим в лимит, и как распоряжаться пачкой, судьба которой неизвестна.
Ретраи kafka в Writer: MaxAttempts и бэкофф#
Writer не отдаёт первую же ошибку наружу: каждая пачка получает до десяти попыток доставки — это дефолт поля MaxAttempts в WriterConfig, и документация формулирует его прямо: «try at most 10 times». Между попытками Writer ждёт бэкофф: WriteBackoffMin задаёт нижнюю границу, по умолчанию сто миллисекунд, WriteBackoffMax — верхнюю, по умолчанию секунду. Ретраятся временные неприятности: моргание сети, перееезд лидера партиции, таймаут запроса — брокерские коды вроде LeaderNotAvailable и RequestTimedOut клиент классифицирует как подлежащие повтору. Если же попытки исчерпаны, Writer сдаётся по-настоящему: ошибка возвращается из WriteMessages, и дальше решаете вы. Каждая фактическая попытка повторного отправления видна в статистике: writer.Stats() отдаёт счётчик Retries — удобный прибор, чтобы отличить тихий здоровый поток от потока, живущего на ретраях. Практическое следствие: поднимать MaxAttempts стоит вместе с пониманием, что каждая попытка — это время, которое пачка висит в памяти; опускать — вместе с буфером недоставленного из конца урока. Важно про время: бэкофф растёт от WriteBackoffMin к WriteBackoffMax, поэтому худший случай пачки — сумма попыток с сетевыми таймаутами по десять секунд на операцию; поднятый MaxAttempts растягивает и этот потолок. Синхронный код, который не готов ждать минуту на упавшем брокере, ограничивает ожидание дедлайном контекста — но тогда вступает в силу правило недетерминированного исхода из коллаута ниже. Для ретраи kafka и скорость, и живучесть — не константы, а ручки под профиль трафика.
Writer с явными лимитами попыток
GOwriter := kafka.NewWriter(kafka.WriterConfig{
Brokers: []string{"localhost:9092"},
Topic: "orders",
MaxAttempts: 5,
WriteBackoffMin: 200 * time.Millisecond,
WriteBackoffMax: 2 * time.Second,
})
defer writer.Close()
err := writer.WriteMessages(ctx, msgs...)
log.Printf("written=%d err=%v retries=%d", len(msgs), err, writer.Stats().Retries)Три поля делают политику повторов явной: MaxAttempts 5 ограничивает число попыток пачки, WriteBackoffMin и WriteBackoffMax задают коридор ожидания между ними — от двухсот миллисекунд до двух секунд. После отправки в лог уезжает счётчик Retries из Stats: если он стабильно растёт, Writer живёт на повторах, и искать стоит сеть или брокера, а не вину конкретного сообщения.
История из ревью: обработчик заказов получал от WriteMessages ошибку и повторно отправлял всю пачку целиком — логично же, раз ошибка. Через месяц аналитика поймала дубликаты заказов с одним и тем же идентификатором. Разбор показал: WriteErrors содержал ненулевые ошибки на трёх позициях из пятисот, остальные четыреста девяносто семь сообщений доставились с первой попытки — а ретрай всей пачки продублировал их. Лечение — цикл по позициям: nil-элемент означает доставленное сообщение, и его трогать нельзя. Второй урок из этой истории: обрабатывать WriteErrors как единый blob так же опасно, как игнорировать их вовсе — цена ошибки проявляется не в логе, а в данных.
WriteErrors: по одной ошибке на каждое сообщение#
Синхронный WriteMessages при частичной неудаче возвращает kafka.WriteErrors — срез ошибок длиной в пачку. Позиция в срезе совпадает с позицией сообщения в исходном вызове: err[2] отвечает за msgs[2]. Nil-элемент — сообщение доставлено; ненулевой — не доставлено после всех попыток, и тип ошибки внутри — тот самый брокерский код, переведённый в Go-ошибку. В пакете kafka есть именованные коды из протокола: LeaderNotAvailable и NotLeaderForPartition на переездах лидера, UnknownTopicOrPartition на опечатках в имени топика, RequestTimedOut на просроченных запросах. Сравнение делается через errors.Is: kafka.Error — целочисленный тип, поэтому ошибки сопоставляются по значению без преобразований. Три сценария стоит различать заранее. Ошибка целиком, не WriteErrors, — значит, вызов сломался до раздачи по партициям: сеть, конфиг, слишком большое сообщение. WriteErrors со всеми ненулевыми — пачка не доехала, повторяйте всё. WriteErrors с точечными nil — повторяйте только виновников. Именно эта развилка отличает код, которому можно доверять поток платежей, от кода, который молча множит сущности.
Проверка: nil внутри WriteErrors
WriteMessages вернул kafka.WriteErrors на пачку из трёх сообщений, при этом err[1] == nil. Что это значит?
MessageTooLargeError и таймауты пути#
Отдельная ошибка на этапе планирования — kafka.MessageTooLargeError: сообщение крупнее BatchBytes или лимита брокера. Важно, что она возвращается до отправки: Writer проверяет размеры до раздачи по партициям, поэтому не уезжает ничего, даже маленькие соседние сообщения. У ошибки два полезных поля: Message — сам виновник, Remaining — пачка без него, готовая к повторной отправке без пересборки среза. Историческое примечание из исходников честно называет это поведение неудобным, но обратная совместимость важнее: ожидание такое, что программа отбросит слишком крупное сообщение и повторит остальное. Второй слой ограничений — время: у Writer есть ReadTimeout и WriteTimeout, оба по умолчанию десять секунд, на операции с брокером. Внешний бюджет задаёт контекст: дедлайн из context.WithTimeout прерывает WriteMessages в любой момент, и исход пачки в этом случае неопределён — документация предписывает считать всю пачку недоставленной и повторить позже, что при повторе может дать дубликаты. Таймауты и лимиты — не раздражители, а контуры защиты: их стоит выставить до продакшена, а не после первого инцидента с гигабайтным сообщением.
Разбор ошибок: too large и по позициям
GOerr := writer.WriteMessages(ctx, msgs...)
if err != nil {
var tooLarge kafka.MessageTooLargeError
if errors.As(err, &tooLarge) {
log.Printf("dropping offender: %d bytes", len(tooLarge.Message.Value))
err = writer.WriteMessages(ctx, tooLarge.Remaining...)
}
}
if err != nil {
var werr kafka.WriteErrors
if errors.As(err, &werr) {
for i := range werr {
if werr[i] != nil {
log.Printf("msg %d: %v", i, werr[i])
}
}
}
}Две ветки закрывают два типа отказов. errors.As распознаёт MessageTooLargeError: виновник логируется и отбрасывается, Remaining отправляется повторно — пачка без пересборки. Вторая ветка разбирает WriteErrors по позициям: nil пропускается, ненулевые попадают в лог с индексом сообщения. Такой обработчик не теряет и не дублирует события — редкое свойство для кода на проде.
WriteMessages после Close возвращает io.ErrClosedPipe — Writer закрыт и новые пачки не принимает. Звучит безобидно, пока шатдаун не перемешает порядок: горутина ещё дописывает хвост дня, а deferred Close уже сработал — и хвост дня оказывается в логах вместо топика. Порядок завершения строгий: сначала отменяем контекст и дожидаемся финальных отправок, потом Close. С отменой контекста связана вторая тонкость из документации: прерванная посреди пути пачка считается недоставленной целиком, но отдельные сообщения могут доехать и после возврата — повтор такой пачки даёт дубликаты, о чём девятый урок говорил со стороны консьюмера. Кафка не даёт транзакционности на этом уровне: решать судьбу непосланного — работа вашего кода.
Проверка: контекст отменён посреди отправки
Контекст отменён посреди WriteMessages с пачкой из сотни сообщений. Как документация kafka-go советует относиться к пачке?
Что вернёт WriteMessages на этой пачке?
Прочитайте код и выберите, что он выведет:
writer := kafka.NewWriter(kafka.WriterConfig{
Brokers: []string{"localhost:9092"},
Topic: "orders",
BatchBytes: 1000, // 1 КБ
})
msgs := []kafka.Message{
{Value: []byte(strings.Repeat("a", 500))},
{Value: []byte(strings.Repeat("b", 2000))},
}
err := writer.WriteMessages(ctx, msgs...)Что делать с непосланным: буфер, outbox, метрика#
Когда все попытки исчерпаны, у пачки должны появиться хозяин и план — иначе событие исчезнет в логе. Вариантов три, и они не исключают друг друга. Первый — буфер в памяти: недоставленные сообщения докладываются в локальную очередь и повторяются с паузой; дёшево, но буфер живёт ровно столько, сколько живёт процесс, и при рестарте его содержимое пропадает. Второй — транзакционный outbox: событие записывается в таблицу той же транзакцией, что и бизнес-данные, а отдельный воркер доставляет его в Kafka и помечает доставленным; дороже, но переживает что угодно, и это стандарт для платежей. Внутри outbox живёт простая дисциплина: события читаются из таблицы в порядке записи, а ключ идемпотентности страхует от повторов — идеально ровно один раз не обещает никто, но двойная запись платежа становится невозможной. Третий — осознанный дроп с метрикой: для телеметрии потеря легальна, важно лишь, чтобы счётчик недоставленных был виден на дашборде, а не в логе. Выбор делает не вкусовщина, а цена события: заказ или платёж едут через outbox, метрика — через дроп. Любому из путей нужен ключ идемпотентности в Headers: повторы на стороне продюсера, как и ретраи kafka на стороне брокера, не дают ровно один раз — дают минимум один раз.
Правила обработки ошибок продюсера одним списком#
- Разбирайте WriteErrors по позициям: nil-элемент — доставленное сообщение, его повторять нельзя.
- MessageTooLargeError приходит до отправки: Remaining содержит пачку без виновника.
- Попытки доставки ограничивает MaxAttempts, дефолт 10; бэкофф от WriteBackoffMin до WriteBackoffMax.
- После Close любой WriteMessages возвращает io.ErrClosedPipe — закрывайте Writer последним шагом шатдауна.
- Отмена контекста: исход пачки неизвестен, повтор — с расчётом на дубликаты.
- Непосланное — всегда решение: буфер в памяти, транзакционный outbox или дроп с метрикой, но не молчание.
Ретраи и дубликаты: цена повторов на записи#
Ретраи kafka на стороне продюсера симметричны ретраям на стороне консьюмера из девятого урока и имеют ту же цену: повтор после неизвестного исхода может создать дубликат. kafka-go не поддерживает идемпотентный режим продюсера с порядковыми номерами, как «большие» клиенты, — значит, страховка от дублей живёт в вашем коде: ключ идемпотентности в Headers, дедупликация на потребителе или upsert на приёмнике. Связь с десятым уроком прямая: при Async: true ни WriteErrors, ни MessageTooLargeError вы не увидите вовсе — ошибки оседают в Stats, а о судьбе пачки отчитывается только Completion. Поэтому первые вопросы продюсеру, который начал терять события: какой режим и где счётчик Errors. Проверить всё это против живого брокера легко локально: docker stop на брокер из третьего урока — и WriteErrors с Retries в Stats покажут больше, чем час чтения документации. В песочнице сети нет — она имитирует только чистую логику на программах в Go ниже.
Ошибки продюсера одним столом#
| Ошибка | Когда возникает | Что делать |
|---|---|---|
| kafka.WriteErrors | Пачка ушла, часть сообщений не принята после всех попыток | Цикл по позициям: nil — доставлено, остальное — повтор или дроп |
| kafka.MessageTooLargeError | Сообщение больше BatchBytes; до отправки не уехало ничего | Исключить виновника, отправить Remaining |
| io.ErrClosedPipe | WriteMessages после Close | Упорядочить шатдаун: сначала дозапись, потом Close |
| context.DeadlineExceeded | Дедлайн контекста истёк посреди отправки | Исход неизвестен: повторить с идемпотентностью |
| kafka.LeaderNotAvailable и соседи | Брокерские коды на переездах лидера и таймаутах | Writer ретраит сам; при исчерпании ошибки окажутся в WriteErrors |
Соберите обработчик с политикой повторов
Допишите лимит попыток, нижнюю границу бэкоффа и тип ошибки пачки.
Соберите позиционный разбор WriteErrors
Расставьте строки обработчика: отправка, распознавание WriteErrors, цикл по позициям с пропуском доставленных и логом виновников.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: ошибки и ретраи
Продюсер теперь умеет проигрывать: политика повторов описана полями конфига, ошибки разобраны по позициям, непосланное имеет план. Дальше по курсу: четвёртый урок про первый Writer покажет фон, из которого вырос этот обработчик; десятый урок про батчи объяснит, как Async меняет путь ошибок; пятнадцатый урок про durability свяжет RequiredAcks с тем, сколько брокеров должны подтвердить запись до возврата ошибки; а девятнадцатый урок про ошибки консьюмера покажет зеркальную сторону — что делать с сообщениями, которые не читаются. Код урока запускается локально против брокера в Docker: уроните его и посмотрите, как ошибки становятся данными.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Writer ретраит доставку сам: до MaxAttempts попыток с бэкоффом от 100 миллисекунд до секунды.
- WriteErrors возвращает по одной ошибке на позицию пачки: nil-элемент — доставленное сообщение.
- MessageTooLargeError приходит до отправки: поля Message и Remaining позволяют продолжить без пересборки.
- Таймауты пути — ReadTimeout и WriteTimeout по 10 секунд, внешний бюджет — дедлайн контекста.
- После Close WriteMessages возвращает io.ErrClosedPipe: закрытие — последний шаг шатдауна.
- Отмена контекста: исход пачки неизвестен, повтор возможен — значит, возможны и дубликаты.
- Непосланное — всегда решение: буфер, транзакционный outbox или дроп с метрикой, но не молчание.
Домашнее задание
Против брокера из третьего урока отправьте пачку из ста событий, затем docker stop на брокер и повторную пачку: разберите WriteErrors по позициям и запишите, сколько сообщений доехало до остановки. Выставьте MaxAttempts: 2 и WriteBackoffMin: 50 миллисекунд, сравните время возврата ошибки с дефолтами. Добавьте обработку MessageTooLargeError с отправкой Remaining и подтвердите логом, что хвост доехал. Финал: заведите счётчик недоставленных сообщений и напишите для каждого топика проекта одно предложение — буфер, outbox или дроп, и почему.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему Writer молча переживает короткий сбой брокера?
Потому что ретраи встроены: Writer повторяет доставку до MaxAttempts попыток с бэкоффом от WriteBackoffMin до WriteBackoffMax, и моргание в несколько сотен миллисекунд перекрывается без вашего участия. Признак этой работы — счётчик Retries в writer.Stats(). Если же попытки исчерпаны, ошибка возвращается из WriteMessages и дальше её разбираете вы.
Что вернёт WriteMessages при Async: true?
Ошибок отправки вызывающий не увидит вовсе: асинхронный Writer не возвращает их из WriteMessages, они оседают в Stats().Errors, а о судьбе конкретной пачки отчитывается только Completion из десятого урока. Косвенный признак потери — счётчик Errors в Stats: если вы ловите потерю постфактум по аналитике, а не по Completion, ваш мониторинг опаздывает на очередь. Обработка WriteErrors — прерогатива синхронного режима: для критичных потоков Async не берут, и этот урок — ещё один аргумент почему.
Как понять, какую ошибку из WriteErrors стоит повторять?
Брокерские коды делятся на временные и конфигурационные: LeaderNotAvailable или RequestTimedOut — временные, UnknownTopicOrPartition — ошибка настройки, повторять бессмысленно. Отдельный случай — NotEnoughReplicas: по классификации протокола он временный, но говорит о сужении ISR ниже минимума, то есть о болезни кластера, а не сети; его рост — повод смотреть нодам и репликам, о чём говорит пятнадцатый урок. Практическая развилка проще: Writer уже отретраил временные до MaxAttempts, значит, всё, что доехало до вас в WriteErrors, автоматикой не лечится. Дальше — пауза с повтором через буфер или outbox, либо осознанный дроп с метрикой.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Ebitengine: 2D-игры на Go
Курс Ebitengine для Go: игровой цикл Update/Draw, спрайты и анимации, ввод, коллизии, тайлмапы, камера, звук, сцены и сборка на платформы. 25 уроков.
- Понимать игровой цикл Update/Draw и Layout
- Рисовать спрайты, атласы и примитивы
- Обрабатывать ввод: клавиатура, мышь, тач
- Строить движение, коллизии и анимации