Почему отправка сообщения в Kafka — это двухфазный коммит, а не fire-and-forget
Работа не сделана в момент producer.send() — она сделана, когда получатель обработал и подтвердил сообщение. До этого два узла рассогласованы, и это окно eventual consistency нужно проектировать осознанно, а не обнаруживать в проде.
Проблема
Команды думают: отправил сообщение — работа сделана. Но система становится консистентной только когда получатель обработал и подтвердил. До этого момента два узла находятся в рассогласованном состоянии — и это окно нужно осознанно проектировать, а не игнорировать.
Методика
- 1. Что такое две фазы на самом деле. Фаза 1 — отправитель фиксирует изменение и публикует событие. Фаза 2 — получатель обрабатывает и подтверждает. Между фазами система рассогласована: отправитель уже завершил, получатель ещё нет. Это не баг — это нормальное состояние eventual consistency. Его нужно проектировать явно.
- 2. Получатель — не тупой relay. У получателя есть собственная бизнес-логика: он может отклонить событие (нет товара на складе, лимит превышен). Если отправитель не обрабатывает отклонение — он навсегда остаётся в рассогласованном состоянии.
- 3. Два архитектурных выбора — и оба требуют разговора с бизнесом. Fire-and-forget: отправитель считает задачу выполненной сразу. Await confirmation: отправитель ждёт подтверждения от получателя. Ни один не «правильный» сам по себе — выбор зависит от того, что бизнес готов принять: окно рассогласования или дополнительную латентность. Это нужно проговорить явно до архитектурного решения.
-
4. Инфраструктурная предпосылка: outbox.
Без outbox-паттерна фаза 1 уже ненадёжна: запись в БД и публикация события —
два отдельных коммита, и они могут разойтись.
# Фаза 1: сервис А фиксирует изменение и отправляет with db.transaction(): order.status = "placed" order_repo.save(order) outbox.publish("orders.created", order) # ← А завершил; система рассогласована до завершения фазы 2 # Фаза 2: сервис Б обрабатывает асинхронно def handle_order_created(event): if not inventory.reserve(event.items): outbox.publish("orders.rejected", event.order_id) return fulfillment_repo.create(event) outbox.publish("orders.confirmed", event.order_id) # Фаза 1 завершается: А получает подтверждение от Б def handle_order_confirmed(event): order_repo.update(event.order_id, status="confirmed") # ← только теперь оба узла согласованы по бизнес-факту def handle_order_rejected(event): order_repo.update(event.order_id, status="rejected") -
5. Три «подтверждения», которые путают в одно.
«Отправил = готово» схлопывает три разных факта, и каждый — своя ручка со своей
гарантией:
Уровень Что подтверждает Ручка Что НЕ значит Broker ack Сообщение записано в лог брокера acks(0/1/all) +min.insync.replicasЧто его кто-то обработал Offset commit Потребитель пометил сообщение прочитанным enable.auto.commit/ ручнойcommitSyncЧто бизнес-логика отработала Business confirmation Получатель обработал и согласен по бизнесу прикладное событие confirmed/rejected— (это и есть фаза 2) acks=all+min.insync.replicas=2— минимум для durability публикации: ack только когда запись реплицирована в ISR, иначе потеря при падении лидера. Но durability ≠ обработка: broker ack закрывает надёжность фазы 1, не фазу 2. -
6. Порядок commit офсета задаёт семантику доставки.
Commit до обработки — at-most-once (упал в процессе → сообщение потеряно).
Commit после обработки — at-least-once (упал после обработки, до commit →
передоставка, отсюда обязательная идемпотентность потребителя). Exactly-once на
публикации —
enable.idempotence=true+ транзакции (transactional.id), связывающие запись в топик и commit офсета в один атомарный коммит; на стороне обработки всё равно нужен ключ дедупликации, потому что «exactly-once» Kafka — про её топики, а не про сайд-эффекты в вашей БД.
Для отслеживания состояния через несколько сервисов — Correlation ID (единый идентификатор, который проходит через все фазы). Для управления компенсирующими действиями при отклонении — паттерн Saga.
Артефакт
Видео «Почему отправка сообщения в Кафку — это двухфазный коммит» на канале @IT-Head.
Где ломается
- Dual write без outbox — запись в БД закоммитилась, публикация упала (или наоборот): рассогласование, которое всплывёт под нагрузкой, не на демо. Митигация: transactional outbox + relay (CDC или поллер) — одна транзакция на запись и на «намерение опубликовать».
- Commit офсета до обработки — тихая потеря сообщений при падении в процессе. Митигация: ручной commit строго после успешной обработки (at-least-once) + идемпотентность потребителя; авто-commit выключить, если сайд-эффекты важны.
- Идемпотентность потребителя обязательна — при ретраях двухфазная природа протокола даёт дубликаты by design. Митигация: ключ дедупликации (business key / correlation id) в сторе обработанных, upsert вместо insert.
- Бизнес не готов к окну рассогласования — если стейкхолдер ожидает мгновенной консистентности, а система eventual consistency, это конфликт требований, который нужно закрыть до архитектуры, а не после инцидента.
-
Получатель отклонил — отправитель не знает —
если нет явного
handle_rejected, система зависает в промежуточном состоянии навсегда. Митигация: обязательный компенсирующий путь (Saga) + таймаут/алерт на «висящие» в промежуточном статусе дольше SLA. Что мониторить: consumer lag, доля rejected, возраст записей в незакрытом промежуточном статусе.
Для кого и почему
Для команд, которые строят event-driven интеграции и хотят понять, где именно возникает рассогласование — и как его проектировать, а не обнаруживать в проде.
Читать дальше
Об авторе
Григорий Добряков — fractional / interim eng + AI leadership
Есть проблемы с рассогласованием в event-driven системе?
Аудит event-driven интеграций: где окно eventual consistency не проговорено с бизнесом, где получатель может отклонить молча, где dual write без outbox — на основе реального опыта с 40+ сервисами на Kafka/RabbitMQ.
Обсудить fractional-аудит Написать на почтуДругие разборы
Серия инженерных разборов: реальная задача → методика → работающий артефакт → честный разбор, где он ломается.
К серии →