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