Друкарня від WE.UA

Exactly Once Delivery Semantics в Apache Kafka

Зміст

Всім привіт. Я Шевченко Владислав, 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 на кожному етапі ланцюжка, ми:

  1. Гарантуємо, що повідомлення точно дійде (at-least-once через Outbox Pattern)

  2. Гарантуємо, що 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

read_committed додає затримку — consumer чекає на transaction marker перед читанням

Читає одразу, без очікування на 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" в розподілених системах — це завжди комбінація підходів, а не одна чарівна конфігурація.

Статті про вітчизняний бізнес та цікавих людей:

Поділись своїми ідеями в новій публікації.
Ми чекаємо саме на твій довгочит!
Vladyslav Shevchenko
Vladyslav Shevchenko@vladyslav_shevhcneko_ua we.ua/vladyslav_shevhcneko_ua

Team Lead/ Senior Java Dev

1Довгочити
10Прочитання
0Підписники
На Друкарні з 15 лютого

Це також може зацікавити:

Коментарі (0)

Підтримайте автора першим.
Напишіть коментар!

Це також може зацікавити: