Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 12 из 25 ~18 мин +20 XP

Ошибки продюсера и ретраи

Kafka producer errors — не аварийный режим, а нормальная жизнь продюсера: брокер мигнул, лидер партиции переехал, сообщение не влезло в лимит. Разберём ретраи kafka в Writer, разбор WriteErrors по позициям, MessageTooLargeError, таймауты и судьбу пачки, которая так и не уехала.

Содержание урока · 10 разделов

Короткий сбой, который проще придумать, чем поймать: брокер перезапустился на триста миллисекунд, в этот момент сервис отправил пачку из пятисот событий. Что стало с пачкой — вопрос, на который у продюсера из четвёртого урока нет готового ответа. Часть сообщений могла уехать до сбоя, часть — ждать своих попыток, часть — не дожить до конца. Хорошая новость: 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 с явными лимитами попыток

GO
writer := 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 живёт на повторах, и искать стоит сеть или брокера, а не вину конкретного сообщения.

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 и по позициям

GO
err := 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 с пачкой из сотни сообщений. Как документация 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.ErrClosedPipeWriteMessages после CloseУпорядочить шатдаун: сначала дозапись, потом Close
context.DeadlineExceededДедлайн контекста истёк посреди отправкиИсход неизвестен: повторить с идемпотентностью
kafka.LeaderNotAvailable и соседиБрокерские коды на переездах лидера и таймаутахWriter ретраит сам; при исчерпании ошибки окажутся в WriteErrors

Соберите обработчик с политикой повторов

Допишите лимит попыток, нижнюю границу бэкоффа и тип ошибки пачки.

writer := kafka.NewWriter(kafka.WriterConfig{ Brokers: []string{"localhost:9092"}, Topic: "orders", : 5, : 200 * time.Millisecond, }) err := writer.WriteMessages(ctx, msgs...) var werr kafka. if errors.As(err, &werr) { for i := range werr { if werr[i] != nil { log.Printf("msg %d: %v", i, werr[i]) } } }

Соберите позиционный разбор WriteErrors

Расставьте строки обработчика: отправка, распознавание WriteErrors, цикл по позициям с пропуском доставленных и логом виновников.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: ошибки и ретраи

Термин

MaxAttempts

нажмите, чтобы перевернуть
Определение

Лимит попыток доставки пачки, дефолт 10. После исчерпания ошибка возвращается вызывающему.

1 / 6

Продюсер теперь умеет проигрывать: политика повторов описана полями конфига, ошибки разобраны по позициям, непосланное имеет план. Дальше по курсу: четвёртый урок про первый 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 или дроп, и почему.

Личные заметкиПусто
Хранятся только в вашем браузере (Kafka · Ошибки продюсера и ретраи)

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

Почему 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, либо осознанный дроп с метрикой.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Ebitengine25 уроков~12 чпрактика25 уроков уже доступны

Ebitengine: 2D-игры на Go

Курс Ebitengine для Go: игровой цикл Update/Draw, спрайты и анимации, ввод, коллизии, тайлмапы, камера, звук, сцены и сборка на платформы. 25 уроков.

  • Понимать игровой цикл Update/Draw и Layout
  • Рисовать спрайты, атласы и примитивы
  • Обрабатывать ввод: клавиатура, мышь, тач
  • Строить движение, коллизии и анимации