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

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

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

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

Гарантии: at-most-once и at-least-once

Гарантии доставки kafka — это не флаг брокера, а порядок двух ваших шагов: коммита и обработки, и цена выбора — платёж, обработанный дважды, или платёж, не обработанный вовсе. Соберём at-most-once и at least once kafka на FetchMessage и CommitMessages, посчитаем окна потерь и дубликатов и разберём, почему идемпотентный обработчик дешевле exactly-once.

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

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

GO
for {
        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 с идемпотентным обработчиком

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

Проверка: что именно защищает идемпотентность

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

Допишите порядок вызовов: чтение без коммита и подтверждение после обработки.

m, err := reader.(ctx) if err != nil { break } if err := chargeOnce(db, m); err != nil { continue } if err := reader.(ctx, m); err != nil { break }

Соберите at-most-once по строкам

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

Строки

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

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

Карточки: гарантии доставки

Термин

At-most-once

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

Коммит до обработки: дубликатов нет, падение после коммита теряет событие. Легально для метрик и логов.

1 / 6

Гарантия выбрана осознанно: расстановка 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 сервиса как обоснование барьера. Финал: при работающих консьюмерах уроните один инстанс группы, дождитесь ребаланса и проверьте, сколько дубликатов принесла пересборка при ноль упавших обработок.

Личные заметкиПусто
Хранятся только в вашем браузере (Kafka · Гарантии: at-most-once и at-least-once)

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

Почему нельзя получить exactly-once настройками kafka-go?

Потому что окно между обработкой и коммитом неустранимо: обработка — внешний эффект вашего кода, коммит — запись на брокере, и атомарной транзакции между ними нет. Клиент может сдвинуть окно к потерям или к дубликатам, но не закрыть. Строгая семантика собирается приложением: идемпотентный обработчик и факт выполнения в той же транзакции БД, что и результат.

Дедупликация через Redis — это надёжно?

Зависит от режима: набор ключей в Redis с TTL закрывает повторы в секундах и минутах, но не переживает собственную потерю данных. Для денег барьер ставят в транзакционное хранилище — Postgres с уникальным индексом, — потому что там проверка и эффект атомарны: при двух конкурентных вставках вторая вернёт ошибку, которую обработчик глотает как already done. Redis хорош для быстрых потоков, где потеря барьера стоит дубликатов, а не денег.

Как быть, если обработчик зависит от порядка, а дубликаты ломают состояние?

Идемпотентность проектируется от операции: повторное применение того же события должно выдавать тот же результат — upsert вместо insert, вычисление состояния из полного снимка, проверка версии. Если повтор ломает логику, это не про Kafka: сущность требует либо ордеринга по ключу из пятого урока, либо пересмотра обработки до чистой функции события.

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

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

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

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

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

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

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