Главная › Блог › Exactly-once в Kafka: идемпотентный продюсер и транзакции

Exactly-once в Kafka: идемпотентный продюсер и транзакции

Зачем вообще нужен exactly-once

По умолчанию Kafka даёт at-least-once: продюсер может переотправить сообщение после таймаута, и в топике окажется дубликат. Для логов это терпимо, для списания денег или начисления баллов — нет. Exactly-once семантика (EOS) решает две разные проблемы: дубликаты от ретраев продюсера и консистентность записи в несколько партиций в составе одной логической операции.

Важно сразу разделить два механизма: идемпотентный продюсер (защита от дублей при ретраях) и транзакции (атомарность записи в несколько партиций/топиков, включая паттерн read-process-write).

Идемпотентный продюсер

Включается настройкой enable.idempotence=true (в новых версиях клиента — по умолчанию). Брокер выдаёт продюсеру уникальный PID (producer ID), и каждое сообщение в партицию идёт с монотонно растущим sequence number. Брокер хранит для каждого (PID, partition) последний принятый sequence number и отбрасывает дубликат, если sequence уже был обработан.

Это похоже на TCP: каждый пакет пронумерован, получатель видит повтор по номеру и просто подтверждает получение ещё раз, не применяя данные дважды. Брокер делает то же самое на уровне (producer, partition).

Идемпотентность работает только в рамках одной сессии продюсера и одной партиции. Если продюсер перезапустится, он получит новый PID — защита от дублей между разными запусками продюсера идемпотентностью одной не обеспечивается, для этого нужны транзакции с transactional.id.

Транзакции и transactional.id

Для атомарной записи в несколько партиций продюсер настраивается с постоянным transactional.id. Это ключ, по которому координатор транзакций (отдельный компонент на брокере) ассоциирует PID с конкретным логическим продюсером между перезапусками — то есть решает проблему "зомби"-продюсера.

producer.initTransactions();
try {
    producer.beginTransaction();
    producer.send(record1);
    producer.send(record2);
    producer.commitTransaction();
} catch (ProducerFencedException e) {
    producer.close();
} catch (KafkaException e) {
    producer.abortTransaction();
}

Под капотом координатор ведёт внутренний топик __transaction_state, куда пишет статусы: begin, прошедшие партиции, commit/abort. При commit брокер пишет в каждую затронутую партицию служебный control-маркер (COMMIT или ABORT), который и является границей транзакции на диске.

Если продюсер с тем же transactional.id перезапускается, координатор увеличивает epoch и "фенсит" (fences) старый экземпляр — любой вызов от старого PID с прежним epoch получит ProducerFencedException. Это защита от ситуации, когда старый процесс завис, а новый уже поднялся и работает с тем же id.

Read-process-write: consumer + producer в одной транзакции

Самый частый сценарий EOS — Kafka Streams-подобный паттерн: читаем из топика A, обрабатываем, пишем в топик B, и коммит оффсета чтения должен быть атомарен с записью результата. Для этого продюсер коммитит не только сообщения, но и оффсеты потребителя как часть транзакции:

producer.beginTransaction();
producer.send(resultRecord);
producer.sendOffsetsToTransaction(offsets, consumerGroupMetadata);
producer.commitTransaction();

Если процесс упадёт между отправкой результата и коммитом оффсета, при перезапуске либо вся транзакция будет видна (и оффсет, и данные), либо не будет видна ничего — потребитель перечитает тот же батч и обработает заново без дублей в выходном топике, потому что незакоммиченные записи фильтруются на чтении.

READ_COMMITTED на стороне потребителя

Записи, отправленные в рамках незакоммиченной или абортнутой транзакции, физически лежат в партиции сразу — Kafka не откатывает запись на диске. Видимость регулируется на чтении: потребитель с isolation.level=read_committed не вернёт приложению записи до тех пор, пока не увидит control-маркер commit для этой транзакции, и полностью пропустит записи с маркером abort. По умолчанию уровень — read_uncommitted, то есть все записи видны сразу, независимо от исхода транзакции.

Что произойдёт с незакоммиченными сообщениями транзакции, если продюсер упал после send, но до commit?
Они останутся в партиции физически, но consumer с read_committed их не увидит. Координатор по таймауту (transaction.timeout.ms) сам пометит транзакцию как abort, после чего эти записи будут отфильтрованы окончательно.

Что это стоит на практике

EOS не бесплатен: транзакции добавляют задержку (ожидание commit перед видимостью данных), лишние round-trip к координатору, и требуют acks=all с достаточным min.insync.replicas. Для чистого логирования событий это оверхед без пользы; для финансовых операций и консистентных пайплайнов — обязательный инструмент.

Чем идемпотентный продюсер отличается от транзакционного?
Идемпотентность убирает дубли от ретраев в пределах одной партиции и одной сессии PID. Транзакции добавляют атомарность записи в несколько партиций/топиков и возможность атомарно закоммитить оффсеты чтения вместе с результатом записи, переживая рестарт продюсера благодаря transactional.id.

Ещё разборы