Оффсеты и семантики доставки

Урок про то, как консьюмер запоминает прогресс и какие гарантии доставки из этого следуют.

Коммит оффсета — сохранение позиции «до какого события я дочитал»; от того, когда вы коммитите относительно обработки, зависит семантика доставки: at-most-once, at-least-once или exactly-once.

Зачем это нужно

Консьюмер падает и перезапускается — с какого места читать? С последнего закоммиченного оффсета. Но если закоммитить до обработки, а потом упасть — событие потеряется. Если после — при падении между обработкой и коммитом оно обработается повторно. Порядок «обработать/закоммитить» и определяет гарантию.

Три семантики

СемантикаПорядокЭффект при сбое
at-most-onceкоммит ДО обработкивозможна потеря, дублей нет
at-least-onceкоммит ПОСЛЕ обработкивозможны дубли, потерь нет
exactly-onceобработка и коммит атомарныни потерь, ни дублей

at-least-once и идемпотентная обработка

По умолчанию выбирают at-least-once: сначала обработали, потом закоммитили. Потерь нет, но возможны повторы. Чтобы повтор не навредил, обработчик делают идемпотентным — повторная обработка того же события даёт тот же результат. Например, UPSERT по ключу события или проверка «уже видел этот id».

  at-least-once:
    обработать(e) -> записать в БД -> commit(offset)
    падение между записью и commit -> e обработается снова
    -> спасает идемпотентность (UPSERT, дедуп по id)

exactly-once

Exactly-once в Kafka достижим внутри платформы (read-process-write): транзакционный продюсер атомарно записывает результат и коммитит оффсет в одной транзакции. Если транзакция не завершилась — ни результат, ни оффсет не зафиксированы, повтор начнётся с чистого листа. Это мощно, но дороже и работает в границах Kafka; запись во внешнюю систему «ровно один раз» всё равно требует идемпотентности на её стороне.

Как работает под капотом

Оффсеты консьюмера хранятся в специальном внутреннем топике __consumer_offsets — то есть Kafka использует саму себя как хранилище прогресса. Автокоммит (enable.auto.commit=true) периодически коммитит в фоне по таймеру, что удобно, но опасно: можно закоммитить ещё не обработанные сообщения. Для контроля над семантикой автокоммит выключают и коммитят вручную после успешной обработки. Транзакционный режим (isolation.level=read_committed у консьюмера) заставляет читателя видеть только зафиксированные транзакции — основа exactly-once-конвейеров.

Частые ошибки

  • Автокоммит при тяжёлой обработке. Оффсет может закоммититься до завершения работы — потеря при сбое.
  • Расчёт на exactly-once во внешней БД «из коробки». EOS Kafka — внутри Kafka; внешний приёмник всё равно нужно делать идемпотентным.
  • at-least-once без идемпотентного обработчика. Повторы создадут двойные списания/письма.

Итоги

  • Семантику задаёт порядок «обработать/закоммитить»: до — at-most-once, после — at-least-once.
  • at-least-once + идемпотентный обработчик — практичный дефолт без потерь.
  • Exactly-once в Kafka — транзакционный read-process-write; вовне всё равно нужна идемпотентность.
Проверьте себя
1. Что даёт коммит оффсета ПОСЛЕ обработки?
Aat-most-once (возможна потеря)
Bat-least-once (возможны дубли, потерь нет)
Cexactly-once гарантированно
DОтключает чтение
2. Как сделать at-least-once безопасным от повторов?
AВключить acks=0
BСделать обработчик идемпотентным (UPSERT, дедуп по id)
CУдалять оффсеты
DИспользовать одну партицию
3. Где Kafka хранит закоммиченные оффсеты консьюмеров?
AВ файле на диске клиента
BВо внутреннем топике __consumer_offsets
CВ ZooKeeper всегда
DВ оперативной памяти брокера