Гарантии доставки и 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; для внешних систем эффект «ровно один раз» даёт идемпотентность.