Почему отправка сообщения в Kafka — это двухфазный коммит, а не fire-and-forget | Григорий Добряков

Григорий Добряков

Howto · разбор

Разбор 09 YouTube @IT-Head · «Шины событий VS шины данных» 3 600 просмотров

Почему отправка сообщения в Kafka — это двухфазный коммит, а не fire-and-forget

Работа не сделана в момент producer.send() — она сделана, когда получатель обработал и подтвердил сообщение. До этого два узла рассогласованы, и это окно eventual consistency нужно проектировать осознанно, а не обнаруживать в проде.

CTO Architect Tech Lead Head of AI

Проблема

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

Методика

  1. 1. Что такое две фазы на самом деле. Фаза 1 — отправитель фиксирует изменение и публикует событие. Фаза 2 — получатель обрабатывает и подтверждает. Между фазами система рассогласована: отправитель уже завершил, получатель ещё нет. Это не баг — это нормальное состояние eventual consistency. Его нужно проектировать явно.
  2. 2. Получатель — не тупой relay. У получателя есть собственная бизнес-логика: он может отклонить событие (нет товара на складе, лимит превышен). Если отправитель не обрабатывает отклонение — он навсегда остаётся в рассогласованном состоянии.
  3. 3. Два архитектурных выбора — и оба требуют разговора с бизнесом. Fire-and-forget: отправитель считает задачу выполненной сразу. Await confirmation: отправитель ждёт подтверждения от получателя. Ни один не «правильный» сам по себе — выбор зависит от того, что бизнес готов принять: окно рассогласования или дополнительную латентность. Это нужно проговорить явно до архитектурного решения.
  4. 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. 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. 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.

Где ломается

Для кого и почему

Для команд, которые строят event-driven интеграции и хотят понять, где именно возникает рассогласование — и как его проектировать, а не обнаруживать в проде.

Об авторе

Григорий Добряков — fractional / interim eng + AI leadership

Есть проблемы с рассогласованием в event-driven системе?

Аудит event-driven интеграций: где окно eventual consistency не проговорено с бизнесом, где получатель может отклонить молча, где dual write без outbox — на основе реального опыта с 40+ сервисами на Kafka/RabbitMQ.

Обсудить fractional-аудит Написать на почту

Другие разборы

Серия инженерных разборов: реальная задача → методика → работающий артефакт → честный разбор, где он ломается.

К серии →