📨 Розділ 15 · Питання #10

У чому різниця між 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:

  1. Сервіс в одній ACID-транзакції PostgreSQL пише замовлення в таблицю orders і подію в таблицю outbox_table.
  2. Debezium читає WAL журналу PostgreSQL і гарантовано доставляє подію в Kafka із семантикою At-least-once.
  3. Консьюмер на іншому кінці дедуплікує події за первинним ключем.

Внутрішня будова Transaction Coordinator в EOS

Коли продюсер відправляє транзакційні дані в Kafka:

  1. initTransactions(): реєструє transactional.id у координатора транзакцій. Координатор збільшує producer_epoch і відгороджує (fences) будь-які старі зомбі-інстанси з тим самим ID.
  2. beginTransaction(): відкриває локальну транзакцію на клієнті.
  3. send(): повідомлення йдуть у партиції з прапорцем транзакційності. Брокер зберігає їх у журналі, але приховує від консьюмерів із read_committed.
  4. commitTransaction():
    • Фаза 1: Координатор пише статус PREPARE_COMMIT у службовий топік __transaction_state.
    • Фаза 2: Координатор паралельно пише маркери COMMIT у всі задіяні користувацькі партиції та топік __consumer_offsets.
    • Фаза 3: Координатор пише статус COMPLETE_COMMIT.
  5. Консьюмер із 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 тягне за собою вимірні накладні витрати за ресурсами та часом відгуку:
    1. Latency (Затримка): збільшується у 2–3 рази. Консьюмер не може прочитати повідомлення одразу після запису: він зобов’язаний чекати, поки продюсер не надішле маркер COMMIT і координатор не зсуне позначку LSO (Last Stable Offset).
    2. Write Amplification (Навантаження на диск і мережу): на кожну транзакцію брокер зобов’язаний додатково записати маркери керування в партиції топіка і стан у топік __transaction_state з виконанням fsync.
    3. Зниження Throughput: часті дрібні транзакції (коміт на кожне окреме повідомлення) катастрофічно просаджують пропускну здатність (аж до падіння на 70–80%). Транзакції в Kafka ефективні лише при батчингу (коміт раз на 100–500 мс або на пачку із сотень повідомлень).

🎯 Шпаргалка для інтерв’ю

Головне про відмінності гарантій доставки

  • 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 у високонавантажених розподілених системах вбиває продуктивність і породжує завислі блокування.

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