📨 Раздел 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 в высоконагруженных распределенных системах убивает производительность и порождает зависшие блокировки.

Связанные вопросы