Achieving Exactly-Once Processing in Kafka Consumers

Part of: Exactly-Once vs At-Least-Once Delivery

A Kafka consumer that reads a batch, applies business logic, produces derived records, and commits its offsets is performing four separate operations against a system that can crash between any two of them. Without transactional guarantees, a crash after the produce but before the offset commit causes the consumer to reprocess the same input batch on restart — and re-produce output records that were already written. This runbook implements Kafka’s transactional exactly-once semantics (EOS) for the read-process-write loop, and the idempotent-consumer fallback required the moment a consumer’s side effect leaves the Kafka cluster.

Prerequisites: you understand the distinction between at-least-once delivery and exactly-once processing and why true exactly-once delivery is impossible over an unreliable network. You are running Kafka 2.5 or later (transactional API is stable from 0.11, but read_committed performance and zombie fencing improved substantially by 2.5). You have broker-level transaction.state.log.replication.factor set to at least 3 in production. All code below is copy-pasteable and independently verifiable.


How the transaction boundary works

The diagram below shows the full read-process-write cycle: the producer begins a transaction, writes output records, attaches the consumer offsets to the same transaction, and commits — all as one atomic unit that either fully lands or fully vanishes.

Kafka Transactional Read-Process-Write Sequence diagram with four lifelines: App Producer/Consumer, Transaction Coordinator, Output Topic, and Downstream Consumer. The app begins a transaction, sends processed records to the output topic, sends consumer offsets to the coordinator, then commits. On success the downstream consumer with read_committed isolation sees the records. An abort branch shows that if any step fails, abortTransaction marks the output records as aborted and the downstream consumer never sees them. App (Producer/Consumer) Txn Coordinator Output Topic Downstream Consumer beginTransaction() send(output-topic, record) — uncommitted sendOffsetsToTransaction(offsets) commitTransaction() mark records committed poll() returns record Success path: offsets, output, and commit land atomically Failure Branch exception during send/commit abortTransaction() mark records aborted skips aborted read_committed consumers never observe the aborted records or their offsets

Step-by-step implementation

Step 1 — Configure a transactional producer

Set a stable transactional.id that is unique per logical producer instance (typically one per partition assignment or per application instance) and call initTransactions() once at startup. The stable ID is what lets the broker fence out a zombie instance of the same producer after a restart.

// Java: transactional producer configuration
Properties props = new Properties();
props.put("bootstrap.servers", "kafka-broker-1:9092,kafka-broker-2:9092");
props.put("transactional.id", "order-processor-partition-0");
props.put("enable.idempotence", "true");
props.put("acks", "all");
props.put("max.in.flight.requests.per.connection", "5");

KafkaProducer<String, String> producer = new KafkaProducer<>(props);
producer.initTransactions();

Verify this step independently: restart the process twice in a row and confirm in the broker logs that the producer epoch for order-processor-partition-0 increments on each initTransactions() call — this is the fencing mechanism working correctly.

Step 2 — Implement the read-process-write loop

Wrap the poll-process-produce-commit cycle in a single transaction. sendOffsetsToTransaction attaches the consumer’s offsets to the same atomic unit as the output records, so a crash between the produce and the offset commit cannot happen — both land together or neither does.

// Java: full read-process-write transaction
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(consumerProps);
consumer.subscribe(List.of("orders-raw"));

while (running) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
    if (records.isEmpty()) continue;

    try {
        producer.beginTransaction();
        Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();

        for (ConsumerRecord<String, String> record : records) {
            String processed = applyBusinessLogic(record.value());
            producer.send(new ProducerRecord<>("orders-processed", record.key(), processed));
            offsets.put(
                new TopicPartition(record.topic(), record.partition()),
                new OffsetAndMetadata(record.offset() + 1)
            );
        }

        producer.sendOffsetsToTransaction(offsets, consumer.groupMetadata());
        producer.commitTransaction();
    } catch (ProducerFencedException | OutOfOrderSequenceException e) {
        // Fatal — this producer instance has been superseded, must shut down
        producer.close();
        throw e;
    } catch (KafkaException e) {
        producer.abortTransaction();
    }
}

Python (confluent-kafka) equivalent:

# Python: confluent-kafka transactional read-process-write
from confluent_kafka import Consumer, Producer, TopicPartition, KafkaException

producer_conf = {
    "bootstrap.servers": "kafka-broker-1:9092",
    "transactional.id": "order-processor-partition-0",
}
producer = Producer(producer_conf)
producer.init_transactions()

consumer = Consumer({
    "bootstrap.servers": "kafka-broker-1:9092",
    "group.id": "order-processor",
    "isolation.level": "read_committed",
    "enable.auto.commit": False,
})
consumer.subscribe(["orders-raw"])

while running:
    msgs = consumer.consume(num_messages=100, timeout=0.5)
    if not msgs:
        continue

    try:
        producer.begin_transaction()
        offsets = []
        for msg in msgs:
            processed = apply_business_logic(msg.value())
            producer.produce("orders-processed", key=msg.key(), value=processed)
            offsets.append(TopicPartition(msg.topic(), msg.partition(), msg.offset() + 1))

        producer.send_offsets_to_transaction(offsets, consumer.consumer_group_metadata())
        producer.commit_transaction()
    except KafkaException:
        producer.abort_transaction()

Step 3 — Set read_committed on every downstream consumer

Any consumer reading orders-processed must set isolation.level=read_committed, or it will see records from transactions that were later aborted:

// Java: downstream consumer configuration
Properties consumerProps = new Properties();
consumerProps.put("bootstrap.servers", "kafka-broker-1:9092");
consumerProps.put("group.id", "downstream-billing");
consumerProps.put("isolation.level", "read_committed");
consumerProps.put("enable.auto.commit", "false");

Verify independently: check every consumer group’s effective config with kafka-consumer-groups.sh --describe and confirm isolation.level is not defaulting to read_uncommitted — this is a common deployment drift that silently breaks the exactly-once guarantee for every downstream reader.

Step 4 — Add an idempotent-consumer fallback for external writes

Kafka transactions only cover Kafka-to-Kafka atomicity. The moment a consumer writes to a database, calls a third-party API, or sends an email, that write is outside the transaction boundary. Combine the read-process-write loop with an idempotent consumer dedup check keyed on topic-partition-offset before performing the external side effect:

# Python: dedup-store fallback for consumers that write to Postgres
import psycopg2

conn = psycopg2.connect(dsn="postgresql://user:pass@localhost/orders")

def process_with_dedup(record):
    dedup_key = f"{record.topic()}-{record.partition()}-{record.offset()}"
    with conn:
        with conn.cursor() as cur:
            cur.execute(
                "INSERT INTO processed_offsets (dedup_key) VALUES (%s) "
                "ON CONFLICT (dedup_key) DO NOTHING RETURNING dedup_key",
                (dedup_key,)
            )
            if cur.fetchone() is None:
                return  # already processed — duplicate delivery, discard

            apply_external_side_effect(record.value())
            # commit happens automatically at the end of the `with conn` block

This uses topic-partition-offset as the identifier rather than a business-level ID because it is guaranteed stable and unique per delivery, even across consumer restarts and rebalances. See using Redis SETNX for distributed request deduplication for a Redis-backed variant of this same pattern.

Step 5 — Verify with duplicate injection

Force a redelivery deliberately and confirm the external write happens only once:

# Kill the consumer process mid-batch after it has produced but before offsets commit
kill -9 $(pgrep -f order-processor)

# Restart it — it will re-read the same uncommitted offset range
./start-order-processor.sh

# Confirm the output topic has no duplicate records for the affected offset range
kafka-console-consumer.sh --bootstrap-server kafka-broker-1:9092 \
  --topic orders-processed --isolation-level read_committed \
  --from-beginning --max-messages 20

Verification and testing

Confirm transactional state on the broker:

kafka-transactions.sh --bootstrap-server kafka-broker-1:9092 --list
# Look for order-processor-partition-0; state should be Empty or CompleteCommit
# between batches, never a stuck Ongoing state

Confirm consumer isolation level is applied:

kafka-consumer-groups.sh --bootstrap-server kafka-broker-1:9092 \
  --describe --group downstream-billing --verbose

Inject a duplicate at the source and confirm dedup absorbs it:

kafka-console-producer.sh --bootstrap-server kafka-broker-1:9092 \
  --topic orders-raw --property "parse.key=true" --property "key.separator=:" <<'EOF'
order-42:{"amount":100,"currency":"USD"}
order-42:{"amount":100,"currency":"USD"}
EOF
# Verify processed_offsets and downstream side-effect table each show exactly one row for order-42

Failure scenarios and debugging

Failure Scenario Remediation Steps Observability Hooks
Producer crashes after send() but before commitTransaction() Broker automatically times out the open transaction after transaction.timeout.ms (default 60,000 ms) and marks it aborted; restart re-processes from the last committed offset kafka_producer_transaction_abort_total; broker transaction-timeout log entries
Two instances of the same transactional.id run simultaneously after a bad deploy initTransactions() on the new instance bumps the producer epoch, fencing the old one with ProducerFencedException on its next send producer_fenced_total counter; alert on any non-zero rate — indicates a deployment or scaling bug
Downstream consumer misconfigured with isolation.level=read_uncommitted Enforce isolation level via config validation at consumer startup; fail fast rather than silently exposing uncommitted records consumer_isolation_level_mismatch_total; config-drift check in CI/CD
External database write succeeds but the Kafka offset commit fails afterward The idempotent-consumer dedup key (topic-partition-offset) makes the redelivered message a no-op on the external system; the Kafka offset simply re-commits on retry external_write_duplicate_detected_total; trace span dedup.external_write_skipped
Consumer group rebalance occurs mid-transaction The transaction either completes before the rebalance revokes the partition, or it is aborted; never left half-committed. Tune max.poll.interval.ms so processing time never triggers an unexpected rebalance consumer_group_rebalance_total; transaction_abort_due_to_rebalance_total

SRE checklist

  1. kafka_producer_transaction_abort_total — Counter; alert if greater than 1% of transactions over a 10-minute window, indicating coordinator instability or undersized transaction.timeout.ms.
  2. producer_fenced_total — Counter; alert on any non-zero value, this always indicates two live producer instances sharing a transactional.id.
  3. consumer_isolation_level_mismatch_total — Gauge from a config-audit job; must be 0 in every environment before shipping a change to a downstream topic.
  4. external_write_duplicate_detected_total — Counter on the dedup-store fallback path; a healthy non-zero rate confirms the fallback is actually catching redeliveries, not silently bypassed.
  5. transaction_abort_due_to_rebalance_total — Counter; a rising rate means max.poll.interval.ms is too low relative to batch processing time.
  6. Structured log fields on every transaction eventtransactional_id, producer_epoch, partition, offset_range, event (begin | commit | abort) indexed for incident triage.