10.1.2 – Producer Transactions
Module: M10 – Advanced Topics & Architecture
Section: 10.1 – Transactions & Exactly-Once Semantics
Goal: Master the producer transactional API and implementation patterns
Introduction
This module covers the practical implementation of transactional producers in Kafka. You'll learn how to configure, initialize, and use the transaction API to achieve exactly-once semantics.
Prerequisites: Understanding of transaction protocol (10.1.1)
Configuring a Transactional Producer
Required Configuration
Minimum config for transactions:
# Enable idempotence (required for transactions)
enable.idempotence=true
# Unique transactional ID (must be unique per producer instance)
transactional.id=payment-processor-1
# Bootstrap servers
bootstrap.servers=localhost:9092
# Serializers
key.serializer=org.apache.kafka.common.serialization.StringSerializer
value.serializer=org.apache.kafka.common.serialization.StringSerializer
Key Configuration Parameters
1. transactional.id
Purpose: Uniquely identifies the producer instance, enabling exactly-once guarantees across restarts.
Rules:
- Must be unique per logical producer instance
- Must be stable across restarts (same ID for same logical producer)
- Enables fencing of old producer instances
Example Naming:
transactional.id = "payment-processor-1" # Single-threaded processor
transactional.id = "order-service-thread-0" # Multi-threaded (one ID per thread)
transactional.id = "etl-job-partition-5" # Partitioned batch job
Anti-Pattern:
transactional.id = UUID.randomUUID().toString() # ❌ Different ID each time
Why Bad?: Each restart gets a new ID, defeating the purpose of transactions (can't fence zombies, can't recover state).
2. transaction.timeout.ms
Purpose: Maximum time a transaction can remain open before the coordinator aborts it.
Default: 60,000 ms (1 minute)
Configuration:
transaction.timeout.ms=300000 # 5 minutes
When to Increase:
- Slow processing (complex transformations)
- Large batch sizes
- External system calls (DB, API)
When to Keep Low:
- Fast, simple transformations
- High-throughput pipelines
- Want quick zombie detection
3. acks
Required: acks=all (transactions implicitly set this)
Why: Transactions require all in-sync replicas to acknowledge writes for durability.
4. max.in.flight.requests.per.connection
Recommendation: max.in.flight.requests.per.connection=5 (default with idempotence)
Why: Enables pipelining while maintaining order guarantees.
Full Example Configuration
Properties props = new Properties();
// Required for transactions
props.put("bootstrap.servers", "broker1:9092,broker2:9092,broker3:9092");
props.put("transactional.id", "payment-processor-1");
props.put("enable.idempotence", "true");
// Serializers
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
// Optional: Tuning
props.put("transaction.timeout.ms", "300000"); // 5 minutes
props.put("max.block.ms", "60000"); // Max time to block on send
props.put("retries", Integer.MAX_VALUE); // Infinite retries (default with idempotence)
// Create producer
KafkaProducer<String, String> producer = new KafkaProducer<>(props);
Initializing Transactions
initTransactions()
Purpose: Initializes the producer for transactions by registering with the transaction coordinator.
What It Does:
- Contacts transaction coordinator
- Obtains or recovers Producer ID (PID) and epoch
- Fences any previous instances with the same
transactional.id - Aborts any incomplete transactions from previous sessions
Usage:
producer.initTransactions(); // Must be called once before any transactions
Blocking Behavior: This call blocks until initialization completes (can take several seconds).
Initialization Sequence:
Producer starts
↓
Calls initTransactions()
↓
Sends InitProducerIdRequest to coordinator
↓
Coordinator checks __transaction_state:
- If transactional.id exists: Increment epoch, fence old producer
- If new: Assign new PID
↓
Coordinator aborts any incomplete transactions from old producer
↓
Returns {PID, epoch} to producer
↓
Producer ready to start transactions
Error Handling:
try {
producer.initTransactions();
} catch (TimeoutException e) {
// Coordinator unreachable or slow
log.error("Failed to initialize transactions", e);
// Retry with backoff or fail fast
} catch (KafkaException e) {
// Unrecoverable error (invalid config, auth failure)
log.error("Transaction initialization failed", e);
throw e;
}
Transaction Lifecycle
Step-by-Step Transaction Flow
1. Begin Transaction
producer.beginTransaction();
What It Does:
- Marks the producer as "in transaction" (local state)
- No network call to coordinator yet
2. Produce Messages
ProducerRecord<String, String> record1 = new ProducerRecord<>("orders", "order-1", "paid");
ProducerRecord<String, String> record2 = new ProducerRecord<>("inventory", "item-1", "reserved");
producer.send(record1);
producer.send(record2);
What It Does:
- First
send()triggersAddPartitionsToTxnRequestto coordinator - Coordinator adds partitions to transaction state
- Messages sent to brokers with transaction metadata (PID, epoch, sequence)
- Messages marked as uncommitted (not visible to
read_committedconsumers)
3. Commit Transaction
producer.commitTransaction();
What It Does:
- Sends
EndTxnRequest(commit=true)to coordinator - Coordinator writes COMMIT markers to all partitions
- Messages become visible to
read_committedconsumers - Transaction completes
Blocking Behavior: Blocks until all markers are written and acknowledged.
4. Abort Transaction
producer.abortTransaction();
What It Does:
- Sends
EndTxnRequest(commit=false)to coordinator - Coordinator writes ABORT markers to all partitions
- Messages become invisible (discarded by
read_committedconsumers) - Transaction rolls back
Complete Transaction Example
KafkaProducer<String, String> producer = new KafkaProducer<>(props);
producer.initTransactions();
try {
producer.beginTransaction();
// Produce messages to multiple topics
producer.send(new ProducerRecord<>("orders", "order-1", "paid"));
producer.send(new ProducerRecord<>("payments", "payment-1", "processed"));
producer.send(new ProducerRecord<>("notifications", "email-1", "sent"));
// Commit transaction (all-or-nothing)
producer.commitTransaction();
log.info("Transaction committed successfully");
} catch (ProducerFencedException | OutOfOrderSequenceException | AuthorizationException e) {
// Fatal errors - cannot recover
log.error("Fatal producer error", e);
producer.close();
throw e;
} catch (KafkaException e) {
// Transient error - abort and retry
log.warn("Transaction failed, aborting", e);
producer.abortTransaction();
// Retry logic here
} finally {
producer.close();
}
Transactional Producer Patterns
Pattern 1: Simple Transactional Write
Use Case: Write to multiple topics atomically.
public void processOrder(Order order) {
try {
producer.beginTransaction();
producer.send(new ProducerRecord<>("orders", order.getId(), order.toJson()));
producer.send(new ProducerRecord<>("inventory", order.getItemId(), "reserved"));
producer.send(new ProducerRecord<>("audit-log", order.getId(), "order-created"));
producer.commitTransaction();
} catch (KafkaException e) {
log.error("Failed to process order", e);
producer.abortTransaction();
throw e;
}
}
Guarantee: All three messages are written atomically, or none are.
Pattern 2: Read-Process-Write (Consumer + Producer)
Use Case: Consume from one topic, transform, produce to another, commit offsets atomically.
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(consumerProps);
KafkaProducer<String, String> producer = new KafkaProducer<>(producerProps);
consumer.subscribe(Arrays.asList("input-topic"));
producer.initTransactions();
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
if (!records.isEmpty()) {
try {
producer.beginTransaction();
for (ConsumerRecord<String, String> record : records) {
// Transform
String transformed = transform(record.value());
// Produce to output topic
producer.send(new ProducerRecord<>("output-topic", record.key(), transformed));
}
// Commit offsets as part of transaction
Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
for (TopicPartition partition : records.partitions()) {
long lastOffset = records.records(partition).get(records.records(partition).size() - 1).offset();
offsets.put(partition, new OffsetAndMetadata(lastOffset + 1));
}
producer.sendOffsetsToTransaction(offsets, consumer.groupMetadata());
// Commit transaction
producer.commitTransaction();
} catch (ProducerFencedException | OutOfOrderSequenceException | AuthorizationException e) {
log.error("Fatal error, closing producer", e);
producer.close();
break;
} catch (KafkaException e) {
log.warn("Transaction failed, aborting", e);
producer.abortTransaction();
}
}
}
Guarantee: Processed messages and consumer offsets are committed atomically. If producer crashes, consumer resumes from last committed offset and reprocesses (no data loss, no duplicates).
Pattern 3: Batch Processing with Transactions
Use Case: Process large batches atomically.
public void processBatch(List<Record> batch) {
try {
producer.beginTransaction();
for (Record record : batch) {
producer.send(new ProducerRecord<>("output-topic", record.getKey(), record.getValue()));
}
producer.commitTransaction();
log.info("Batch of {} records committed", batch.size());
} catch (KafkaException e) {
log.error("Batch failed, aborting", e);
producer.abortTransaction();
throw e;
}
}
Tuning:
transaction.timeout.ms=600000 # 10 minutes for large batches
Pattern 4: External System Integration (Database + Kafka)
Use Case: Write to database and Kafka atomically (requires XA transactions or idempotency).
Approach 1: Idempotent Database Writes
public void processPayment(Payment payment) {
try {
producer.beginTransaction();
// Write to Kafka
producer.send(new ProducerRecord<>("payments", payment.getId(), payment.toJson()));
// Write to database (with idempotent UPDATE)
database.execute("UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?",
payment.getAmount(), payment.getAccountId(), payment.getAmount());
producer.commitTransaction();
} catch (Exception e) {
producer.abortTransaction();
throw e;
}
}
Guarantee: If Kafka commits but DB fails, retry is safe (idempotent UPDATE).
Approach 2: Outbox Pattern
// Write to DB outbox table + commit
database.beginTransaction();
database.insert("INSERT INTO outbox (event_id, payload) VALUES (?, ?)", eventId, payload);
database.commit();
// Separate process reads outbox and sends to Kafka transactionally
Guarantee: Database is source of truth, Kafka is eventually consistent.
Error Handling
Fatal Errors (Cannot Recover)
1. ProducerFencedException
Cause: Another producer with the same transactional.id has fenced this producer (higher epoch).
Action: Close producer immediately, do NOT retry.
catch (ProducerFencedException e) {
log.error("Producer fenced by newer instance", e);
producer.close();
System.exit(1); // Let orchestration restart
}
2. OutOfOrderSequenceException
Cause: Broker detected out-of-order sequence numbers (data corruption or bug).
Action: Close producer, investigate.
catch (OutOfOrderSequenceException e) {
log.error("Sequence error, data corruption possible", e);
producer.close();
throw e;
}
3. AuthorizationException
Cause: Producer lacks permissions to write to topic or use transactions.
Action: Fix ACLs, restart.
catch (AuthorizationException e) {
log.error("Authorization failed, check ACLs", e);
producer.close();
throw e;
}
Transient Errors (Retry Allowed)
1. KafkaException (generic)
Cause: Network issues, broker unavailable, coordinator failover.
Action: Abort transaction, retry with backoff.
catch (KafkaException e) {
log.warn("Transient error, aborting and retrying", e);
producer.abortTransaction();
// Exponential backoff
Thread.sleep(retryBackoffMs);
retryBackoffMs = Math.min(retryBackoffMs * 2, maxBackoffMs);
}
2. TimeoutException
Cause: Coordinator or broker didn't respond in time.
Action: Abort transaction, retry.
catch (TimeoutException e) {
log.warn("Transaction timeout, aborting", e);
producer.abortTransaction();
}
Error Handling Best Practices
1. Always Abort on Transient Errors
try {
producer.beginTransaction();
// ... produce messages ...
producer.commitTransaction();
} catch (ProducerFencedException | OutOfOrderSequenceException | AuthorizationException e) {
// Fatal - close and exit
producer.close();
throw e;
} catch (KafkaException e) {
// Transient - abort and retry
producer.abortTransaction();
// Retry logic
}
2. Implement Retry with Exponential Backoff
int maxRetries = 3;
int retryCount = 0;
long backoff = 1000; // 1 second
while (retryCount < maxRetries) {
try {
// Transaction logic
break; // Success
} catch (KafkaException e) {
retryCount++;
if (retryCount >= maxRetries) throw e;
Thread.sleep(backoff);
backoff *= 2;
}
}
3. Monitor Transaction Failures
Metrics.counter("transactions.aborted").increment();
Metrics.counter("transactions.committed").increment();
Metrics.timer("transaction.duration").record(duration);
Performance Considerations
Transaction Overhead
Latency Impact:
- Idempotent producer: ~1ms overhead (sequence numbers)
- Transactional producer: ~5-10ms overhead (coordinator communication, markers)
Throughput Impact:
- ~10-20% throughput reduction compared to non-transactional producers
Batching to Amortize Overhead
Anti-Pattern: One transaction per message
for (Message msg : messages) {
producer.beginTransaction();
producer.send(msg);
producer.commitTransaction(); // ❌ Expensive (1 transaction = 5-10ms overhead)
}
Best Practice: Batch multiple messages per transaction
producer.beginTransaction();
for (Message msg : messages) {
producer.send(msg); // Batch 100-1000 messages
}
producer.commitTransaction(); // ✅ Overhead amortized
Rule of Thumb: 100-1000 messages per transaction for optimal throughput.
Tuning for Throughput
# Increase batch size to reduce per-message overhead
batch.size=32768 # 32 KB (default: 16 KB)
linger.ms=10 # Wait 10ms to fill batch
# Increase transaction timeout for large batches
transaction.timeout.ms=300000 # 5 minutes
# Enable compression to reduce network/disk I/O
compression.type=snappy
Hands-On Lab: Implementing EOS Step-by-Step
This section provides complete, runnable code examples for implementing transactional producers in real applications.
Lab 1: Transactional Producer-Consumer Pipeline
Goal: Build a Java pipeline where a producer sends messages transactionally, and a consumer reads them with EOS guarantees. We'll simulate failures to test atomicity.
Step 1: Setup
Prerequisites:
- Kafka cluster (local or Confluent)
- Java 8+ and Maven
- Dependencies:
<dependency> <groupId>org.apache.kafka</groupId> <artifactId>kafka-clients</artifactId> <version>3.4.0</version> </dependency>
Create Topics:
kafka-topics --create --topic orders --partitions 3 --replication-factor 1 --bootstrap-server localhost:9092
kafka-topics --create --topic payments --partitions 3 --replication-factor 1 --bootstrap-server localhost:9092
Step 2: Transactional Producer
Code: Sends two messages atomically ("order created" + "payment processed").
import org.apache.kafka.clients.producer.*;
import java.util.Properties;
public class TransactionalProducer {
public static void main(String[] args) {
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
// Transaction configuration
props.put("acks", "all");
props.put("enable.idempotence", "true");
props.put("transactional.id", "eos-demo-1"); // Unique per producer instance
props.put("max.in.flight.requests.per.connection", "5");
KafkaProducer<String, String> producer = new KafkaProducer<>(props);
producer.initTransactions(); // Critical!
try {
producer.beginTransaction();
// Send to two topics atomically
producer.send(new ProducerRecord<>("orders", "order-123", "Order created"));
producer.send(new ProducerRecord<>("payments", "order-123", "Payment processed"));
producer.commitTransaction(); // Both or none
System.out.println("Transaction committed successfully");
} catch (ProducerFencedException | OutOfOrderSequenceException | AuthorizationException e) {
// Fatal errors - close producer
System.err.println("Fatal error: " + e.getMessage());
producer.close();
throw e;
} catch (KafkaException e) {
// Transient error - abort and retry
System.err.println("Transaction failed: " + e.getMessage());
producer.abortTransaction(); // Rollback on failure
} finally {
producer.close();
}
}
}
Key Points:
transactional.idmust be unique and stable across restartsinitTransactions()registers the producer with Kafka's transaction coordinator- If the producer crashes before
commitTransaction(), Kafka discards the messages
Step 3: Transactional Consumer
Code: Reads from payments and commits offsets transactionally.
import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.TopicPartition;
import java.time.Duration;
import java.util.*;
public class TransactionalConsumer {
public static void main(String[] args) {
// Consumer configuration
Properties consumerProps = new Properties();
consumerProps.put("bootstrap.servers", "localhost:9092");
consumerProps.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
consumerProps.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
consumerProps.put("group.id", "eos-consumer-group");
consumerProps.put("isolation.level", "read_committed"); // Ignore aborted messages
consumerProps.put("enable.auto.commit", "false"); // Manual offset commits
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(consumerProps);
consumer.subscribe(Arrays.asList("payments"));
// Producer configuration for transactional offset commits
Properties producerProps = new Properties();
producerProps.put("bootstrap.servers", "localhost:9092");
producerProps.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
producerProps.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
producerProps.put("transactional.id", "eos-consumer-producer-1");
producerProps.put("enable.idempotence", "true");
KafkaProducer<String, String> producer = new KafkaProducer<>(producerProps);
producer.initTransactions();
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
if (!records.isEmpty()) {
try {
producer.beginTransaction();
for (ConsumerRecord<String, String> record : records) {
System.out.printf("Processed: %s\n", record.value());
// Simulate external side effect (e.g., DB write)
}
// Commit offsets + external state atomically
Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
for (TopicPartition partition : records.partitions()) {
List<ConsumerRecord<String, String>> partitionRecords = records.records(partition);
long lastOffset = partitionRecords.get(partitionRecords.size() - 1).offset();
offsets.put(partition, new OffsetAndMetadata(lastOffset + 1));
}
producer.sendOffsetsToTransaction(offsets, consumer.groupMetadata());
producer.commitTransaction();
} catch (ProducerFencedException | OutOfOrderSequenceException | AuthorizationException e) {
System.err.println("Fatal error: " + e.getMessage());
producer.close();
consumer.close();
break;
} catch (KafkaException e) {
System.err.println("Processing failed: " + e.getMessage());
producer.abortTransaction();
}
}
}
}
}
Key Points:
isolation.level=read_committedskips aborted transactionssendOffsetsToTransaction()binds offset commits to the transaction- If transaction aborts, consumer will reprocess the same messages
Step 4: Simulate Failures
Test 1: Crash Producer Mid-Transaction
// Add before commitTransaction():
Thread.sleep(5000); // Simulate slow processing
// Kill the producer during sleep (Ctrl+C)
// Verify: No messages appear in the topics
Verification:
kafka-console-consumer --topic orders --from-beginning --bootstrap-server localhost:9092
kafka-console-consumer --topic payments --from-beginning --bootstrap-server localhost:9092
# Should see no messages (transaction aborted)
Test 2: Crash Consumer Mid-Processing
// Add after printing "Processed: ..."
throw new RuntimeException("Simulated failure");
// Verify: Same message is reprocessed (idempotent handling required)
Lab 2: Kafka Streams EOS Implementation
Goal: Process a stream with built-in EOS guarantees using Kafka Streams.
Code:
import org.apache.kafka.streams.*;
import org.apache.kafka.streams.kstream.*;
import java.util.Properties;
public class KafkaStreamsEOS {
public static void main(String[] args) {
Properties props = new Properties();
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(StreamsConfig.APPLICATION_ID_CONFIG, "eos-streams-app");
props.put(StreamsConfig.PROCESSING_GUARANTEE_CONFIG, "exactly_once_v2");
props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG,
org.apache.kafka.common.serialization.Serdes.String().getClass());
props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG,
org.apache.kafka.common.serialization.Serdes.String().getClass());
StreamsBuilder builder = new StreamsBuilder();
builder.stream("orders")
.mapValues(value -> "Processed: " + value)
.to("processed-orders");
KafkaStreams streams = new KafkaStreams(builder.build(), props);
streams.start();
// Add shutdown hook
Runtime.getRuntime().addShutdownHook(new Thread(streams::close));
}
}
Key Points:
exactly_once_v2handles state stores, changelogs, and offsets atomically- No need for manual transaction APIs!
- Works only within Kafka (external systems still need separate handling)
Lab 3: Expected Output & Validation
Check for Duplicates:
kafka-console-consumer --topic payments --from-beginning \
--property print.key=true --bootstrap-server localhost:9092
Expected: Each order-123 appears exactly once (no duplicates).
Inspect Transaction Logs:
kafka-console-consumer --topic __transaction_state --from-beginning \
--bootstrap-server localhost:9092 \
--formatter "kafka.coordinator.transaction.TransactionLog\$TransactionLogMessageFormatter"
Expected: See transaction state transitions (Ongoing → PrepareCommit → CompleteCommit).
Check Consumer Offsets:
kafka-consumer-groups --describe --group eos-consumer-group \
--bootstrap-server localhost:9092
Expected: Offsets committed only after successful transaction.
Key Takeaways
Configuration: Requires
transactional.id(unique per instance) andenable.idempotence=true.Lifecycle:
initTransactions()→beginTransaction()→send()→commitTransaction()orabortTransaction().Atomicity: All messages in a transaction are committed or aborted atomically across multiple partitions.
Read-Process-Write: Use
sendOffsetsToTransaction()to commit consumer offsets atomically with produced messages.Error Handling: Fatal errors (fencing, sequence errors) require producer close. Transient errors allow abort + retry.
Performance: ~10-20% throughput reduction. Batch 100-1000 messages per transaction to amortize overhead.
Fencing: Old producer instances are automatically fenced when new instance with same
transactional.idstarts.Hands-On Practice: The labs above provide runnable code for transactional producers, consumers, and Kafka Streams with EOS.
What's Next?
Next Module: 10.1.3 – Consumer Isolation Levels
What We Covered:
- ✅ Producer transaction API
- ✅ Configuration and initialization
- ✅ Transaction patterns (simple, read-process-write, batch)
- ✅ Error handling strategies
What's Coming:
- ❌ Consumer isolation levels (
read_committedvsread_uncommitted) - ❌ How consumers handle transaction markers
- ❌ End-to-end EOS pipeline design
References:
- Transaction protocol → 10.1.1
- Producer configs → M3: Producers