Содержание урока · 10 разделов
- Гарантии доставки kafka: формула из двух шагов
- At-most-once: когда терять — легально
- At-least-once: обработка до коммита и дубликаты
- Exactly-once: почему его нет и что делают вместо
- Как выбрать гарантию: цена ошибки против цены дубля
- Гарантии доставки одним столом
- Чек-лист выбора гарантии
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Две истории из одного сервиса, поставленные рядом, объясняют тему быстрее теории. История первая: дежурный поднял упавший процесс рассылок, и клиенты получили по два письма — обработанные перед падением события доехали повторно. История вторая: перезапустился процесс уведомлений, и часть клиентов писем не получила — оффсеты уехали вперёд необработанных событий. Один и тот же брокер, один и тот же клиент kafka-go, противоположные исходы: различие только в порядке коммита и обработки, который авторы кода не выбирали явно — он получился сам, из копипасты. Kafka не ставит гарантию доставки в конфиг: она складывается из вашей расстановки FetchMessage, CommitMessages и обработчика. Восьмой урок разобрал механику коммитов; здесь из неё собираются два осознанных контракта — что делать с событием, если процесс умрёт в произвольный момент. Выбор между ними — бизнес-решение: посчитайте цену пропавшего платежа и цену двойного письма, и расстановка шагов станет очевидной.
Гарантии доставки kafka: формула из двух шагов#
У консьюмера есть два шага на каждое событие: подтвердить позицию — CommitMessages — и выполнить работу — обработчик. Порядок шагов и есть гарантия. Коммит до обработки даёт at-most-once: событие считается доставленным в момент подтверждения, и падение после коммита теряет его навсегда, зато дубликаты невозможны. Обработка до коммита даёт at-least-once: событие считается доставленным после успешной работы, падение возвращает его к следующему запуску, зато окно между работой и подтверждением оставляет шанс дубликата. Названия честные: «не более одного раза» и «не менее одного раза» — прочие обещания брокер не даёт. Из формулы следуют и привычные паттерны: at least once kafka требует идемпотентности обработчика — умения пережить повтор, at-most-once требует готовности терять. Нет варианта без цены: окно между шагами нельзя закрыть, его можно только сместить — к потерям или к дубликатам. Проверьте свой текущий код: где между FetchMessage и CommitMessages стоит обработчик — та гарантия у вас и работает, независимо от того, задумывали ли вы её.
At-most-once: коммит до обработки
GOfor {
m, err := reader.FetchMessage(ctx)
if err != nil {
log.Printf("stop: %v", err)
break
}
// позиция подтверждается СРАЗУ: после этой строки
// рестарт продолжит со следующего события
if err := reader.CommitMessages(ctx, m); err != nil {
log.Printf("commit: %v", err)
break
}
process(m) // падение здесь = событие потеряно навсегда
}Три шага в жёстком порядке: fetch, коммит, обработка. Ошибка коммита останавливает цикл — незакрытая позиция вернёт событие после рестарта, и потери не будет. Зато сбой в process после успешного коммита теряет сообщение: группа уже двинулась дальше, и брокер не повторит доставку. Контракт совпадает с ReadMessage из восьмого урока, только теперь ошибка коммита и решение о ней — в ваших руках, а не внутри вызова.
At-most-once: когда терять — легально#
Контракт «не более одного раза» уместен там, где событие дёшево, а повтор вреден. Телеметрия и метрики: потеря точки на графике не замечается, а двойной инкремент искажает статистику. Логи и нотификации с дедупликацией на стороне приёмника. Потоки, где событие — сигнал, а не факт: «пересчитай кэш» дважды — то же самое, что один раз. И особый случай — потоки, где дубликат дороже потери не потому, что данные дешёвые, а потому, что приёмник сам идемпотентен: событие несёт полный снимок состояния, и повторная обработка записывает то же значение. Заметьте, что ReadMessage с группой из восьмого урока — это уже at-most-once: самокоммит внутри вызова подтверждает позицию до вашей обработки, о чём документация предупреждает прямо. Для быстрых и безопасных обработчиков это компактная реализация контракта без лишних строк. Опасность не в API, а в недоговорённости: когда автор кода не формулировал гарантию, at-most-once оказывается на платежах — и потери всплывают в дежурном чате. Граница легко проверяется вопросом к владельцу потока: что произойдёт, если это событие никогда не случится? Если ответ «ничего страшного» — контракт ваш.
Проверка: цена порядка шагов
Код: FetchMessage, затем CommitMessages, затем обработка. Процесс убит посреди обработки первого события. Что произойдёт?
At-least-once: обработка до коммита и дубликаты#
Поменяйте два вызова местами — и контракт перевернётся. Fetch, обработка, коммит: подтверждение фиксирует только сделанную работу, падение возвращает события к следующему запуску. Цена — окно дубликатов: процесс, умерший между успешной обработкой и коммитом, оставляет событие незакрытым, и после рестарта оно приедет снова. Поэтому at least once kafka живёт в паре с идемпотентностью — умением обработчика выдать один и тот же результат на повтор. Практика простая: у события есть уникальный идентификатор — ключ сообщения или поле в Value, — и обработчик перед дорогим действием проверяет, не выполнялось ли оно уже. Хранителем проверки становится ближайшее надёжное хранилище: уникальный индекс в Postgres, набор Redis, таблица обработанных событий. Списание по заказу становится upsert-ом с ключом события вместо слепого INSERT, письмо — записью в таблице отправок с уникальным ограничением. Ключ дедупликации не обязан жить в Value: kafka.Message несёт Headers, и идемпотентный маркер удобно ехать там, рядом с трассировочными, не смешиваясь с полезной нагрузкой. Заметьте, что дубликаты — не авария, а штатный режим контракта: они приходят при каждом рестарте и ребалансе, и система должна их глотать молча, а не писать в дежурный чат.
Дубликаты at-least-once прирастают не только падениями процесса. При ребалансе партиция переезжает к другому члену группы, который продолжит с закоммиченного оффсета: всё обработанное, но не закоммиченное предыдущим владельцем, приедет к новому — независимо от того, жив ли старый процесс. Барьер дедупликации обязан переживать смену владельца, поэтому его хранилище общее, а не локальное по инстансу. Второй источник внутри той же пересборки — интервальные коммиты: их стэш гибнет при ребалансе, о чём говорил восьмой урок. Итог для планирования: окно дубликатов шире окна падений — оценивайте его по частоте рестартов плюс частоте ребалансов, а не только по стабильности процесса.
At-least-once с идемпотентным обработчиком
GOfor {
m, err := reader.FetchMessage(ctx)
if err != nil {
log.Printf("stop: %v", err)
break
}
// chargeOnce проверяет ключ события в БД и
// выполняет списание в одной транзакции
if err := chargeOnce(db, m); err != nil {
log.Printf("charge %d: %v", m.Offset, err)
time.Sleep(time.Second) // ретрай на месте
continue // без коммита: вернёмся к нему
}
if err := reader.CommitMessages(ctx, m); err != nil {
log.Printf("commit: %v", err)
break
}
}Обработка до коммита: chargeOnce защищает от повторов уникальным ключом события, поэтому дубликат после рестарта отработает в no-op и вернёт тот же результат, что и первый прогон. При ошибке обработки цикл возвращается к тому же сообщению — без коммита позиция не двигается. Постоянная ошибка подвесит поток: вытаскивают её ретраями с бэкоффом, лимитом попыток и шиной ошибок, о которых девятнадцатый урок.
Спорное мнение, оплаченное ночным инцидентом: идемпотентность нужна не «где-нибудь», а на самом внешнем эффекте. Сервис рассылок имел идемпотентный обработчик: повтор события не создавал вторую запись задачи. Но отправка письма стояла за внешним API без дедупликации, и при рестарте между обработкой и коммитом задача уезжала повторно — клиент получал два письма при «защищённом» коде. Дедуп переехал в самый край: уникальный ключ события в таблице отправок, checked до вызова API. Мораль: идемпотентность измеряется границей системы — защищайте последний внешний эффект, а не внутреннюю запись. Если внешняя система дедупликации не умеет, окно дубликатов закрывается сохранением факта отправки в той же транзакции, что и бизнес-данные.
Проверка: что именно защищает идемпотентность
At-least-once, обработчик списывает деньги через внешний шлюз. Где должен стоять барьер от повторного списания?
Какая гарантия в этом коде?
Прочитайте код и выберите, что он выведет:
// ReaderConfig с GroupID: "billing"
for {
m, err := reader.ReadMessage(ctx)
if err != nil {
break
}
if err := sendNotification(m); err != nil {
log.Printf("notify: %v", err)
}
}Exactly-once: почему его нет и что делают вместо#
Вопрос «а как сделать ровно один раз» приходит первым, и честный ответ — из консьюмера такого примитива не собрать. Проблема фундаментальна: обработка — это ваш внешний эффект, коммит — запись на брокере, и атомарной транзакции между ними не существует; падение между шагами всегда оставляет либо потерю, либо дубль. Протокол Kafka умеет транзакции продюсера, и консьюмер может читать только закоммиченные транзакционные записи: в kafka-go это поле IsolationLevel в ReaderConfig, где ReadUncommitted — значение по умолчанию, а ReadCommitted фильтрует незакрытые транзакции. Но это о целостности чтения чужих транзакций, а не о exactly-once обработки. Рабочая альтернатива — эмуляция: идемпотентный обработчик на at-least-once плюс хранитель факта выполнения. Там, где нужен строгий exactly-once на внешнем эффекте, факт совершения действия записывают в ту же базу, что и бизнес-результат, в одной транзакции: списание и запись «событие 42 обработано» атомарны средствами Postgres, а Kafka остаётся каналом доставки с повторами. Так exactly-once переносится из брокера, где он недостижим, в транзакцию базы, где он честный.
Как выбрать гарантию: цена ошибки против цены дубля#
Решение сводится к двум числам: во сколько обойдётся потерянное событие и во сколько — повторное. Платежи, биллинг, аудит — потеря необратима и дорога, повтор отвешивается идемпотентностью: at-least-once и барьер на границе системы. Телеметрия, счётчики, логи — потеря незаметна, повтор искажает данные: at-most-once без лишних записей в хранилище. Между ними — серая зона событий, где оба исхода терпимы, и там решает простота: самокоммит ReadMessage короче ручной схемы на два вызова. Проверяйте решение тестом на смерть: убейте процесс между каждой парой строк кода и выпишите, что произойдёт с событием — таблица ниже собрана ровно по этой процедуре. И помните, что гарантия чтения не сильнее гарантии записи: WriteMessages с Async: true даст at-most-once ещё на входе в брокер — десятый урок покажет цену этого выбора. И не забудьте продюсерскую сторону: гарантия консьюмера ничего не стоит, если продюсер молча теряет сообщения на отправке, — ретраи и acks продюсера разбирает двенадцатый урок, а полная картина потерь от Writer до обработчика собирается в уроке про грабли продакшена.
Гарантии доставки одним столом#
| Гарантия | Порядок шагов | При падении | Типовые потоки |
|---|---|---|---|
| At-most-once | Коммит, затем обработка | Событие теряется | Метрики, логи, сигналы с идемпотентным приёмником |
| At-least-once | Обработка, затем коммит | Событие приедет повторно | Платежи, биллинг, аудит с ключом дедупликации |
| Exactly-once | Эмуляция: повтор плюс факт выполнения | Ни потери, ни дубля — ценой транзакции в БД | Деньги и инвентарь, где двойной эффект недопустим |
Чек-лист выбора гарантии#
- Посчитайте цену потери и цену повтора конкретного потока — не абстрактно, в деньгах или часах дежурства.
- At-most-once: коммит до обработки или ReadMessage; список потоков, где это легально, утвердите явно.
- At-least-once: обработка до коммита и уникальный ключ события в общем хранилище.
- Барьер дедупликации ставьте на внешнем эффекте: письмо, списание, звонок — а не на внутренней записи.
- Ошибки обработки решайте осознанно: ретрай на месте, лимит попыток, шина ошибок — зависание тоже решение, но плохое.
- Тест на смерть: убийство процесса между каждой парой строк покажет реальную гарантию, а не заявленную.
Соберите at-least-once
Допишите порядок вызовов: чтение без коммита и подтверждение после обработки.
Соберите at-most-once по строкам
Расставьте шаги цикла так, чтобы событие подтверждалось до обработки: чтение, коммит, обработка, выходы по ошибкам.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: гарантии доставки
Гарантия выбрана осознанно: расстановка FetchMessage, обработчика и CommitMessages — это контракт, а не случайность копипасты. Из восьмого и девятого уроков складывается правило сервиса: коммит — это подтверждение работы, а не нажатие галочки, и любой редизайн обработки начинается с вопроса, что приедет повторно. Дальше по курсу: урок про ошибки консьюмера достроит at-least-once до промышленного вида — ретраи с бэкоффом, лимиты попыток и что делать с ядом, который не обрабатывается никогда. А урок про ошибки продюсера закроет вторую половину пути события — от Writer до диска брокера, без которой консьюмерская гарантия лишь полконтракта. Формат честный, как всегда: песочница сеть не поднимает, эксперименты с падениями процессов — локально против брокера из Docker, интерактивы — программы на чистом Go с той же семантикой шагов.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Гарантия — порядок двух шагов: коммит до обработки даёт at-most-once, обработка до коммита — at-least-once.
- At-most-once теряет события при падении после коммита; легальные зоны — метрики, логи, сигналы.
- At-least-once возвращает события после падения; цена — дубликаты, которые глотает идемпотентность.
- ReadMessage с группой — at-most-once из коробки: самокоммит происходит до вашей обработки.
- Барьер дедупликации ставится на внешнем эффекте: уникальный ключ события перед списанием и отправкой.
- Exactly-once консьюмером не собирается; честная эмуляция — идемпотентность плюс факт выполнения в транзакции БД.
- Ребаланс переезжает партицию вместе с окном дубликатов: барьер дедупликации живёт в общем хранилище, а не в памяти инстанса.
- Эксперименты с гарантиями — локально против брокера в Docker; песочница показывает шаги на чистом Go.
Домашнее задание
Соберите на брокере из третьего урока два консьюмера одного топика: at-most-once из урока и at-least-once с идемпотентным обработчиком — таблица processed_events с уникальным ключом сообщения. Запустите оба, отправьте двадцать платежей и убейте каждый процесс посреди обработки. Зафиксируйте три факта: сколько событий потерял первый, сколько дубликатов получил второй и сколько дублей поглотила таблица. Затем уберите проверку ключа из chargeOnce и убедитесь, что at-least-once начнёт дважды списывать — запишите этот эксперимент в README сервиса как обоснование барьера. Финал: при работающих консьюмерах уроните один инстанс группы, дождитесь ребаланса и проверьте, сколько дубликатов принесла пересборка при ноль упавших обработок.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему нельзя получить exactly-once настройками kafka-go?
Потому что окно между обработкой и коммитом неустранимо: обработка — внешний эффект вашего кода, коммит — запись на брокере, и атомарной транзакции между ними нет. Клиент может сдвинуть окно к потерям или к дубликатам, но не закрыть. Строгая семантика собирается приложением: идемпотентный обработчик и факт выполнения в той же транзакции БД, что и результат.
Дедупликация через Redis — это надёжно?
Зависит от режима: набор ключей в Redis с TTL закрывает повторы в секундах и минутах, но не переживает собственную потерю данных. Для денег барьер ставят в транзакционное хранилище — Postgres с уникальным индексом, — потому что там проверка и эффект атомарны: при двух конкурентных вставках вторая вернёт ошибку, которую обработчик глотает как already done. Redis хорош для быстрых потоков, где потеря барьера стоит дубликатов, а не денег.
Как быть, если обработчик зависит от порядка, а дубликаты ломают состояние?
Идемпотентность проектируется от операции: повторное применение того же события должно выдавать тот же результат — upsert вместо insert, вычисление состояния из полного снимка, проверка версии. Если повтор ломает логику, это не про Kafka: сущность требует либо ордеринга по ключу из пятого урока, либо пересмотра обработки до чистой функции события.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Ebitengine: 2D-игры на Go
Курс Ebitengine для Go: игровой цикл Update/Draw, спрайты и анимации, ввод, коллизии, тайлмапы, камера, звук, сцены и сборка на платформы. 25 уроков.
- Понимать игровой цикл Update/Draw и Layout
- Рисовать спрайты, атласы и примитивы
- Обрабатывать ввод: клавиатура, мышь, тач
- Строить движение, коллизии и анимации