Какие гарантии доставки сообщений предоставляет Kafka
Apache Kafka поддерживает три классических уровня семантики доставки сообщений:
🟢 Junior Level
30-секундный ответ
Apache Kafka поддерживает три классических уровня семантики доставки сообщений:
- At-most-once (Не более одного раза):
- Сообщения могут теряться, но никогда не дублируются.
- Где используется: метрики, телеметрия, логирование, где потеря отдельных записей дешевле затрат на повторную отправку.
- At-least-once (Как минимум один раз):
- Сообщения гарантированно не теряются, но возможны дубликаты при сбоях сети и повторных отправках (retries).
- Индустриальный стандарт по умолчанию: используется в 95% корпоративных систем в связке с идемпотентными получателями.
- Exactly-once (Ровно один раз, EOS):
- Сообщение обрабатывается строго один раз без потерь и дубликатов.
- Критически важное ограничение: поддерживается из коробки только в топологиях Kafka-to-Kafka (вычитали из топика А $\to$ обработали $\to$ записали в топик Б и закоммитили оффсет в рамках атомарной транзакции Kafka).
Наглядная аналогия из жизни
- At-most-once — отправка обычного письма почтой без трек-номера: бросили конверт в почтовый ящик. Если почтальон уронил его под дождь — письмо пропало. Вы не узнаете об этом, но получателю точно не придут два одинаковых письма.
- At-least-once — доставка курьером с росписью: курьер привез посылку, но у него завис терминал при подтверждении вручения. Курьер ушел, а на следующий день пришел еще один курьер с дубликатом. Посылка гарантированно получена, но возник дубликат.
- Exactly-once — межбанковский клиринг: передача денег между счетами внутри единого закрытого банковского процессинга. Сумма либо списана с одного счета и зачислена на другой в единой транзакции, либо откатана назад.
Сводная таблица параметров
| Гарантия | Настройки продюсера | Настройки консьюмера | Риск |
| :— | :— | :— | :— |
| At-most-once | acks=0 или acks=1, retries=0 | enable.auto.commit=true (коммит до обработки) | ❌ Потеря сообщений |
| At-least-once | acks=all, retries=MAX, enable.idempotence=true | enable.auto.commit=false (ручной коммит после работы) | ⚠️ Дублирование при сбоях |
| Exactly-once | transactional.id=TX-1, enable.idempotence=true | isolation.level=read_committed, ручной коммит | ⏳ Повышенная задержка (Latency) |
🟡 Middle Level
«Формула» гарантии: совместная работа трех звеньев
Гарантия доставки — это сквозной контракт. Нельзя настроить только продюсера или только консьюмера. Если хотя бы один компонент настроен неверно, гарантия рушится.
\[\text{Сквозная гарантия} = \text{Producer Guarantee} + \text{Broker Durability} + \text{Consumer Processing}\][ Producer ] [ Kafka Broker ] [ Consumer ]
acks=all min.insync.replicas=2 enable.auto.commit=false
enable.idempotence=true replication.factor=3 commitSync() после записи в БД
- Продюсер: гарантирует, что сообщение записано на кворум брокеров. При сетевом сбое выполняет retry.
- Брокер: гарантирует сохранение данных на диск и синхронизацию с репликами (
min.insync.replicas). - Консьюмер: коммитит оффсет в
__consumer_offsetsстрого после того, как бизнес-логика успешно отработала.
Реализация At-least-once на Spring Boot 3
@Configuration
public class KafkaConfig {
@Bean
public ProducerFactory<String, Object> producerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.ACKS_CONFIG, "all"); // Ждем подтверждения всех ISR
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); // Защита от дублей продюсера
props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);
return new DefaultKafkaProducerFactory<>(props);
}
}
@Service
public class OrderConsumer {
@KafkaListener(topics = "orders", groupId = "order-group")
public void consume(ConsumerRecord<String, String> record, Acknowledgment ack) {
// 1. Сначала сохраняем в БД бизнес-результат
orderRepository.save(parse(record.value()));
// 2. Только после успешного коммита в БД подтверждаем смещение в Kafka
ack.acknowledge();
}
}
🔴 Senior Level
Анатомия Exactly-Once в Kafka (KIP-98)
Для реализации семантики Exactly-Once (EOS) Kafka использует три ключевых механизма:
- Идемпотентный продюсер (Idempotent Producer):
- Каждому инстансу продюсера брокер присваивает 64-битный
Producer ID (PID). - Каждое сообщение снабжается монотонным счетчиком
Sequence Number. - Брокер сохраняет в памяти последние 5 sequence numbers для каждого PID. Если приходит сообщение с уже известным номером, брокер возвращает ACK, но не дублирует запись в лог.
- Каждому инстансу продюсера брокер присваивает 64-битный
- Координатор транзакций (Transaction Coordinator) и топик
__transaction_state:- Специальный брокер управляет жизненным циклом двухфазного коммита транзакции.
- Маркеры управления (Control Records / Transaction Markers):
- При коммите или откате транзакции координатор пишет в партицию специальную служебную запись —
COMMITилиABORT.
- При коммите или откате транзакции координатор пишет в партицию специальную служебную запись —
Архитектурный лимит: почему нет «Out-of-the-Box Exactly-Once в Базу Данных»?
Частое заблуждение кандидатов: «Мы включили Kafka Exactly-Once, поэтому данные сохраняются в PostgreSQL ровно один раз».
- Это ложь! Брокер Kafka и СУБД PostgreSQL не участвуют в общей распределенной XA-транзакции (2PC).
- Если консьюмер сохранил запись в PostgreSQL, но упал до вызова
commitSync()в Kafka, при рестарте он вычитает то же сообщение снова и выполнит повторныйINSERT. - Решение проблемы в Enterprise:
- Идемпотентный консьюмер:
INSERT ... ON CONFLICT DO UPDATEпо бизнес-ключу (idempotency_key). - Transactional Outbox / Inbox Pattern: обработка сообщения и сохранение его ID в таблицу
processed_eventsв единой ACID-транзакции локальной БД.
- Идемпотентный консьюмер:
Зависание read_committed консьюмеров (Last Stable Offset — LSO)
Консьюмер с isolation.level=read_committed читает сообщения только до отметки LSO (Last Stable Offset):
- LSO — это смещение первой незавершенной (open/in-flight) транзакции.
- Если транзакционный продюсер открыл транзакцию и «подвис» на 15 минут, все последующие закоммиченные транзакции других продюсеров будут заблокированы для вычитки, пока координатор не прибьет зависшую транзакцию по таймауту
transaction.timeout.ms.
4 Tricky Questions
1. Почему Kafka из коробки не может гарантировать Exactly-Once при вычитке из Kafka и сохранении данных в PostgreSQL, и как инженер обязан решить эту проблему?
Ответ:
- Встроенная транзакционная модель Kafka (Transaction API) координирует запись исключительно между партициями самой Kafka и служебным топиком оффсетов
__consumer_offsets. Kafka не умеет управлять коммитами внешних систем (PostgreSQL, Redis, ElasticSearch, внешний REST API). - Если консьюмер записал строку в таблицу PostgreSQL, а затем у него моргнула сеть или упал контейнер до фиксации оффсета в Kafka, при рестарте он получит сообщение повторно.
- Инженерные решения:
- Идемпотентная запись в СУБД (Upsert /
ON CONFLICT (payment_id) DO NOTHING). - Паттерн Inbox (Идемпотентный приемник): в рамках одной транзакции PostgreSQL вставляется бизнес-сущность и запись в служебную таблицу
inbox_messages (message_id PRIMARY KEY). Повторное сообщение отсекается ошибкой уникальности первичного ключа.
- Идемпотентная запись в СУБД (Upsert /
2. Что такое Last Stable Offset (LSO), и как зависшая транзакция одного продюсера может устроить Denial of Service для консьюмеров с isolation.level=read_committed?
Ответ:
- LSO (Last Stable Offset) — это минимальное смещение среди всех активных незавершенных транзакций в партиции.
- Консьюмер, работающий в режиме
read_committed, имеет право вычитывать сообщения строго до отметки LSO, даже если за этой отметкой уже лежат сотни тысяч сообщений от других успешно завершенных транзакций. - Если продюсер начал транзакцию со смещением 1000 и завис, LSO останется равным 1000. Сообщения с оффсетами 1001, 1002 и выше не будут отданы консьюмеру, что выглядит как «зависание» консьюмера и взрывной рост лага.
- Выход из блокировки происходит только после принудительного отката транзакции брокером по истечении
transaction.timeout.ms(по умолчанию 60 секунд), после чего брокер пишет маркерABORT, и LSO сдвигается вперед.
3. Почему начиная с Kafka 3.0 настройки enable.idempotence=true и acks=all включены по умолчанию, и можно ли потерять сообщение даже при acks=all?
Ответ:
- Kafka перешла на подход «Secure and Reliable by Default»: разработчикам больше не нужно вручную прописывать надежные параметры, защита от дублей продюсера включена из коробки без существенной деградации throughput.
- Как потерять сообщение при
acks=all:- Если на брокере параметр
min.insync.replicasоставлен в значении1(дефолт во многих старых установках). - В такой конфигурации
acks=allтребует подтверждения только от реплик из списка ISR (In-Sync Replicas). Если в ISR остался только один Лидер, он записывает сообщение в свой локальный журнал и подтверждает запись продюсеру. Если сразу после этого нода с лидером сгорает до репликации на фолловеры, сообщение теряется навсегда! - Правило надёжности:
replication.factor >= 3иmin.insync.replicas >= 2.
- Если на брокере параметр
4. В чем разница между commitSync() и commitAsync() в консьюмере при реализации семантики At-least-once, и почему их часто комбинируют?
Ответ:
commitSync()блокирует поток консьюмера до получения ответа от брокера. При сбоях он автоматически повторяет попытку коммита. Он максимально надежен, но снижает пропускную способность (throughput) из-за сетевых пауз.commitAsync()не блокирует поток и не выполняет retry при ошибке. Если коммит оффсета 200 завершился ошибкой из-за тайм-аута, а следом ушел коммит оффсета 300, повторная отправка оффсета 200 привела бы к откату назад (out-of-order commit). ПоэтомуcommitAsync()не делает retry.- Идеальный паттерн (Best Practice): в основном цикле обработки вызывают быстрый
commitAsync(), а в блокеfinallyпри завершении работы приложения или вonPartitionsRevoked()вызывают блокирующийcommitSync(), гарантируя сохранение финального состояния.
🎯 Шпаргалка для интервью
Главное о гарантиях доставки
- At-most-once: сообщение может потеряться, дублей нет (
acks=0, автокоммит до обработки). - At-least-once (дефолт индустрии): сообщения не теряются, возможны дубли (
acks=all,min.insync.replicas=2, ручной коммит после записи). - Exactly-once: только для контура Kafka-to-Kafka через транзакции (
transactional.id,isolation.level=read_committed). - Сквозной контракт: надежность складывается из триады «Продюсер + Брокер + Консьюмер».
- Интеграция с БД: для гарантии ровно один раз во внешних системах необходимы идемпотентные ключи или Outbox/Inbox паттерны.
- LSO блокировка: незавершенная транзакция блокирует чтение последующих сообщений для
read_committed. - Защита от сбоев брокера:
acks=allработает надежно ТОЛЬКО приmin.insync.replicas >= 2.
Сравнение уровней семантики
| Критерий | At-most-once | At-least-once | Exactly-once (Kafka-to-Kafka) | | :— | :— | :— | :— | | Потеря данных | Возможна | ❌ Исключена | ❌ Исключена | | Дублирование | ❌ Исключено | Возможно | ❌ Исключено | | Throughput | Максимальный | Высокий | Средний (-10-30% overhead) | | Сложность внедрения | Минимальная | Средняя (нужна идемпотентность) | Высокая |
Красные флаги на собеседовании (Чего говорить нельзя)
- ❌ «Kafka Exactly-Once гарантирует, что в нашей базе данных не будет дублей без дополнительных проверок» — Kafka не управляет базой данных.
- ❌ «acks=all гарантирует 100% сохранность даже при min.insync.replicas=1» — если упадет единственный брокер в ISR, данные будут утеряны.
- ❌ «Для надежности достаточно просто поставить acks=all на продюсере» — если консьюмер автокоммитит до сохранения, данные потеряются на стороне консьюмера.