1. Введение

Kafka часто воспринимается как система, гарантирующая доставку сообщений и Exactly Once Semantics. Однако в реальных распределённых системах эти гарантии заканчиваются на границе брокера.

Сообщение может потеряться между записью в базу данных и публикацией события, а может быть обработано повторно при сбое сервиса.

В этой статье разберём:

  • где именно теряются гарантии Kafka

  • почему Exactly Once не работает на уровне всей системы

  • и как паттерны Outbox / Inbox помогают решить эту проблему.

2. Контекст: микросервисы и асинхронное взаимодействие

Мы используем микросервисную архитектуру: каждый сервис отвечает за свою предметную область и развивается независимо.

Для взаимодействия между сервисами применяем два подхода:

  • Синхронный — через HTTP, когда нужен немедленный ответ;

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

Асинхронность:

  • уменьшает связанность;

  • помогает справляться с пиковыми нагрузками;

В качестве транспорта мы выбрали Apache Kafka — надёжную и масштабируемую платформу.

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

3. Проблемы доставки данных в распределенных системах

Kafka предоставляет мощные механизмы надёжной доставки сообщений:

  • подтверждение записи (acks=all);

  • идемпотентный продюсер (enable.idempotence=true);

  • ручной коммит offset'а после обработки (ack.acknowledge()).

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

Kafka гарантирует доставку внутри себя, но не может контролировать внешние системы — базы данных, сторонние API и т.д.

3.1. Границы, где теряется Exactly Once

Публикация события после бизнес-операции

Сценарий:

  1. Сервис успешно сохраняет заказ в БД;

  2. После этого публикует событие в Kafka.

public void saveOrder(Oreder oreder) {
orederRepository.save(oreder);
String message = KafkaMessage.createPayload(oreder);
kafkaTemplate.send(message);
}

Проблема: Сервис падает до отправки события.

Результат: Бизнес-операция завершена, а событие утеряно.

Обработка события после получения

Сценарий:

  1. Сервис получает сообщение из Kafka;

  2. Обрабатывает его и сохраняет данные в БД;

  3. После этого коммитит offset.

@KafkaListener(topics = "order")
public void listen(String message, Acknowledgment ack) {
KafkaMessage kafkaMessage = NonNullObjectMapper.convertJsonToObject(message,KafkaMessage.class);
Order order = Order.from(kafkaMessage.getPayload);
oredrRepository.save(order);
ack.acknowledge();
}

Проблема: Сервис падает после записи в БД, но до коммита offset

Результат: Kafka считает, что сообщение не обработано, и отправляет его повторно. Если бизнес-операция не идемпотентна — возникает повторное выполнение действия: двойной платёж, повторное письмо, дублирующая запись.

Вывод

Kafka предоставляет надёжную доставку сообщений внутри своей инфраструктуры, но не может гарантировать целостность данных на уровне всей системы.

Гарантии теряются на стыке:

  • между бизнес-операцией и публикацией события;

  • между получением события и записью в БД.

Именно здесь и появляются риски:

  • Потери сообщений;

  • Дублирование действий;

  • Нарушение согласованности между сервисами.

Чтобы их избежать, нужны архитектурные решения — такие как Outbox / Inbox, которые позволяют расширить зону надёжности за пределы Kafka и обеспечить гарантированную доставку в рамках всей системы.

4. Архитектурный подход: Outbox, Inbox и Kafka

Что такое Outbox и Inbox?

Паттерн

Что делает?

Зачем нужен?

Outbox

Сохраняет событие в локальную таблицу в рамках бизнес-транзакции

Гарантирует, что событие будет создано только если бизнес-операция успешна

Inbox

Сохраняет входящее сообщение перед обработкой

Позволяет безопасно обрабатывать события и избегать потери при сбоях

4.1. Outbox — надёжная публикация событий.


Когда сервис выполняет бизнес-операцию (например, создаёт заказ), он:

  • сохраняет бизнес-данные в свою таблицу (например, orders);

  • в той же транзакции сохраняет событие в таблицу outbox.

Это гарантирует, что событие будет создано только если бизнес-операция прошла успешно.

Далее компонент публикации событий:

  • регулярно проверяет, какие события готовы к отправке;

  • отправляет их в Kafka;

  • при успешной отправке помечает событие как отправленное;

  • если отправка не удалась — компонент публикации попытается отправить событие повторно.


@Scheduled(fixedRateString = "...")
public void postMessage() {
List<OutboxMessage> forDelivery = outboxRepository.getMessagesForDelivery(...);
for (OutboxMessage msg : forDelivery) {
try {
kafkaTemplate.send(topic, msg.payload()).get();
outboxRepository.updateStatusTo(msg.messageId(), SENT);
} catch (Exception e) {
outboxRepository.resetStatus(msg.messageId());
}
}
}

Логика повторной отправки инкапсулирована в outboxRepository. Репозиторий управляет статусами сообщений (NEW, IN_PROGRESS, SENT) и гарантирует, что сообщения, отправка которых завершилась ошибкой или долго висят в SENT, будут автоматически возвращены в очередь на повторную обработку.

4.2. Inbox - безопасный прием событий

Inbox — это хранилище входящих сообщений. Вместо того чтобы сразу обрабатывать событие, сервис:

  1. Сохраняет сообщение в таблицу Inbox;

  2. Фиксирует offset в Kafka (сообщает, что сообщение получено);

  3. Позже обрабатывает сообщение из базы, а не напрямую из Kafka.

Такой подход дает два ключевых преимущества:

  1. Защита от потерь

  2. Защита от дублирования - каждое сообщение имеет уникальный message_id. Перед сохранением в Inbox сервис проверяет: есть ли уже такое сообщение.

@KafkaListener(...)
public void listen(String message, Acknowledgment ack) {
InboxMessage msg = InboxMessage.from(message);
inboxRepository.saveToInbox(msg);
ack.acknowledge(); // ручной commit
}

4.3 Подтверждение доставки

Чтобы обеспечить ещё более надёжную доставку, мы используем механизм подтверждения доставки. После того как получатель сохранил сообщение в свою таблицу inbox, он отправляет подтверждение обратно отправителю. Зачем это нужно? Механизм подтверждения позволяет компоненту публикации знать, что сообщение было успешно получено и сохранено получателем. Чтобы избежать повторной отправки одного и того же события при отсутствии подтверждения.

5. Заключение

Outbox / Inbox — это не просто паттерн, а архитектурное соглашение, которое позволяет:

  • проектировать надёжные интеграции;

  • минимизировать риски потерь и дублирования;

  • стандартизировать подход к обмену событиями;

Пишите код, который не теряет сообщения.

Архитектура — это не только про «как», но и про «что будет, если...».