Гарантии доставки и exactly-once на практике

Урок-синтез: как из кирпичиков надёжности собирается сквозная гарантия «ровно один раз».

Exactly-once semantics (EOS) — гарантия, что каждое событие в конвейере read-process-write учитывается ровно один раз: ни потерь, ни дубликатов в результате.

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

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

Три кирпичика EOS

КирпичикЧто закрывает
Идемпотентный продюсердубли при ретраях записи
Транзакции продюсераатомарность «запись результата + коммит оффсета»
Консьюмер read_committedчтение только зафиксированных транзакций

Транзакционный read-process-write

  begin transaction
    результат = process(событие)
    produce(результат -> топик-выход)
    sendOffsetsToTransaction(оффсет входа)
  commit transaction   <- атомарно: и результат, и оффсет

Ключевой трюк: коммит оффсета входного топика тоже часть транзакции. Либо зафиксированы и результат, и продвижение оффсета — либо ничего. Сбой посередине откатывает всё, и обработка повторится с того же входного оффсета без половинчатого результата.

# продюсер EOS
enable.idempotence=true
transactional.id=billing-processor-1
# консьюмер EOS
isolation.level=read_committed

Границы гарантии

EOS Kafka действует внутри Kafka: вход — топик, выход — топик, оффсеты — в Kafka. Как только результат пишется во внешнюю систему (БД, API), транзакция Kafka её не охватывает. Там exactly-once достигается иначе — идемпотентной записью (UPSERT по ключу события) или паттерном «transactional outbox». Поэтому честная формула для большинства реальных систем: at-least-once доставка + идемпотентный приёмник = эффект exactly-once.

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

Транзакционный продюсер регистрирует transactional.id у координатора транзакций (специальный брокер). Записи в рамках транзакции попадают в лог сразу, но помечаются как «незакоммиченные»; в конце пишется маркер commit или abort. Консьюмер с read_committed пропускает данные незавершённых и отменённых транзакций, отдавая приложению только зафиксированные. transactional.id ещё и «огораживает» зомби-экземпляры: если старый процесс с тем же id ожил, координатор отвергнет его транзакции — это защищает от двойной обработки при сбоях.

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

  • Верить в «exactly-once везде». Гарантия — внутри Kafka; внешний приёмник нужно делать идемпотентным.
  • EOS там, где хватит at-least-once. Транзакции дороже; включайте их прицельно для критичных конвейеров.
  • Консьюмер read_uncommitted в EOS-конвейере. Он увидит откатанные данные — гарантия сломана.

Итоги

  • EOS строится из идемпотентного продюсера, транзакций и консьюмера read_committed.
  • Транзакция атомарно фиксирует результат и оффсет входа — повтор не оставляет полурезультатов.
  • Граница EOS — внутри Kafka; для внешних систем эффект «ровно один раз» даёт идемпотентность.
Проверьте себя
1. Почему коммит входного оффсета включают в транзакцию?
AДля скорости
BЧтобы результат и продвижение оффсета фиксировались атомарно — сбой откатит оба
CЧтобы удалить дубликаты в источнике
DЭто требование ZooKeeper
2. Что видит консьюмер с isolation.level=read_committed?
AВсе записи, включая откатанные
BТолько данные зафиксированных транзакций
CТолько незакоммиченные данные
DНичего до коммита оффсета
3. Где проходит граница exactly-once Kafka?
AГарантия действует в любых внешних системах
BВнутри Kafka (топик-в-топик); для внешнего приёмника нужна идемпотентность
CТолько в одной партиции
DТолько при RF=1