У чому різниця між at-most-once, at-least-once і exactly-once
Різниця між трьома семантиками доставки зводиться до того, як система реагує на збої мережі, серверів і повторні спроби (retries):
🟢 Junior Level
30-секундна відповідь
Різниця між трьома семантиками доставки зводиться до того, як система реагує на збої мережі, серверів і повторні спроби (retries):
- At-most-once (Не більше одного разу): $0 \le N \le 1$. Продюсер відправляє дані в режимі «fire-and-forget» без повторів, а консьюмер комітить офсет до фактичної обробки. Якщо щось впало — повідомлення втрачається назавжди. Дублів немає, втрат багато.
- At-least-once (Як мінімум один раз): $N \ge 1$. Продюсер повторює відправлення при помилках до підтвердження брокером, а консьюмер комітить офсет суворо після успішного запису в БД. Повідомлення не втрачаються ніколи, але через збої мережі можуть прийти дублікати. Це дефолтний стандарт індустрії.
- Exactly-once (Рівно один раз, EOS): $N = 1$. Повідомлення доставляється і обробляється суворо один раз без втрат і дублікатів. У Kafka це працює атомарно через Transaction API для сценаріїв вичитування та запису всередині кластера (Kafka $\to$ Обробка $\to$ Kafka).
Наочна аналогія з життя
- At-most-once — оголошення по гучномовцю на вокзалі: диктор один раз оголосив платформу відправлення поїзда. Якщо пасажир відволікся або зашумів поїзд — оголошення пропущено. Повторів немає, ризику почути двічі немає.
- At-least-once — кур’єрська доставка піци з оплатою при отриманні: кур’єр дзвонить у двері, поки клієнт не відчинить. Якщо домофон збоїть, кур’єр може передзвонити двічі або приїхати повторно. Піца гарантовано доставлена, але клієнт може отримати два дзвінки.
- Exactly-once — безготівковий платіж банківською карткою з Idempotency Key: клієнт натиснув кнопку «Оплатити», термінал відправив унікальний
UUIDплатежу. Навіть якщо інтернет моргне і термінал надішле запит повторно, банк спише гроші з рахунку суворо один раз.
Візуальне порівняння сценаріїв збою
Сценарій: Падіння сервера консьюмера в процесі обробки
At-most-once:
[ Читання ] ──► [ Коміт офсету ] ──► [ Вибух 💥 ] ──► [ Збереження в БД ] (НЕ ВИКОНАНО!)
Результат: Повідомлення втрачено назавжди, офсет зміщено вперед.
At-least-once:
[ Читання ] ──► [ Збереження в БД ] ──► [ Вибух 💥 ] ──► [ Коміт офсету ] (НЕ ВИКОНАНО!)
Рестарт: Консьюмер знову читає те саме повідомлення ──► Повторне збереження в БД (ДУБЛІКАТ).
Exactly-once (Kafka Transaction):
[ Читання + Транзакція ] ──► [ Запис у Kafka-топік ] ──► [ Атомарний коміт офсету ]
Результат: Або обидві дії зафіксовані, або обидві відкочені (чистий стан).
🟡 Middle Level
Матриця конфігурації всіх трьох режимів
Щоб система працювала в потрібному режимі, параметри продюсера, брокера і консьюмера повинні бути узгоджені:
| Компонент | At-most-once | At-least-once | Exactly-once (Kafka-to-Kafka) |
|---|---|---|---|
Producer acks |
0 (або 1) |
all (чекаємо кворум ISR) |
all |
Producer retries |
0 |
Integer.MAX_VALUE |
Integer.MAX_VALUE |
| Producer Idempotence | false |
true |
true |
| Producer Transactional | Не використовується | Не використовується | transactional.id = "tx-producer-1" |
| Broker Replication | min.insync.replicas=1 |
min.insync.replicas=2 |
min.insync.replicas=2 |
| Consumer Auto-commit | true |
false |
false |
| Consumer Isolation | read_uncommitted |
read_uncommitted |
read_committed |
| Момент коміту | Одразу при отриманні | Після збереження у сховищі | Всередині producer.sendOffsetsToTransaction() |
Патерн «At-least-once + Ідемпотентний консьюмер» у Spring Boot 3
Оскільки побудувати чистий Exactly-once за межі Kafka (у БД PostgreSQL) неможливо силами одного брокера, у 99% мікросервісів використовують зв’язку At-least-once доставки з дедуплікацією на стороні БД:
@Service
@RequiredArgsConstructor
public class IdempotentPaymentConsumer {
private final JdbcTemplate jdbcTemplate;
@Transactional
@KafkaListener(topics = "payments", groupId = "billing-group")
public void consume(PaymentEvent event, Acknowledgment ack) {
// Ідемпотентна вставка: ON CONFLICT DO NOTHING
// Таблиця payments має UNIQUE(payment_id)
int updated = jdbcTemplate.update("""
INSERT INTO payments (id, user_id, amount, status)
VALUES (?, ?, ?, 'SUCCESS')
ON CONFLICT (id) DO NOTHING
""", event.id(), event.userId(), event.amount());
if (updated == 0) {
log.warn("Виявлено дублікат повідомлення з ID: {}. Пропускаємо обробку.", event.id());
} else {
processBalanceUpdate(event);
}
// Ручний коміт зміщення суворо після завершення транзакції БД
ack.acknowledge();
}
}
🔴 Senior Level
Проблема подвійного запису (Dual-Write Problem) та Transactional Outbox
Архітектурна катастрофа багатьох мікросервісів:
// АНТИПАТЕРН: Dual-Write
@Transactional
public void createOrder(Order order) {
orderRepository.save(order); // Запис у Postgres
kafkaTemplate.send("orders", order.getId(), order); // Відправлення в Kafka 💥
}
Чому це ламає і At-least-once, і Exactly-once:
- Якщо відправлення в Kafka впало через таймаут мережі, транзакція БД відкотиться, але продюсер міг встигнути залити повідомлення в брокер (фантомна подія в Kafka без замовлення в БД).
- Якщо транзакція БД закомітилася, але впав под застосунку до відправлення в Kafka, замовлення в БД є, а подія в Kafka не пішла (втрата даних).
Рішення: Transactional Outbox Pattern + Debezium CDC:
- Сервіс в одній ACID-транзакції PostgreSQL пише замовлення в таблицю
ordersі подію в таблицюoutbox_table. - Debezium читає WAL журналу PostgreSQL і гарантовано доставляє подію в Kafka із семантикою At-least-once.
- Консьюмер на іншому кінці дедуплікує події за первинним ключем.
Внутрішня будова Transaction Coordinator в EOS
Коли продюсер відправляє транзакційні дані в Kafka:
initTransactions(): реєструєtransactional.idу координатора транзакцій. Координатор збільшуєproducer_epochі відгороджує (fences) будь-які старі зомбі-інстанси з тим самим ID.beginTransaction(): відкриває локальну транзакцію на клієнті.send(): повідомлення йдуть у партиції з прапорцем транзакційності. Брокер зберігає їх у журналі, але приховує від консьюмерів ізread_committed.commitTransaction():- Фаза 1: Координатор пише статус
PREPARE_COMMITу службовий топік__transaction_state. - Фаза 2: Координатор паралельно пише маркери
COMMITу всі задіяні користувацькі партиції та топік__consumer_offsets. - Фаза 3: Координатор пише статус
COMPLETE_COMMIT.
- Фаза 1: Координатор пише статус
- Консьюмер із
read_committedбачить маркерCOMMITі віддає накопичені закомічені записи застосунку.
4 Tricky Questions
1. Чому патерн подвійного запису (Dual-Write) ламає семантику доставки, і як Transactional Outbox вирішує цю проблему без використання важкого розподіленого двофазного коміту (XA / 2PC)?
Відповідь:
- При наївному подвійному записі неможливо атомарно пов’язати коміт локальної СУБД (PostgreSQL) і відправлення в брокер (Kafka). Якщо впаде мережа або сервер між кроком 1 і кроком 2, виникне розсинхронізація: дані збережені в БД, але подія не потрапила в Kafka (втрата), або транзакція БД відкочена, а подія вже пішла підписникам (фантомні дані).
- Transactional Outbox усуває розподілену транзакцію: і бізнес-сутність, і вихідна подія зберігаються в межах однієї локальної ACID-транзакції БД у таблиці
ordersтаoutbox_events. - Далі окремий надійний агент (Debezium через читання Write-Ahead Log БД або фоновий poller) вичитує
outbox_eventsі гарантовано публікує їх у Kafka із семантикою At-least-once. Втрата виключена математично, а можливі дублікати відсікаються ідемпотентним консьюмером.
2. Чи може ввімкнення семантики Exactly-Once (EOS) у Kafka захистити систему від дублікатів, якщо зовнішній клієнт (наприклад, мобільний застосунок) двічі смикнув REST API сервісу-продюсера через збій у стільниковій мережі?
Відповідь:
- Категорично ні!
- Семантика Kafka EOS захищає від дублювання виключно всередині межі кластера Kafka (між клієнтом KafkaProducer і брокерами Kafka при retry).
- Якщо мобільний застосунок відправив HTTP POST
/orders, сервер виконав замовлення і почав відповідати200 OK, але стільникова вежа обірвала з’єднання, мобільний клієнт чесно повторить POST/ordersз новим HTTP-запитом. - Для сервісу-продюсера це два абсолютно різні бізнес-запити. Продюсер згенерує два різні повідомлення і обидва атомарно закомітить у Kafka.
- Для запобігання дублям на цьому рівні потрібно передавати наскрізний Idempotency Key від мобільного клієнта в заголовках HTTP і валідувати його в сервісі (через Redis/БД) до відправлення в Kafka.
3. Що прочитає консьюмер із налаштуванням isolation.level = read_uncommitted (значення за замовчуванням), якщо продюсер почав транзакцію, відправив 10 повідомлень, а потім викликав producer.abortTransaction()?
Відповідь:
- Консьюмер із
read_uncommittedпрочитає всі 10 повідомлень скасованої транзакції так, ніби вони були успішно закомічені! - У Kafka повідомлення пишуться в лог-сегменти послідовно в порядку надходження мережевих пакетів. Брокер не видаляє записи з логу при відкочуванні транзакції: він лише дописує в кінець логу спеціальний службовий маркер керування
ABORT. - Режим
read_uncommittedповністю ігнорує маркери транзакцій і віддає всі «брудні» дані нагору в застосунок. Це може призвести до обробки скасованих замовлень або помилкових платежів. - Для коректної роботи з транзакціями консьюмер зобов’язаний явно конфігуруватися параметром
isolation.level = read_committed.
4. У чому полягає «штраф за надійність» (Performance Overhead) при переході від At-least-once до Exactly-once у Kafka?
Відповідь:
- Перехід на EOS тягне за собою вимірні накладні витрати за ресурсами та часом відгуку:
- Latency (Затримка): збільшується у 2–3 рази. Консьюмер не може прочитати повідомлення одразу після запису: він зобов’язаний чекати, поки продюсер не надішле маркер
COMMITі координатор не зсуне позначку LSO (Last Stable Offset). - Write Amplification (Навантаження на диск і мережу): на кожну транзакцію брокер зобов’язаний додатково записати маркери керування в партиції топіка і стан у топік
__transaction_stateз виконанням fsync. - Зниження Throughput: часті дрібні транзакції (коміт на кожне окреме повідомлення) катастрофічно просаджують пропускну здатність (аж до падіння на 70–80%). Транзакції в Kafka ефективні лише при батчингу (коміт раз на 100–500 мс або на пачку із сотень повідомлень).
- Latency (Затримка): збільшується у 2–3 рази. Консьюмер не може прочитати повідомлення одразу після запису: він зобов’язаний чекати, поки продюсер не надішле маркер
🎯 Шпаргалка для інтерв’ю
Головне про відмінності гарантій доставки
- At-most-once: швидко, дешево, ненадійно. $0 \le N \le 1$. Дані втрачаються при будь-яких збоях (
acks=0, автокоміт до обробки). - At-least-once: надійно, стандарт де-факто. $N \ge 1$. Дані не втрачаються, можливі дублікати (
acks=all,min.insync.replicas=2, ручний коміт після збереження). - Exactly-once: дорого, складно, ізольовано. $N = 1$. Працює з коробки суворо для топологій Kafka-to-Kafka через Transaction API.
- Міф про наскрізний EOS: Kafka не вміє керувати транзакціями зовнішніх СУБД. На межі з БД завжди використовують Transactional Outbox та ідемпотентні консьюмери.
- Dual-write — зло: одночасний неузгоджений запис у БД і Kafka гарантовано призводить до втрати або фантомів.
- Консьюмери при EOS: зобов’язані мати
isolation.level=read_committed, інакше вони читають «брудні» скасовані транзакції.
Порівняльний чекліст
| Властивість | At-most-once | At-least-once | Exactly-once | | :— | :— | :— | :— | | Втрати даних | Можливі | ❌ Виключені | ❌ Виключені | | Дублікати | ❌ Виключені | Можливі | ❌ Виключені | | Latency | Базова ($1\times$) | Незначна ($1.1 - 1.2\times$) | Висока ($2 - 3\times$) | | Throughput | $100\%$ | $85 - 95\%$ | $50 - 70\%$ | | Основний Use Case | Логування, метрики | Замовлення, бізнес-події | Банківський кліринг (Kafka Streams) |
Червоні прапорці на співбесіді (Чого говорити не можна)
- ❌ «Exactly-once вирішує всі проблеми дублікатів на клієнтах» — повторний клік у застосунку створить два різні повідомлення.
- ❌ «Ми налаштували Exactly-once на продюсері, тому консьюмер можна не налаштовувати» — без
isolation.level=read_committedконсьюмер прочитає незакомічені та скасовані дані. - ❌ «Щоб не втрачати повідомлення, ми використовуємо XA 2PC транзакції між Kafka та PostgreSQL» — розподілений 2PC у високонавантажених розподілених системах вбиває продуктивність і породжує завислі блокування.