How it works
The explanation, with examples you can run.
Where duplicates and gaps come from
- Producer side. A send is retried after a lost acknowledgement. The idempotent producer (default since 3.0) removes these duplicates within a producer session.
- Consumer side. The consumer processes records, then crashes before committing. The next owner starts from the old commit and processes them again (the trace in the previous topic). Committing before processing turns this into a gap instead.
So, by default: no loss with acks=all and sensible replication, but possible duplicates at the consumer.
Exactly-once: read, process, write
For a consumer that reads topic A and writes results to topic B, the dangerous moment is between writing to B and committing A's offset. Kafka's transactions put both in one atomic unit:
# producertransactional.id=payments-enricher-1enable.idempotence=true# consumerisolation.level=read_committedenable.auto.commit=falseproducer.initTransactions();while (true) { ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500)); producer.beginTransaction(); for (var r : records) producer.send(new ProducerRecord<>("payments-enriched", r.key(), enrich(r.value()))); producer.sendOffsetsToTransaction(nextOffsets(records), consumer.groupMetadata()); producer.commitTransaction(); // output and input offsets together, or neither}(Error handling omitted: on failure, abort and rewind.) The transactional.id survives restarts, so a restarted instance fences off its old self and the old instance can no longer commit. Consumers with read_committed only see records from committed transactions; the default read_uncommitted also returns aborted ones. Kafka Streams does all of this for you with processing.guarantee=exactly_once_v2.
Kafka 4.0 strengthened the protocol on the broker side (KIP-890): with 4.0 clients the producer epoch is bumped on every transaction, so a late, retried write cannot slip into the next transaction.
What it does not cover
Exactly-once covers records in Kafka and offsets in Kafka. It does not cover:
- writes to a database, a cache or a search index;
- emails, payments, HTTP calls;
- a producer that calls
send()twice because your code ran twice.
For those, design the consumer to be idempotent: upsert by a business key, keep a table of processed record ids (an inbox) in the same database transaction as the effect, or carry an idempotency key to the external API. With Kafka Connect, whether a sink is exactly-once depends on the connector and the target system, so read its documentation. The pragmatic default for most services is at-least-once delivery plus idempotent handling.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.