Kafka Exactly-Once Semantics & Transactions
Kafka supports exactly-once semantics (EOS) through a combination of idempotent producers and transactional APIs. Together they ensure that a message is produced to Kafka exactly once even in the presence of retries, and that a read-process-write operation either fully commits or fully aborts atomically.
Contents
Delivery Guarantees
| Guarantee | How | Risk |
|---|---|---|
| At-most-once | Fire and forget: acks=0 |
Message loss is possible |
| At-least-once | acks=all |
Duplicates possible on retry |
| Exactly-once | Idempotent producer + transactions + read_committed |
Higher latency and complexity |
Idempotent Producer
An idempotent producer assigns a sequence number to every message. The broker deduplicates retries within a single producer session, ensuring each message is written exactly once per partition.
- Enabled by setting
(default in Kafka 3+).enable.idempotence=true - Requires
andacks=all ≤ 5.max.in.flight.requests.per.connection - Idempotence covers one producer session, if the producer restarts, duplicates can still occur without transactions.
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
// Enable idempotence — deduplicates retries within a session
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); // sets acks=all automatically
props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);
props.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5);
KafkaProducer<String, String> producer = new KafkaProducer<>(props);
producer.send(new ProducerRecord<>("orders", "key1", "order data"));
producer.flush();
producer.close();
Transactional Producer
A transactional producer groups multiple sends (potentially to different topics/partitions)
into an atomic unit. Either all writes commit or all are aborted: consumers configured with
isolation.level=read_committed
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
// Unique ID that survives producer restarts — required for transactions
props.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "order-producer-1");
KafkaProducer<String, String> producer = new KafkaProducer<>(props);
producer.initTransactions(); // register with transaction coordinator
try {
producer.beginTransaction();
producer.send(new ProducerRecord<>("orders", "order-1", "order data"));
producer.send(new ProducerRecord<>("order-events", "order-1", "ORDER_CREATED"));
producer.commitTransaction(); // both writes become visible atomically
} catch (ProducerFencedException | OutOfOrderSequenceException e) {
// Fatal — must close producer
producer.close();
} catch (KafkaException e) {
producer.abortTransaction(); // roll back — both writes are discarded
}
transactional.idRead-Process-Write Pattern
The canonical exactly-once pattern reads from an input topic, processes the records, and writes results along with consumer offsets atomically in a single transaction.
// Consumer — must use read_committed to skip aborted transaction messages
Properties consumerProps = new Properties();
consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "eos-processor");
consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
consumerProps.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
consumerProps.put(ConsumerConfig.ISOLATION_LEVEL_CONFIG, "read_committed");
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(consumerProps);
consumer.subscribe(List.of("input-topic"));
// Producer — transactional
Properties producerProps = new Properties();
producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
producerProps.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
producerProps.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "eos-producer-1");
KafkaProducer<String, String> producer = new KafkaProducer<>(producerProps);
producer.initTransactions();
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(200));
if (records.isEmpty()) continue;
try {
producer.beginTransaction();
for (ConsumerRecord<String, String> record : records) {
String result = transform(record.value()); // your processing logic
producer.send(new ProducerRecord<>("output-topic", record.key(), result));
}
// Atomically commit offsets as part of the transaction
Map<TopicPartition, OffsetAndMetadata> offsets = currentOffsets(records);
producer.sendOffsetsToTransaction(offsets, consumer.groupMetadata());
producer.commitTransaction(); // all writes + offset commit are atomic
} catch (KafkaException e) {
producer.abortTransaction();
consumer.seekToBeginning(consumer.assignment()); // reprocess from committed offset
}
}
sendOffsetsToTransaction()Limitations
- Performance overhead: transactions add latency. The transaction coordinator must persist the transaction state before commit.
defaults to 60 seconds.transaction.timeout.ms - Single Kafka cluster only: exactly-once across multiple Kafka clusters is not supported.
- No cross-system EOS: Kafka transactions do not extend to external databases or systems. If your processing involves a database write, you need additional techniques (outbox pattern, saga).
- Zombie fencing: a stale producer with the same
is fenced when a new producer initialises. Plan for this in deployments: use unique IDs per consumer partition for fan-out patterns.transactional.id - Kafka Streams EOS: if using Kafka Streams, set
instead of managing transactions manually.processing.guarantee=exactly_once_v2