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
Публикация события после бизнес-операции
Сценарий:
Сервис успешно сохраняет заказ в БД;
После этого публикует событие в Kafka.
public void saveOrder(Oreder oreder) {
orederRepository.save(oreder);
String message = KafkaMessage.createPayload(oreder);
kafkaTemplate.send(message);
}
Проблема: Сервис падает до отправки события.
Результат: Бизнес-операция завершена, а событие утеряно.
Обработка события после получения
Сценарий:
Сервис получает сообщение из Kafka;
Обрабатывает его и сохраняет данные в БД;
После этого коммитит 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 — это хранилище входящих сообщений. Вместо того чтобы сразу обрабатывать событие, сервис:
Сохраняет сообщение в таблицу Inbox;
Фиксирует offset в Kafka (сообщает, что сообщение получено);
Позже обрабатывает сообщение из базы, а не напрямую из Kafka.
Такой подход дает два ключевых преимущества:
Защита от потерь
Защита от дублирования - каждое сообщение имеет уникальный 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 — это не просто паттерн, а архитектурное соглашение, которое позволяет:
проектировать надёжные интеграции;
минимизировать риски потерь и дублирования;
стандартизировать подход к обмену событиями;
Пишите код, который не теряет сообщения.
Архитектура — это не только про «как», но и про «что будет, если...».
