📨 Раздел 15 · Вопрос #9

Какие гарантии доставки сообщений предоставляет Kafka

Apache Kafka поддерживает три классических уровня семантики доставки сообщений:


🟢 Junior Level

30-секундный ответ

Apache Kafka поддерживает три классических уровня семантики доставки сообщений:

  1. At-most-once (Не более одного раза):
    • Сообщения могут теряться, но никогда не дублируются.
    • Где используется: метрики, телеметрия, логирование, где потеря отдельных записей дешевле затрат на повторную отправку.
  2. At-least-once (Как минимум один раз):
    • Сообщения гарантированно не теряются, но возможны дубликаты при сбоях сети и повторных отправках (retries).
    • Индустриальный стандарт по умолчанию: используется в 95% корпоративных систем в связке с идемпотентными получателями.
  3. 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() после записи в БД
  1. Продюсер: гарантирует, что сообщение записано на кворум брокеров. При сетевом сбое выполняет retry.
  2. Брокер: гарантирует сохранение данных на диск и синхронизацию с репликами (min.insync.replicas).
  3. Консьюмер: коммитит оффсет в __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 использует три ключевых механизма:

  1. Идемпотентный продюсер (Idempotent Producer):
    • Каждому инстансу продюсера брокер присваивает 64-битный Producer ID (PID).
    • Каждое сообщение снабжается монотонным счетчиком Sequence Number.
    • Брокер сохраняет в памяти последние 5 sequence numbers для каждого PID. Если приходит сообщение с уже известным номером, брокер возвращает ACK, но не дублирует запись в лог.
  2. Координатор транзакций (Transaction Coordinator) и топик __transaction_state:
    • Специальный брокер управляет жизненным циклом двухфазного коммита транзакции.
  3. Маркеры управления (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:
    1. Идемпотентный консьюмер: INSERT ... ON CONFLICT DO UPDATE по бизнес-ключу (idempotency_key).
    2. 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, при рестарте он получит сообщение повторно.
  • Инженерные решения:
    1. Идемпотентная запись в СУБД (Upsert / ON CONFLICT (payment_id) DO NOTHING).
    2. Паттерн Inbox (Идемпотентный приемник): в рамках одной транзакции PostgreSQL вставляется бизнес-сущность и запись в служебную таблицу inbox_messages (message_id PRIMARY KEY). Повторное сообщение отсекается ошибкой уникальности первичного ключа.

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 на продюсере» — если консьюмер автокоммитит до сохранения, данные потеряются на стороне консьюмера.

Связанные вопросы