Всім привіт. Я Шевченко Владислав, Team Lead/Senior Java Developer. Детальніше про мене тут - LinkedIn.
Останніми тижнями я пройшов ряд інтерв’ю в різні компанії, і мене здивувало, як часто підіймається тема Apache Kafka — exactly-once delivery.
Отож хочу висловити пару думок щодо цієї популярної теми.
Маленький Спойлер…
А що якщо я відразу візьму на себе сміливість і заявлю що exactly-once в мікросервісній архітектурі не особо то й треба?
Якщо після спойлера ви ще тут тоді поїхали…
Трохи базової теорії.
Kafka підтримує три рівні гарантій доставки (delivery semantics):
1. At-Most-Once (максимум один раз) — повідомлення може бути втрачене, але ніколи не дублюється.
2. At-Least-Once (принаймні один раз) — повідомлення ніколи не втрачається, але може дублюватися.
3. Exactly-Once (точно один раз) — повідомлення доставляється рівно один раз.
Але чомусь з мого останнього досвіду, на співбесідах в контексті розмови exactly-once в мікросервісах часто опускали перші 2 пункти і відразу переходять до 3-го пункту:
Нас цікавить найсмачніший — Exactly-Once.
Ще й до того - це питання звучить дуже загально, без деталей, без врахування архітектури системи, наприклад:
А можете пояснити exactly-once delivery в Kafka?
┌──────────┐ ┌───────────────┐ ┌──────────┐
│ Producer ├────────►│ Kafka ├────────►│ Consumer │
└──────────┘ └───────────────┘ └──────────┘Тут або ставати нудним і витягувати деталі, фактично корегуючи запит інтерв'ювера або ж відповідати в лоб.
Ну окей, справедливості ради треба відмітити, що деколи в такому форматі запитували на загальному, швидкому pre-screen interview, щоб зрозуміти чи орієнтується кандидат у темі, в такому кейсі — годиться.
Давайте відповідати в лоб
Думаю відповідь має бути теж коротенька, швидка щось на кшталт цього:
Kafka Exactly-Once складається з трьох компонентів:
1. Idempotent Producer - це гарантує, що повідомлення не буде дубльовано в межах однієї партиції (сесії), навіть якщо продюсер надішле його кілька разів (retries) через мережеві помилки.
2. Transactional Producer - транзакції дозволяють атомарно записати дані та закомітити offset-и в одному пакеті.
3. Transactional Consumer - (isolation.level=read_committed) дозволяє Consumer бачити лише успішно зафіксовані (committed) дані й ігнорувати ті, що були відкочені (aborted).
Як на мене, то виглядає цілком ґрунтовною та достатньою відповідю для pre-screen interview.
NOTE: Давайте запам'ятаємо цю комбінацію Idempotent Producer + Transactional Producer + Transactional Consumer та назвемо її "ідеальною комбінацією"
Після pre-screen interview зазвичай проводять більш серйозні technical interview де все значно цікавіше та й на реальних проєктах exactly-once реалізується по-різному.
Тому далі розглянемо exactly-once детальніше на прикладі мікросервісної архітектури з Order Service, Payment Service, базами даних та Kafka як брокером між ними.
Погнали
Exactly Once Delivery в мікросервісній архітектурі
Це вже цікавіше і дає можливість оцінити реальний бойовий досвід кандидата.
NOTE: Задача максимально спрощена, щоб не втрачати фокус комунікації з Kafka Broker, але при цьому розглянути більш-менш реальний кейс.
Опис задачі:
1. Order Service — приймає HTTP-запит, зберігає order в свою базу, відправляє OrderCreated event в Kafka.
2. Payment Service — читає OrderCreated з Kafka, створює payment в своїй базі, відправляє PaymentCreated event в Kafka.

Виглядає просто, правда? Але тут ціла купа місць, де все може піти не так.
Потенційні проблеми
1. Order Service: не атомарність запису DB + Kafka
Order записаний у базу, але сервіс впав до відправки в Kafka
Або навпаки: event відправлений у Kafka, але DB-транзакція відкотилась
Результат: неконсистентність між базою та Kafka

2. Order Service: дублікат OrderCreated event
Kafka producer відправив event, але не отримав ACK (мережева помилка)
Retry створює дублікат у топіку
Payment Service обробить один order двічі

3. Payment Service: дублікат обробки OrderCreated
Payment Service прочитав event, створив payment
Впав до коміту offset
Після рестарту — обробляє
OrderCreatedвдруге → дублікат payment

Знову спойлер: тут вже недостатньо просто увімкнути `enable.idempotence=true` та `isolation.level=read_committed`.
Кожна з цих проблем потребує свого підходу до вирішення. Тож давайте step by step розберемо кожну з них.
Вриваємось…та рішаємо
Рішення 1-ї проблеми: Order Service — не атомарність DB + Kafka
Нагадаю суть: ми маємо дві незалежні системи (DB та Kafka), і нам потрібно записати дані в обидві атомарно.
Рішення: Transactional Outbox Pattern
Ідея проста — не пишемо в Kafka напряму. Замість цього:
1. В рамках однієї DB-транзакції зберігаємо і order, і order_created_event в спеціальну таблицю order_event_outbox
2. Окремий процес (relay/poller або CDC) читає з order_event_outbox і відправляє в Kafka

Тепер атомарність гарантована на рівні DB-транзакції. Якщо транзакція відкотилась — ні order, ні event в outbox не з'являться. Якщо закомітилась — Outbox Producer рано чи пізно відправить event у Kafka.
Важливий нюанс: Outbox Producer може впасти після відправки в Kafka, але до позначення event як published. Після рестарту він відправить event повторно. Тобто Outbox Pattern гарантує at-least-once доставку в Kafka, а не exactly-once. І це підводить нас до наступної проблеми.
Рішення 2-ї проблеми: Order Service — дублікат OrderCreated event
Тепер ми знаємо, що дублікати можуть з'явитись з двох джерел:
Kafka producer retry (ACK загублений в мережі)
Outbox producer retry (producer впав після publish, але до mark as published)
Рішення: Idempotent Producer + Transactional Producer
Idempotent Producer: (enable.idempotence=true) вирішує проблему мережевих retry в рамках однієї сесії — Kafka-брокер відхиляє дублікати за PID + sequence number.
Transactional Producer: (transactional.id) вирішує проблему retry після рестарту — стабільний transactional.id дозволяє Kafka зв'язати нову сесію зі старою та продовжити дедуплікацію.

Таким чином, комбінація Outbox Pattern + Idempotent/Transactional Producer дає нам exactly-once запис в Kafka на стороні Order Service.
Рішення 3-ї проблеми: Payment Service — дублікат обробки OrderCreated
Що ж, виглядає що у нас є супер крутий producer без дублікатів, і ми розраховували на Transactional Consumer з isolation.level=read_committed, але нюанс все ж таки залишився:
consumer все одно може обробити повідомлення двічі, кейс - "crash до коміту offset"
Transactional Consumer з `isolation.level=read_committed` захищає лише від читання незакомічених або відкочених транзакційних повідомлень. Тобто це фільтр на стороні consumer-а: "не показуй мені aborted messages".
Але не вирішує наступну ситуацію:
Повідомлення OrderCreated — це валідне, закомічене повідомлення в Kafka
Consumer його прочитав, обробив (записав payment у DB), але впав до коміту offset
Після рестарту consumer знову читає те саме закомічене повідомлення
read_committed пропускає його, бо воно committed — все чесно
Проблема не в тому, що consumer читає, а в тому, що обробка (DB write) та коміт offset — не атомарні. read_committed цю проблему не вирішує взагалі.
Тому єдине надійне рішення — Idempotent Consumer (unique constraint або processed_events таблиця)
Idempotent Consumer
Ідея — consumer сам відповідає за дедуплікацію на своїй стороні. Кожне повідомлення містить унікальний ідентифікатор наприклад orderId і consumer перевіряє чи вже оброблялось це повідомлення.
Варіанти реалізації:
Unique constraint в DB

Окрема таблиця processed_events

В обох варіантах дедуплікація відбувається на рівні DB-транзакції, що робить consumer ідемпотентним — повторна обробка того самого повідомлення не створить дублікатів.
А чи потрібен нам взагалі Exactly-Once на стороні Producer?
Зупинімось і подумаємо. Ми щойно з'ясували, що Idempotent Consumer нам потрібен у будь-якому разі — навіть з ідеальним exactly-once producer, consumer може обробити повідомлення двічі (crash до коміту offset). Тоді виникає логічне питання: якщо consumer і так обробляє дублікати — навіщо ускладнювати producer транзакціями?
Тобто замість того, щоб гарантувати exactly-once на кожному етапі ланцюжка, ми:
Гарантуємо, що повідомлення точно дійде (at-least-once через Outbox Pattern)
Гарантуємо, що consumer коректно обробить дублікати (Idempotent Consumer)
І в результаті маємо exactly-once processing — кожне повідомлення оброблене рівно один раз. Це підводить до думки
At-Least-Once delivery + Idempotent Consumer = Exactly-Once processing
Порівняння підходів
Підхід A: Exactly-Once на всіх рівнях
Outbox Pattern + Idempotent Producer + Transactional Producer + Transactional Consumer (read_committed) + Idempotent Consumer
Підхід B: At-Least-Once + Idempotent Consumer
Outbox Pattern + стандартний Producer (acks=all, retries>0) + Idempotent Consumer
Критерій | Підхід A (Exactly-Once everywhere) | Підхід B (At-Least-Once + Idempotent Consumer) |
|---|---|---|
Складність розробки | Висока — потрібно налаштувати transactional.id, керувати транзакціями producer-а, read_committed на consumer-ах | Низька — стандартний producer + дедуплікація на стороні consumer-а |
Перформанс Producer | Нижчий — Kafka-транзакції додають overhead (InitPID, BeginTxn, EndTxn, додаткові round-trip до брокера) | Вищий — немає транзакційного overhead на producer-і |
Перформанс Consumer |
| Читає одразу, без очікування на transaction markers |
Latency | Вища — транзакційний коміт додає latency на кожне повідомлення або батч | Нижча — стандартний produce без транзакцій |
Throughput | Нижчий — транзакції обмежують батчинг та паралелізм | Вищий — повна свобода батчингу |
Надійність | Дублікати майже виключені на рівні Kafka, але Idempotent Consumer все одно потрібен | Можливі дублікати в топіку, але consumer їх коректно відхиляє |
Операційна складність | Вища — потрібно моніторити транзакції, transaction timeout, zombie fencing | Нижча — менше рухомих частин |
Коли використовувати | Kafka Streams (consume-transform-produce), багато consumer-ів на одному топіку, критично мінімізувати дублікати | Мікросервіси з DB, більшість продакшн-систем |
Ну от і приїхали…
Давайте пригадаємо нашу ідеальну комбінацію з pre-screen interview - Idempotent Producer + Transactional Producer + Transactional Consumer. Після детальнішого розгляду на "більш-менш реальних prod прикладах" я приходжу до висновку, що ідеальною стає комбінація At-Least-Once delivery + Idempotent Consumer = Exactly-Once processing.
Звісно, що комбінація з pre-screen має право на життя та набуває сенсу для Kafka-to-Kafka сценарію (наприклад, Kafka Streams: consume-transform-produce), але для мікросервісів із зовнішніми базами, компонентами на мою думку At-Least-Once delivery + Idempotent Consumer краще рішення.
Головне — розуміти, що "exactly-once" в розподілених системах — це завжди комбінація підходів, а не одна чарівна конфігурація.