← Назад к списку
Системный дизайнJava и KotlinSenior

Сервис должен сохранить заказ в БД и опубликовать событие в Kafka. Как гарантировать согласованность? Расскажите про transactional outbox.

Короткий ответ

  • Двойная запись в БД и брокер не атомарна — одна из сторон может потеряться
  • Отправка в Kafka внутри @Transactional не решает: коммит БД может упасть после отправки
  • Outbox: событие пишется в таблицу outbox той же транзакцией, что и заказ
  • Отдельный процесс (поллер или Debezium CDC) публикует события из таблицы
  • Гарантия at-least-once, потребители должны быть идемпотентными
  • Порядок — через ключ партиционирования по aggregate id
  • Альтернативы: CDC без outbox, listen-to-yourself; 2PC с Kafka не вариант

Transactional outbox превращает двойную запись в одну локальную транзакцию плюс асинхронную доставку с гарантией at-least-once.

Как сказать вслух

пример ответа

Проблема в том, что запись в базу и отправка в Kafka — две разные системы, и атомарно их не выполнить: между ними сервис может упасть, и либо заказ без события, либо событие без заказа. Паттерн outbox решает это так: в той же транзакции, где я сохраняю заказ, я пишу событие в соседнюю таблицу outbox. Это одна локальная транзакция — либо всё, либо ничего. А отдельный процесс читает эту таблицу и публикует события в Kafka, помечая отправленные. Получается доставка минимум один раз, поэтому потребители должны уметь отбрасывать дубликаты.

Подробный ответ

Основной ответ

Наивные варианты ломаются: «сохранить, потом отправить» теряет событие при падении между операциями; «отправить внутри транзакции» публикует событие, которое откатится вместе с транзакцией; распределённые транзакции (2PC/XA) Kafka не поддерживает, и они плохо масштабируются. Outbox: таблица outbox(id, aggregate_id, type, payload, created_at, published_at); бизнес-запись и вставка события — одна локальная ACID-транзакция. Доставка: polling publisher (простой, задержка равна интервалу опроса; нужен SELECT ... FOR UPDATE SKIP LOCKED при нескольких инстансах) или CDC через Debezium (читает WAL, минимальная задержка, меньше нагрузки на БД). Гарантия at-least-once: паблишер может упасть после отправки до пометки — потребители дедуплицируют по event id или строят обработку идемпотентно. Порядок внутри агрегата — ключ партиции Kafka = aggregate_id. Ещё нужны: ретраи с backoff, очистка отправленных, мониторинг отставания outbox, схема событий (Avro/JSON Schema) и её эволюция.

Ключевые моменты

  • Почему не двойная запись. Две системы без общей транзакции; падение между записями даёт рассинхрон, который трудно заметить.
  • Суть outbox. Событие фиксируется той же локальной транзакцией, что и данные; публикация становится асинхронной и повторяемой.
  • Доставка. Поллер со SKIP LOCKED или Debezium CDC; выбор — компромисс задержки, сложности и нагрузки.
  • At-least-once. Дубликаты неизбежны — идемпотентный consumer или дедупликация по event id обязательны.

Практический контекст

Стандартный вопрос на микросервисных собеседованиях уровня senior: он связывает транзакции, брокеры и отказоустойчивость. Красные флаги для интервьюера — «просто отправлю в Kafka в @Transactional» или вера в exactly-once без оговорок. Сильный кандидат рисует таблицу outbox, обсуждает SKIP LOCKED против Debezium, идемпотентность потребителей и признаёт компромисс: событие доставляется с задержкой, зато никогда не теряется.

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

  • Публикуют в Kafka внутри транзакции БД и считают это атомарным
  • Предлагают 2PC между базой и Kafka, которого там нет
  • Забывают про идемпотентность потребителей при at-least-once доставке

ИП Кочкин Алексей Сергеевич · ИНН 390509026279 · ОГРНИП 325390000030973 · jiniys2005@yandex.ru