📨 Розділ 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 на продюсері» — якщо консьюмер автокомітить до збереження, дані загубляться на стороні консьюмера.

Пов’язані запитання