📨 Раздел 15 · Вопрос #14

В чём разница между auto commit и manual commit

Разница заключается в том, кто и в какой момент фиксирует прогресс чтения в брокере:


🟢 Junior Level

30-секундный ответ

Разница заключается в том, кто и в какой момент фиксирует прогресс чтения в брокере:

  • Auto Commit (enable.auto.commit = true): клиентская библиотека Kafka автоматически сохраняет оффсеты через фиксированные интервалы времени (auto.commit.interval.ms, по умолчанию 5 секунд). Сохраняются оффсеты полученных, но еще не обязательно успешно обработанных сообщений.
  • Manual Commit (enable.auto.commit = false): приложение берет полный программный контроль на себя и отправляет команду коммита (commitSync() / commitAsync()) строго после того, как бизнес-логика успешно отработала и сохранила результат в базу данных.

Наглядная аналогия из жизни

Представьте автосохранение документа в текстовом редакторе:

  • Auto Commit: программа автоматически жмет Ctrl+S каждые 5 секунд, даже если вы случайно вставили испорченный текст или файл поврежден. Если программа упадет посреди записи, документ останется в непредсказуемом состоянии.
  • Manual Commit: вы нажимаете кнопку «Сохранить» только тогда, когда лично перечитали отчет, исправили все опечатки и убедились в его идеальном качестве.

Сравнение поведения при сбое

Сценарий: Обработка сообщения завершилась аварийным падением JVM (Crash)

1. При Auto Commit:
   Вычитан Offset 10 -> Сработал таймер 5 сек (Offset 10 закоммичен в Kafka) ->
   JVM упала -> Рестарт сервиса -> Чтение начинается с Offset 11!
   ❌ Итог: Сообщение 10 потеряно навсегда (Data Loss / At-Most-Once).

2. При Manual Commit:
   Вычитан Offset 10 -> Обработка упала -> Коммит НЕ вызывался ->
   JVM упала -> Рестарт сервиса -> Чтение начинается с Offset 10!
   ✅ Итог: Сообщение 10 перечитывается и обрабатывается заново (At-Least-Once).

🟡 Middle Level

Когда на самом деле срабатывает Auto Commit?

Распространенное заблуждение: многие считают, что автокоммит выполняется отдельным фоновым таймером в фоновом потоке. Это не так!

  • Все операции автокоммита выполняются внутри клиентского метода consumer.poll().
  • При каждом вызове poll() клиент проверяет: System.currentTimeMillis() - lastCommitTime >= autoCommitIntervalMs.
  • Если условие истинно, клиент отправляет асинхронный запрос коммита последнего возвращенного оффсета.
  • Следствие: если ваш цикл обработки одной записи завис на 30 секунд, никакой автокоммит не сработает до тех пор, пока управление снова не вернется в метод poll().

Варианты Manual Commit в Java

Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "order-service");
// 1. Обязательно отключаем автокоммит
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");

try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props)) {
    consumer.subscribe(List.of("orders"));

    while (running) {
        ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(200));
        for (ConsumerRecord<String, String> record : records) {
            processOrder(record.value());
        }

        // 2. Ручной коммит строго после завершения обработки всей пачки
        try {
            consumer.commitSync(); // или consumer.commitAsync()
        } catch (CommitFailedException e) {
            log.error("Ребалансировка группы перехватила партицию!", e);
        }
    }
}

Режимы AckMode в Spring Kafka

В экосистеме Spring Boot разработчики обычно не работают напрямую с KafkaConsumer, а настраивают ContainerProperties.AckMode:

Режим Spring AckMode Описание механики
RECORD Коммит отправляется брокеру после обработки каждого отдельного сообщения.
BATCH (по умолчанию) Коммит отправляется после обработки всей пачки сообщений, возвращенной методом poll().
TIME Коммит выполняется, если с прошлого коммита прошло заданное время ackTime.
COUNT Коммит выполняется после обработки заданного количества сообщений ackCount.
MANUAL Разработчик сам вызывает acknowledgment.acknowledge(), коммит копится до конца батча.
MANUAL_IMMEDIATE Коммит отправляется брокеру немедленно в момент вызова acknowledgment.acknowledge().

🔴 Senior Level

Архитектурные гарантии доставки: At-Most-Once vs At-Least-Once vs EOS

Выбор между Auto и Manual Commit определяет базовую семантику доставки всей распределенной системы:

  1. At-Most-Once (максимум один раз):
    • Характерно для Auto Commit.
    • Оффсет фиксируется до завершения надежной обработки. При падениях сообщения теряются, но дубликаты исключены.
    • Применимо исключительно для некритичных данных с высокой частотой: метрики, трекинг кликов, телеметрия IoT.
  2. At-Least-Once (минимум один раз):
    • Достигается через Manual Commit строго после обработки.
    • Потери данных исключены, однако при авариях возможна повторная обработка сообщений.
    • Требует обязательной идемпотентности на стороне потребителя (дедупликация по messageId в Redis/PostgreSQL).
  3. Exactly-Once Semantics (ровно один раз):
    • Невозможно достичь простым commitSync().
    • Требует объединения чтения из Kafka и записи в Kafka в единую распределенную транзакцию с помощью координатора транзакций и метода KafkaProducer.sendOffsetsToTransaction().

Transactional Offset Commit (Read-Process-Write)

Когда сервис вычитывает сообщение из топика $A$ и пишет результат в топик $B$, смещение топика $A$ фиксируется не через commitSync(), а включается в транзакцию продюсера:

producer.beginTransaction();
try {
    for (ConsumerRecord<String, String> record : records) {
        String result = transform(record.value());
        producer.send(new ProducerRecord<>("output-topic", record.key(), result));
    }

    // Коммит оффсетов консьюмера в рамках транзакции продюсера!
    Map<TopicPartition, OffsetAndMetadata> offsetsToCommit = Map.of(
        new TopicPartition(record.topic(), record.partition()),
        new OffsetAndMetadata(record.offset() + 1)
    );
    
    producer.sendOffsetsToTransaction(offsetsToCommit, consumer.groupMetadata());
    producer.commitTransaction(); // Атомарная фиксация данных и оффсетов (2PC)
} catch (ProducerFencedException | OutOfOrderSequenceException e) {
    producer.abortTransaction();
}

4 Tricky Questions

1. В каком потоке физически выполняется Auto Commit, и почему настройка auto.commit.interval.ms = 1000 не зафиксирует оффсет через 1 секунду, если цикл обработки сообщений длится 20 секунд?

Ответ:

  • В KafkaConsumer нет фонового потока для автокоммитов. Клиент Kafka работает в однопоточной модели.
  • Проверка и отправка автокоммита зашиты внутрь реализации метода poll().
  • Если поток вычитал батч и тратит 20 секунд на обработку (например, тяжелый I/O или синхронный HTTP), метод poll() не вызывается в течение всех этих 20 секунд.
  • Никакого коммита через 1 секунду не произойдет. Оффсет будет отправлен только тогда, когда цикл завершит обработку и снова вызовет consumer.poll().

2. Почему Auto Commit может привести к потере данных (At-Most-Once), даже если разработчик не вызывал никаких методов коммита вручную?

Ответ:

  • Представим сценарий:
    1. Вызов poll() вернул 100 сообщений (оффсеты 1-100).
    2. Обработка заняла 6 секунд (что больше дефолтного интервала auto.commit.interval.ms = 5000).
    3. Цикл завершился, вызывается следующий poll(). В этот момент консьюмер видит, что интервал 5000 мс истек, и автоматически отправляет в брокер коммит оффсета 101.
    4. Новый вызов poll() вернул очередную сотню сообщений (оффсеты 101-200).
    5. Посреди обработки сообщения 105 приложение аварийно падает (OOM, kill -9, отключение питания).
    6. При рестарте группа начинает чтение с оффсета 101, но если сбой произошел в пункте 3 прямо перед выполнением бизнес-логики нового батча, эти данные считаются брокером закоммиченными и пропускаются навсегда.

3. Почему опасно вызывать consumer.commitSync() вручную внутри метода Spring @KafkaListener?

Ответ:

  • Spring Kafka (KafkaMessageListenerContainer) полностью инкапсулирует жизненный цикл консьюмера и управление потоком poll().
  • Если внутри @KafkaListener вызывать нативный commitSync() в обход Spring-контейнера:
    1. Возникает конфликт синхронизации с внутренними механизмами AckMode контейнера.
    2. Spring может попытаться закоммитить оффсет повторно в конце итерации батча, вызывая избыточную сетевую нагрузку.
    3. При возникновении ошибки в бизнес-логике Spring не сможет корректно применить обработчик ошибок (DefaultErrorHandler, DLT), так как вы уже закоммитили оффсет в брокере, и повторная отправка в Dead Letter Topic станет невозможной.
  • Правильное решение в Spring: использовать Acknowledgment.acknowledge() вместе с AckMode.MANUAL или MANUAL_IMMEDIATE.

4. В чем разница между AckMode.MANUAL и AckMode.MANUAL_IMMEDIATE в Spring Kafka, и когда выбор критичен для производительности?

Ответ:

  • MANUAL: вызов acknowledgment.acknowledge() лишь выставляет в памяти флаг готовности коммита для конкретного сообщения. Физический сетевой запрос в Kafka отправляется контейнером только после того, как все записи из текущего батча poll() будут обработаны. Это минимизирует сетевой оверхед (один коммит на батч).
  • MANUAL_IMMEDIATE: вызов acknowledgment.acknowledge() немедленно инициирует синхронный или асинхронный сетевой вызов commitSync / commitAsync на каждый вызов метода.
  • Когда критично: если батч содержит 500 сообщений, MANUAL_IMMEDIATE создаст 500 сетевых обращений к брокеру подряд, что обрушит пропускную способность консьюмера в десятки раз. Его стоит использовать только для редких, сверхкритичных транзакций с одиночными сообщениями.

🎯 Шпаргалка для интервью

Главное о сравнении

  • Auto Commit: фоновый в рамках poll(), по таймеру auto.commit.interval.ms. Не знает, успешна ли бизнес-логика. Риск потери данных.
  • Manual Commit: полный контроль разработчика. Коммит только после гарантии успешной обработки и сохранения в БД.
  • At-Least-Once: достигается через ручной коммит + идемпотентная обработка на приемнике.
  • Spring Kafka: используйте AckMode.MANUAL с интерфейсом Acknowledgment, никогда не вызывайте нативный consumer.commitSync() вручную внутри слушателя.

Сравнительная таблица

| Параметр | Auto Commit | Manual Commit | | :— | :— | :— | | Параметр конфигурации | enable.auto.commit = true | enable.auto.commit = false | | Момент коммита | Внутри poll() по таймеру | Явный вызов в коде разработчика | | Гарантия доставки | At-Most-Once (возможна потеря) | At-Least-Once (возможны дубли) | | Сложность кода | Минимальная (из коробки) | Требует обработки ошибок и ревалансов | | Применимость в Prod | ❌ Метрики, логи, аналитика | ✅ Финансы, заказы, критичные данные |

Красные флаги на собеседовании (Чего говорить нельзя)

  • ❌ «Автокоммит выполняется независимым фоновым демоном раз в 5 секунд» — он выполняется строго внутри потока poll().
  • ❌ «Для надежной системы в продакшене достаточно включить автокоммит» — автокоммит гарантирует потерю сообщений при падениях.
  • ❌ «В методе @KafkaListener я всегда вызываю consumer.commitSync()» — грубая ошибка архитектуры Spring Kafka, ломающая DefaultErrorHandler.

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