abhi-g.dev
Distributed systemsOct 20195 min read

Kafka consumer groups and dead letter queues

A consumer group is how Kafka spreads a topic across workers. A dead letter queue is how those workers avoid getting stuck on a message they cannot process. The two are usually configured together and usually configured wrong.

Originally written on 7 October 2019. Migrated from a WordPress blog and reformatted.

Consumer groups

A topic is divided into partitions. A consumer group is a set of consumers that share a group.id; Kafka assigns each partition to exactly one consumer in the group. Two consequences follow immediately.

Parallelism is bounded by partition count. A topic with 6 partitions and a group of 8 consumers leaves 2 consumers idle. Adding consumers beyond the partition count does nothing.

Ordering holds within a partition only. Messages with the same key go to the same partition and are consumed in order. Messages with different keys may be processed in any relative order. If ordering matters for an entity, the entity's id must be the message key.

Six partitions, one consumer group of three; each partition has exactly one consumertopic: orderspartition 0partition 1partition 2partition 3partition 4partition 5group: billingconsumer 0offset per partitionconsumer 1offset per partitionconsumer 2offset per partitiona 7th consumer would idle: 6 partitions
Six partitions, one consumer group of three; each partition has exactly one consumer

Each consumer tracks its position per partition as an offset and commits it back to Kafka. On restart, or when a partition is reassigned during a rebalance, the new consumer resumes from the last committed offset. Everything after that offset is redelivered, which is why every consumer must tolerate duplicates.

Offset commit timing

enable.auto.commit=true (the default) commits offsets on a timer, regardless of whether the messages have been processed. A consumer that polls 500 messages, auto-commits, and crashes at message 200 loses 300 messages. For anything that matters, commit manually after processing:

props.put("enable.auto.commit", "false");
...
for (ConsumerRecord<String, String> r : consumer.poll(Duration.ofMillis(500))) {
    process(r);
}
consumer.commitSync();

This gives at-least-once delivery: a crash between process and commitSync redelivers the batch. Combined with idempotent processing it is the standard configuration.

Rebalances

When a consumer joins or leaves the group, Kafka reassigns partitions. During the rebalance, no consumer in the group processes anything. Frequent rebalances, caused by consumers that exceed max.poll.interval.ms between polls because processing is slow, are the most common cause of a consumer group that appears to hang. The fix is to reduce max.poll.records, increase max.poll.interval.ms, or move slow work off the poll thread.

The poison message

A message that cannot be processed, because it is malformed, references a record that does not exist, or triggers a bug, will be retried on every redelivery. Since the offset cannot advance past it, the partition stops. Every message behind it waits indefinitely.

The remedy is a dead letter queue: a separate topic to which unprocessable messages are sent, after which the offset advances.

try {
    process(r);
} catch (NonRetryableException e) {
    deadLetterProducer.send(new ProducerRecord<>("orders.dlq", r.key(), r.value(),
        headersWith(r.headers(), "error", e.getMessage(), "original-offset", r.offset())));
}

What belongs in the dead letter queue

Only failures that will not succeed on retry. Distinguishing them from transient failures is the whole design problem:

FailureRetry?Destination
Malformed payload, schema mismatchnoDLQ immediately
Referenced entity not foundmayberetry a bounded number of times, then DLQ
Database timeout, downstream 503yesretry with backoff; DLQ only after a limit
Bug in the consumernoDLQ, and the fix redeploys and replays

Transient failures should not go to the DLQ on first occurrence, or the DLQ fills with messages that would have succeeded a second later. Bounded retries with backoff first, DLQ as the terminal state.

Retrying without blocking the partition

Retrying in place, by not committing the offset, blocks the partition for the duration of the backoff. The alternative is a retry topic: on failure, publish the message to orders.retry with a delay header, commit the original offset, and have a separate consumer process the retry topic after the delay. The main partition keeps moving.

Operating the dead letter queue

A DLQ that nobody reads is a slower way of losing messages. It needs a consumer that alerts on depth, a tool to inspect messages with their error headers, and a way to replay them to the original topic after the underlying fault is fixed. Without the replay path, every message in the DLQ is a manual recovery.