How to read this guide
a ledger office, a city council, and a lot of clerksMost Kafka tutorials teach you the API: create a producer, call send(), write a poll loop, done.
That gets you to "it works on my laptop". It does not tell you why a consumer group freezes for thirty seconds, why a
broker that lost its disk did not lose your data, or why acks=all is still not enough on its own.
Those answers live one layer down, in the mechanisms. This guide is about that layer.
We will keep one picture in our heads the whole way through. Imagine a city's ledger archive. Every event in the city is written, in order, as a numbered line in a bound ledger book. Nobody ever erases or rewrites a line; they only add new lines at the end. The archive has several branches (buildings), each holding some of the books, and every important book has photocopies in other branches in case one burns down. A city council keeps the official minutes of which branch holds which book. Teams of clerks read the books and keep a bookmark of how far they got. That is Kafka: records, partitions, brokers, replicas, the KRaft controller quorum, consumer groups and committed offsets.
The guide has levels. Chapters and blocks carry a chip — L1 essentials, L2 practitioner, L3 expert internals, L4 mastery (operations and application design). Choose your depth with the switch in the bottom-left corner; deeper material hides until you want it. The map below jumps anywhere and ticks off what you've read.
Facts are checked against Apache Kafka 4.3.1 (released 25 June 2026, the newest release at the time of writing) and Spring for Apache Kafka 4.1.1 (August 2026). The 4.x line changed a lot of "classic" Kafka knowledge, so older blog posts can mislead you:
| Release | What changed in the internals |
|---|---|
| 4.0 (Mar 2025) | ZooKeeper mode removed — KRaft only. New group coordinator and the next-generation consumer rebalance protocol (KIP-848) GA. Transactions server-side defense (KIP-890). Eligible Leader Replicas (KIP-966 part 1). Producer linger.ms default 0 → 5. Old protocol API versions removed (brokers and clients must be ≥ 2.1). |
| 4.1 (Sep 2025) | Queues for Kafka (share groups, KIP-932) in preview. ELR enabled by default on new clusters. Streams rebalance protocol (KIP-1071) in early access. |
| 4.2 (Feb 2026) | Share groups production-ready. Streams rebalance protocol production-ready for its core feature set. Dynamic KRaft controllers can auto-join the voter set (controller.quorum.auto.join.enable). |
| 4.3 (May 2026) | Cordoning of log directories (KIP-1066). Newly added followers on tiered-storage clusters can skip already-uploaded data (opt-in follower.fetch.last.tiered.offset.enable, default false). New share-group limits (share.delivery.count.limit and friends). |
Always read the upgrade notes of your exact version.
Kafka only ever appends to files. It never updates a record in place and, by default, does not even force data to disk on each write. So how can it be faster than a database, survive a broker crash without losing writes it acknowledged, and offer exactly-once processing? Keep the question in mind — the finale answers it.
Foundations
Zero Kafka knowledge assumed. You get the mental model everything else builds on: the log, the cluster, how a producer writes, how consumers read, why copies exist and when old data disappears. We'll use one analogy, the city ledger archive, for the whole course.
The log: Kafka's one big idea
append-only, numbered, never edited: that's the whole trickYour bank shows you a balance, but underneath it keeps the list of every transaction, in order, forever. If someone wiped the balance you could rebuild it from that list. If someone wiped the list, the balance would mean nothing. Which of the two is the real data?
Kafka's answer is the list. Everything in Kafka is built on one simple data structure: the log. Here "log" doesn't mean a debug log. It means an append-only sequence of records. You can add records at the end and you can read from any position. You can't change or insert a record in the middle.
Meet the city ledger archive
We'll use one picture for the whole course, so let's set it up carefully. Imagine a city's ledger archive, an old-fashioned records office. Clerks write every event that happens in the city into bound ledger books. They never erase a line and never squeeze a line between two others. A new event always goes on the next empty line.
| In the archive | In Kafka | Where you meet it |
|---|---|---|
| One entry: "Alice paid Bob $200" | Record (event, message) | this chapter |
| A ledger series, e.g. "Payments" | Topic | this chapter |
| One bound ledger book in that series | Partition | this chapter |
| The printed line number | Offset | this chapter |
| An archive branch building | Broker | ch. 2 |
| A courier who bundles letters into sacks | Producer | ch. 3 |
| A team of clerks sharing the books, each with a bookmark | Consumer group, committed offset | ch. 4 |
| Photocopies of a book kept in other branches | Replicas | ch. 5 |
| Shredding the oldest volumes of a book | Retention | ch. 6 |
| The city council keeping the official registry | KRaft controller quorum | ch. 2, 9 |
Records
The unit of data is a record. The docs also call it an event or a message. A record says that "something happened". It has a key, a value, a timestamp and optional headers (small key/value metadata). The official introduction uses this example: key "Alice", value "Made a payment of $200 to Bob", and a timestamp. To Kafka, the key and value are just bytes. Your application decides what they mean (JSON, Avro, a plain string…).
Topics
Records are organized and durably stored in topics, for example payments or page-views. That's our ledger series. A topic is multi-producer and multi-subscriber: zero, one or many applications can write to it, and zero, one or many can read from it. And, unlike a classic message queue, reading a record does not delete it. Records stay until the topic's retention rule throws them away (chapter 6), so the same data can be read again and again, by different readers, at different times.
Partitions and offsets
One ledger book would be a bottleneck: one book can only be written by one hand at a time. So a topic is split into partitions, several books in the same series. The partitions can live on different servers, so many clients can read and write the topic in parallel. That's how Kafka scales.
Inside a partition, every record gets a sequential number called the offset: 0, 1, 2, 3… It's the printed line number in the book. Offsets are per partition. Partition 0 and partition 1 both have a record at offset 5, and the two are unrelated. To name a record uniquely you need three things: topic, partition and offset.
Which book does a new record go into? The producer picks. With the default rules, records with the same key always go to the same partition because the partition is chosen from a hash of the key. All of Alice's payments land in one book, in the order they were written. Records without a key are spread over partitions (chapter 3 shows how).
That sentence is the most important one in this chapter. The docs put it this way: any consumer of a given topic-partition will always read that partition's events in exactly the same order as they were written. Across two partitions there is no such promise. Play with it below. Type a key, append it, and watch where it lands. Then press the break-it button.
payments has 3 partitions. Append a record with a key.murmur2(keyBytes) made positive, modulo the partition count (ported from BuiltInPartitioner.partitionForKey). The "#n" badge shows the order in which records were produced. It's for illustration: Kafka doesn't store such a global number. The two readers are independent consumers of partition 0.The key-to-partition mapping is hash(key) % numberOfPartitions. If you add partitions to an existing topic, the same key can start landing in a different partition. New records for Alice may go into a different book than her old ones, and a reader may see them out of order. The kafka-topics.sh help text warns about exactly this. Choose the partition count for keyed topics up front.
Start a local single-node Kafka 4.3 cluster as in the official quickstart (bin/kafka-storage.sh format --standalone … then bin/kafka-server-start.sh config/server.properties). Then:
bin/kafka-topics.sh --bootstrap-server localhost:9092 \
--create --topic payments --partitions 3
bin/kafka-console-producer.sh --bootstrap-server localhost:9092 --topic payments \
--reader-property parse.key=true --reader-property key.separator=:
> alice:paid 10
> carol:paid 5
> alice:paid 7
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic payments \
--from-beginning --formatter-property print.key=true \
--formatter-property print.partition=true --formatter-property print.offset=true
1) Do both of Alice's records show the same partition? 2) Which offsets do they have? 3) Run the consumer command again. Do you get the records a second time?
Answer
1) Yes. Same key, same partition. 2) Consecutive offsets within that partition (for example 0 and 1), whatever offsets Carol's record got in its own partition. 3) Yes. Without --group, the console consumer invents a fresh random group (console-consumer-NNNNN) and doesn't commit offsets, so there's no saved position to resume from, and --from-beginning starts it at the earliest offset. Reading didn't delete anything. (In chapter 4 you'll add --group and see a different result.)
What a partition looks like on disk. A partition is a directory on the broker. A topic my-topic with two partitions has the directories my-topic-0 and my-topic-1. Inside, the log is split into files called segments, each named after the first offset it contains. The first file is 00000000000000000000.log, and a new one starts when the current one reaches segment.bytes or gets older than segment.ms. New records are always appended to the last file. In our analogy, a "book" is really a shelf of volumes. The last volume is still being written; the older ones are closed. Chapter 6 (retention) and chapter 7 (storage engine) build on this.
🔬 Go deeper: why offsets instead of message IDs?
The implementation docs say Kafka's original idea was a producer-generated GUID per message, with a GUID→offset map on each broker. They dropped it. A consumer must track an ID per server anyway, so global uniqueness adds nothing. Mapping random IDs to positions would also need a heavy, disk-synchronized index. A simple per-partition atomic counter (the offset) is enough, and it doubles as the consumer's position.
An offset is a 64-bit integer. A consumer's position is "the offset of the next record I'll read", so a consumer at position 5 has consumed offsets 0–4. Offsets are not guaranteed to be consecutive from a reader's point of view. Compacted topics (ch. 14) remove records, and transactions (ch. 13) write control markers that take up offsets but are never returned to the application. That's why you should never compute "number of records = end − start".
On the broker, the partition's log is managed by UnifiedLog (package org.apache.kafka.storage.internals.log), which keeps its LogSegment files through a LocalLog (a LogSegments collection).
- A Kafka record has a key, value, timestamp and optional headers. Kafka treats key and value as bytes.
- A topic is a named stream of records; it's split into partitions, each an append-only log.
- Each record in a partition has an offset, a per-partition sequence number.
- Order is guaranteed only within a partition. Same key → same partition (by default) → per-key order.
- Reading doesn't delete. Many readers can read the same data independently, at their own pace.
- Adding partitions later changes where keys land. Plan the count for keyed topics.
Brokers & the cluster
no switchboard: clients walk straight to the right branchOne archive building can't hold every ledger in a big city, and one fire would destroy everything. So the city runs several branches. In Kafka, a branch is a broker: a server process that stores partitions and serves reads and writes for them. A group of brokers working together is a cluster.
The partitions of a topic are spread across the brokers. Each partition also has copies (replicas, chapter 5) on several brokers, and exactly one of those copies is the leader. All writes for a partition go to its leader. There are normally many more partitions than brokers, and Kafka spreads leadership around so each broker leads its fair share.
Finding the right branch: bootstrap + metadata
A client (producer or consumer) needs to know which broker leads which partition. It learns this in two steps:
- Bootstrap. You configure
bootstrap.servers, a list likehost1:9092,host2:9092. It's used for the first connection (and again to re-bootstrap if every known broker becomes unreachable, the defaultmetadata.recovery.strategy=rebootstrap). It doesn't need to list every broker, but the docs recommend listing more than one in case one is down. - Metadata. The client asks any broker for metadata. Every broker can answer: which brokers exist, and for each partition which broker is the leader, which brokers hold replicas, and which replicas are in sync.
Then the client connects directly to the leader of each partition it needs. There's no router or load balancer in the middle. In archive terms: you walk into any branch and read the notice board ("Payments book 1 is kept at branch B2"), then you walk to B2 yourself. This is a deliberate design choice. No extra hop, and no central bottleneck in the data path.
Who keeps the notice board up to date? The controllers
Somebody has to decide which broker leads which partition and notice when a broker dies. In Kafka 4.x that's the job of the controllers. They form a small quorum and use a Raft-based protocol called KRaft. In the analogy, they're the city council that keeps the official registry. Brokers register with the controller and send it regular heartbeats. If no heartbeat arrives within broker.session.timeout.ms (default 9000 ms), the broker is considered offline, and the controller picks a new leader for its partitions from the replicas that are in sync.
For learning you can run one process that is both broker and controller. The quickstart's config/server.properties sets process.roles=broker,controller. In production they're usually separate machines. Kafka 4.x has no ZooKeeper at all. Chapter 9 covers KRaft in depth.
orders: 3 partitions, each with a leader on a different broker.NOT_LEADER_OR_FOLLOWER as a "metadata is stale" error and requests a metadata update before retrying.metadata.max.age.ms (default 5 minutes) anyway, so you notice new brokers and partitions.Describe the topic you created in chapter 1:
bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic payments
Find the Leader:, Replicas: and Isr: columns for each partition. On a single-node cluster, what must they all say, and why?
Answer
Every partition has the same leader, replica list and ISR: the one node's ID (with the quickstart config, 1). With one broker there's nowhere else to put a leader or a copy. On a three-broker cluster with --replication-factor 3 you'd see three IDs under Replicas and the leaders spread across the brokers.
Staying in sync with reality. Clients refresh metadata in two ways: periodically (metadata.max.age.ms, default 300000 ms, to discover new brokers or partitions proactively) and on demand when a broker returns a "you're talking to the wrong node" error. Errors like NOT_LEADER_OR_FOLLOWER (error code 6) and UNKNOWN_TOPIC_OR_PARTITION extend InvalidMetadataException, which is a retriable exception. The producer retries the batch after the refresh, so your application normally never sees them.
🔬 Go deeper: what's inside a MetadataResponse
From MetadataResponse.json in the protocol definitions: a list of Brokers (NodeId, Host, Port, Rack), the ClusterId, a ControllerId, and per topic its Name, TopicId and Partitions. Each partition entry has PartitionIndex, LeaderId, LeaderEpoch, ReplicaNodes, IsrNodes and OfflineReplicas. On the broker, KafkaApis.handleTopicMetadataRequest answers from a local metadata cache (KRaftMetadataCache). That cache is fed by the controllers' metadata log, which is why any broker can answer.
Skipping a round-trip (KIP-951). Since ProduceResponse v10, a NOT_LEADER_OR_FOLLOWER response can carry tagged fields CurrentLeader (leader id + epoch) and NodeEndpoints. In the producer's Sender, that hint updates the partition's leader right away, and a normal metadata update is still requested. The LeaderEpoch number prevents confusion between an old and a new leader. Chapter 16 explains it.
- A broker stores partitions and serves requests; several brokers form a cluster.
- Each partition has one leader broker; writes go to the leader. Leadership is spread across brokers.
bootstrap.serversis for first contact (and re-bootstrap); the client then gets metadata from any broker.- Clients talk directly to partition leaders. No proxy or router sits in the data path.
- KRaft controllers track broker liveness (heartbeats,
broker.session.timeout.ms9 s) and pick new leaders. - A stale client gets
NOT_LEADER_OR_FOLLOWER, refreshes metadata and retries automatically.
Producer basics
send() returns before anything leaves the buildingA producer is the client that writes records to Kafka. In the archive, it's a courier. You hand it letters (records); it sorts them by destination book (partition) into sacks (batches), and a van (a background thread) drives full sacks to the right branch. Sending one letter per trip would be absurdly slow, so batching is at the heart of Kafka's performance.
What happens when you call send()
The Java KafkaProducer.send() is asynchronous. It puts the record into a buffer and returns right away with a Future. You can wait on that future or pass a callback to learn the outcome later. Inside, the record goes through a small pipeline:
- Metadata. The producer needs to know the topic's partitions (chapter 2). The first send to a topic may wait for metadata.
- Serializer. Your key and value objects become bytes via
key.serializerandvalue.serializer, e.g.StringSerializer. - Partitioner. Picks the partition. If you set one explicitly, it's used. Otherwise, with a key, it's
murmur2(key bytes)made positive, mod the partition count. Without a key, the producer uses a sticky partition that changes after at leastbatch.sizebytes have been sent to it. - Record accumulator. The record is appended to the open batch for that partition, in a memory pool of size
buffer.memory. - Sender thread. A background I/O thread (named
kafka-producer-network-thread…) collects ready batches, groups them per leader broker into produce requests, and sends them.
A batch is "ready" when it's full (batch.size, default 16384 bytes) or it has waited linger.ms. Since Kafka 4.0 the default linger.ms is 5 ms (it was 0 before). The source says the change was made because larger batches usually give similar or lower latency despite the extra wait.
linger.ms=0 whenever records pile up while a request is in flight. Only the rules are real: sent when full or when linger.ms expires, and send() blocks when the buffer is full.acks: when is a write "done"?
The acks setting decides how much confirmation the producer waits for. acks=0: don't wait at all. acks=1: the leader wrote it to its own log. acks=all (same as -1, and the default): the leader waits until all in-sync replicas have it. Chapter 5 shows what each one can lose.
Producers also retry automatically (retries defaults to Integer.MAX_VALUE, bounded in time by delivery.timeout.ms, default 120000 ms). Retries raise a problem: if an ack is lost, the retry could write the record twice. That's why enable.idempotence is true by default. The broker then drops the duplicate retry (chapter 12 shows how). Idempotence needs acks=all. If you set a conflicting value like acks=1 without explicitly enabling idempotence, it is quietly turned off.
| Config | Default | What it does | Use it when |
|---|---|---|---|
bootstrap.servers | (required) | Initial brokers to contact | Always; list 2–3 brokers |
key.serializer / value.serializer | (required) | Turn objects into bytes | Always; match your data format |
acks | all | How many replicas must confirm | Keep all unless losing data is acceptable |
linger.ms | 5 | Max wait to fill a batch | Raise (10–100) for throughput; lower for latency at low volume |
batch.size | 16384 bytes | Max size of one partition's batch | Raise with high volume or compression |
buffer.memory | 33554432 (32 MiB) | Total memory for unsent records | Raise if send() blocks under bursts |
max.block.ms | 60000 | How long send() may block (metadata, full buffer) | Lower if your caller must never hang long |
enable.idempotence | true | Broker de-duplicates producer retries | Keep on; needs acks=all |
compression.type | none | Compress whole batches | Big or repetitive payloads (gzip, snappy, lz4, zstd) |
Fire-and-forget by accident. Calling producer.send(record) and ignoring the returned future means you never find out if the write failed. Always pass a callback, or at least log failures. And close the producer on shutdown: the Javadoc warns that not closing it leaks its buffer and I/O thread, and close() is also what waits for the sending of all incomplete requests to finish.
KafkaProducer is thread-safe, and the Javadoc says sharing one instance across threads is generally faster than having several. One producer per message is a classic performance killer.🔬 Go deeper: the classes behind send()
KafkaProducer.doSend() waits for metadata (waitOnMetadata), serializes key and value, computes the partition, checks the estimated size against max.request.size (default 1048576), then calls RecordAccumulator.append(). The accumulator keeps a deque of ProducerBatches per partition. If the append filled a batch or opened a new one, it wakes up the Sender. The sender runs on its own KafkaThread. It drains ready batches per node and sends produce requests, with at most max.in.flight.requests.per.connection (default 5) unacknowledged requests per connection.
Key hashing lives in BuiltInPartitioner.partitionForKey(): Utils.toPositive(Utils.murmur2(serializedKey)) % numPartitions. For keyless records the built-in partitioner is sticky, and with partitioner.adaptive.partitioning.enable=true (default) it sends more data to partitions on faster brokers. partitioner.ignore.keys=true makes it ignore keys completely. The only other shipped strategy is RoundRobinPartitioner; its docs note a known uneven-distribution issue (KAFKA-9965).
send()is asynchronous: serialize → partition → accumulate in a per-partition batch → a background sender ships it.- A batch goes out when it's full (
batch.size16384) or afterlinger.ms(default 5 since 4.0). - Keyed records:
murmur2hash mod partition count. Keyless: sticky partition. - Defaults are safe:
acks=all,enable.idempotence=true, retries bounded bydelivery.timeout.ms(120 s). - A full buffer makes
send()block up tomax.block.ms(60 s), then fail. - Use one shared producer, check results via callback, and always
close()it.
Consumer basics
the reader keeps the bookmark, not the libraryA consumer reads records from topics. In the archive, a consumer is a clerk who reads a ledger book line by line and does something with each entry: updates a balance, sends an email, copies it into a data warehouse.
Pull, not push
Kafka consumers pull. The consumer sends fetch requests to the partition leaders and says "give me records starting at offset N". The broker never pushes data at a consumer. The design docs explain why: with push, a slow consumer gets flooded when production outpaces it. With pull, a slow consumer just falls behind and catches up when it can. Pull also batches naturally, because each fetch takes everything available after the position (up to a size limit).
What if there's nothing new? The consumer doesn't spin in a tight loop. A fetch can wait on the broker (a "long poll") until at least fetch.min.bytes (default 1 byte) is available or fetch.max.wait.ms (default 500 ms) has passed.
The poll loop
Your code calls consumer.poll(timeout) in a loop. Each call returns a batch of records (at most max.poll.records, default 500), you process them, then you poll again:
consumer.subscribe(List.of("payments"));
while (running) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> r : records) {
handle(r.key(), r.value()); // your business logic
}
// with enable.auto.commit=true (default), progress is committed in the background
}
Two rules to remember from the Javadoc. The KafkaConsumer is not thread-safe: use one instance per thread. And you must call poll() often enough. If more than max.poll.interval.ms (default 300000 ms, 5 minutes) passes between polls, the consumer is considered failed and its partitions are given to someone else.
Position vs committed offset: the bookmark
A consumer tracks two numbers per partition:
- Position: the offset of the next record it will receive. It's one more than the highest offset it has seen, and it advances automatically with every
poll(). It lives in the consumer's memory. - Committed offset: the last position stored safely in Kafka. If the consumer crashes and restarts (or another consumer takes over), it resumes from here.
In the archive, the committed offset is a bookmark the clerk slips into the book and registers at the front desk. The position is just where the clerk's finger is right now. If the clerk faints, the finger's position is lost, but the bookmark stays.
Where do bookmarks live? In an internal, compacted Kafka topic called __consumer_offsets. Each group has one broker acting as its group coordinator. The consumer finds it with a FindCoordinator request and sends its offset commits there. The coordinator writes each commit to __consumer_offsets and replies only after all replicas of that topic have it. By default the topic has 50 partitions (offsets.topic.num.partitions) and replication factor 3 (offsets.topic.replication.factor).
By default (enable.auto.commit=true), the consumer commits its position automatically every auto.commit.interval.ms (default 5000 ms). You can switch that off and call commitSync() or commitAsync() yourself.
The order of process and commit decides your delivery guarantee. Commit then process: a crash in between skips records (at-most-once). Process then commit: a crash in between re-delivers records (at-least-once). At-least-once is Kafka's default promise. So write handlers that can safely see the same record twice.
Consumer groups: a team of clerks
One clerk can't keep up with a big series. So consumers join a consumer group by sharing the same group.id. Kafka splits the topic's partitions among the group's members so that each partition is read by exactly one consumer in the group. A topic with four partitions and two consumers gives each consumer two partitions. When a consumer joins, leaves or crashes, the partitions are redistributed. That's called a rebalance.
Different groups are completely independent. Each group gets all the records and keeps its own bookmarks. That covers both classic messaging styles. Queue-like: all workers in one group share the load. Pub-sub-like: each application uses its own group and sees everything.
| Partition | Owner | Committed | End | Lag |
|---|
payments has 4 partitions. Group billing has one consumer.group.protocol and assignor settings (chapter 11). Offsets and lag numbers are illustrative. The rule that each partition has exactly one owner within the group is real.auto.offset.reset. When a group has no committed offset for a partition (or the offset no longer exists), this setting decides where to start. The default is latest: only records produced from now on. Use earliest to start from the oldest available record, none to get an exception, or by_duration:<ISO-8601 duration> to start that far back in time.Consume payments as group billing, stop it with Ctrl-C, then inspect the group:
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 \
--topic payments --group billing --from-beginning
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group billing
1) What do CURRENT-OFFSET, LOG-END-OFFSET and LAG mean? 2) Run the console consumer again with the same group and --from-beginning. Why don't the old records come back? 3) How would you replay them?
Answer
1) Committed offset, the partition's latest readable offset (its high watermark), and their difference: how far behind the group is. 2) --from-beginning only applies when the consumer doesn't already have an established offset. The group committed one, so it resumes there. 3) With all group members stopped: kafka-consumer-groups.sh --bootstrap-server localhost:9092 --group billing --topic payments --reset-offsets --to-earliest --execute. Without --execute it's a dry run that only shows the plan.
| Symptom | Likely cause | Fix |
|---|---|---|
| LAG keeps growing | Consumers process slower than producers write | Speed up handling, add consumers (up to the partition count), add partitions |
| One consumer gets nothing | More consumers than partitions | Expected. Remove it or add partitions |
| Constant rebalances, commits fail after them | Processing one poll's batch takes longer than max.poll.interval.ms | Lower max.poll.records, speed up handling, or raise the interval |
| New group sees no old data | auto.offset.reset=latest (default) | Set earliest before the group's first start, or reset offsets |
| Duplicates after a crash | At-least-once: processed but not yet committed | Make processing idempotent (dedupe by key or ID) |
Two group protocols. group.protocol defaults to classic. The next-generation protocol from KIP-848 (group.protocol=consumer) became generally available in Kafka 4.0. It moves partition assignment to the broker and replaces the stop-the-world rebalance with incremental, heartbeat-driven reconciliation. Chapter 11 races them against each other.
Bookmarks can expire. Committed offsets aren't kept forever. Once a group has no members, its offsets are discarded after offsets.retention.minutes (default 10080 minutes = 7 days). When it comes back, auto.offset.reset applies again.
🔬 Go deeper: where the numbers live in the client
In the Java client, KafkaConsumer delegates to one of two implementations: ClassicKafkaConsumer (classic protocol, with ConsumerCoordinator for group membership) or AsyncKafkaConsumer (the KIP-848 consumer protocol, with a background network thread). Both keep per-partition state (assigned partitions, fetch position, paused flags) in SubscriptionState.
Why can offset tracking be this cheap? Traditional brokers keep per-message state ("sent", "acknowledged", "consumed"), and they must solve lost-ack and double-delivery problems on the server. Kafka's design gives each partition to exactly one consumer per group, so all the state is one integer per partition per group, checkpointed periodically. The side effect is a feature: you can rewind (seek or reset offsets) and re-process after fixing a bug. With a classic queue that's impossible, because the messages are gone.
- Consumers pull with fetch requests from a given offset; long polling avoids busy loops.
- Position = next offset to read (in memory). Committed offset = saved bookmark in
__consumer_offsets. - Auto-commit is on by default (every 5 s). Process-then-commit gives at-least-once.
- A consumer group splits partitions: each partition has exactly one owner per group. Extra consumers idle.
- Different groups are independent and each sees every record.
auto.offset.resetdefaults tolatest. Keep polling withinmax.poll.interval.ms(5 min).
Replication basics
one book burns, the photocopies take overDisks die. Machines reboot. A whole rack loses power. If each partition lived on only one broker, any of those would make data unavailable or lose it. So Kafka keeps replicas: copies of each partition on several brokers. In the archive, every ledger book has photocopies kept in other branches, and the copies are updated as new lines are written.
Leader and followers
The number of copies is the topic's replication factor (RF). You set it per topic, and it includes the original. One replica is the leader. It receives all writes (and normally all reads). The others are followers. They fetch new records from the leader, just like a consumer would, and append them to their own copy. All replicas have the same records at the same offsets in the same order. A follower may just be a few records behind at the tail.
The broker-wide default for new topics is default.replication.factor=1, which means no copies at all. The docs call RF 3 a common production setting. Set it explicitly (for example --replication-factor 3).
In-sync replicas (ISR)
Not every follower is always up to date. The leader tracks the in-sync replicas (ISR): the replicas that are caught up. A replica is in sync if its broker has a live session with the controller and it keeps up with the leader. A follower that can't catch up to the leader's log end within replica.lag.time.max.ms (default 30000 ms) is removed from the ISR. It rejoins after catching up fully.
A record is committed when all current ISR members have it and the ISR has at least min.insync.replicas members (more on that setting below). Only committed records are handed to consumers. If the leader dies, the controller picks the new leader from the ISR. Every ISR member has every committed record, so nothing committed is lost. With RF f+1, Kafka can survive f failed replicas without losing committed records.
acks and min.insync.replicas: your durability dial
The producer's acks decides when it hears "done":
acks=0: the record is put in the socket buffer and considered sent. No guarantee the broker got it; retries don't apply. The returned offset is always -1.acks=1: the leader wrote it to its own log. If the leader dies before a follower copies it, it's lost.acks=all(default): the leader waits for the full ISR. Not lost as long as one in-sync replica survives.
There's a trap in acks=all. "All" means all current ISR members, and the ISR can shrink. If only the leader is left in the ISR, acks=all is effectively acks=1. The topic setting min.insync.replicas (default 1) closes the gap. With acks=all, if the ISR is smaller than this number, the write is rejected with NotEnoughReplicas (or NotEnoughReplicasAfterAppend) instead of being accepted with too few copies. The Kafka docs give the typical recipe: RF 3, min.insync.replicas=2, acks=all. A majority must persist each write, and you can still lose one broker and keep writing.
payments-0, RF 3, min.insync.replicas=2. Leader on B1.NotEnoughReplicas rejection follow the producer and topic config docs.min.insync.replicas equal to RF (e.g. 3 with RF 3) sounds safe but means any single broker restart blocks all acks=all writes to that partition. Use RF − 1. The other trap is RF 1 in production: the default. One lost disk and the data is gone, whatever acks says.
A topic has RF 3 and min.insync.replicas=2. Producers use acks=all. For each case, can producers write, and can committed data be lost? a) One broker down. b) Two brokers down. c) Same as b) but the producer uses acks=1.
Answer
a) Writes succeed (ISR = 2 ≥ 2); every acknowledged record is on two brokers. b) acks=all writes are rejected with NotEnoughReplicas (ISR = 1 < 2). The partition stays readable from the leader. No acknowledged data at risk, but it's unavailable for writes. c) Writes are accepted because min.insync.replicas is only checked for acks=all. But they are not committed, so consumers can't see them until the ISR grows back to 2. Meanwhile each record lives on one broker. Lose it and those records are gone.
To try it for real, create a topic with the setting: bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic safe --partitions 3 --replication-factor 3 --config min.insync.replicas=2 (needs 3 brokers).
Why not majority voting? Many systems (Raft, ZooKeeper's Zab) commit on a majority: 2f+1 replicas to survive f failures. Kafka's ISR approach survives f failures with f+1 replicas, because it waits for all in-sync replicas and elects only from them. The design docs argue this matters for bulk data. Five copies of every byte for two-failure tolerance would cost 5× the disk and a fifth of the throughput. The cost is that a slow in-sync follower slows commits until it drops out of the ISR.
When every replica dies. Kafka then waits for an ISR member to come back instead of electing a stale replica. That's unclean.leader.election.enable=false, the default. Turning it on trades consistency (possible loss of committed data) for availability.
🔬 Go deeper: who does what on the broker
On each broker, ReplicaManager owns the local replicas and handles produce and fetch requests. Each partition is a kafka.cluster.Partition object that knows its leader, ISR and log (UnifiedLog). Followers replicate by sending ordinary fetch requests to the leader. That's how a follower's progress reaches the leader, and how the leader decides when a record is committed (the high watermark, chapter 10). ISR changes are persisted in the cluster metadata through the controller, so any ISR member can be elected leader.
The design docs also say the protocol doesn't require crashed replicas to keep all their data. A replica that lost unflushed data in a crash must fully re-sync before it rejoins the ISR. That's why Kafka doesn't need an fsync per write for correctness. And with the Eligible Leader Replicas feature (KIP-966, ch. 16), the meaning of min.insync.replicas changes a bit; the topic config docs point there.
- Each partition has RF replicas: one leader (takes writes) and followers that fetch from it.
- The ISR is the set of caught-up replicas; lagging longer than
replica.lag.time.max.ms(30 s) drops you out. - Committed = on all ISR members (with ISR ≥
min.insync.replicas). Consumers only see committed records; new leaders come from the ISR. acks=0/1can lose acknowledged writes;acks=all(default) can't while one ISR member survives.min.insync.replicas(default 1) rejectsacks=allwrites when the ISR is too small.- Typical recipe: RF 3, min.insync.replicas 2, acks all. Default RF is 1, so set it explicitly.
Retention basics
the shredder works by the volume, not by the lineReading doesn't delete, so something else must, or disks fill up forever. That's retention. The archive has a policy like "we keep ledger entries for seven days". Old records are removed based on time or size, never based on whether someone read them. A consumer that is days behind can still catch up, as long as it stays inside the retention window.
Two cleanup policies
Each topic has a cleanup.policy:
delete(the default): discard old data when it's older thanretention.msor when the partition is bigger thanretention.bytes. Good for event streams where each record stands alone: clicks, logs, payments.compact: keep at least the latest record for each key and remove older records with the same key. The archive only keeps the newest entry per account. Good for "current state" topics, like a customer's latest address. Chapter 14 covers compaction.- Both at once,
delete,compact: compact, and also drop old segments by time/size.
The defaults
| Config | Default | What it does | Use it when |
|---|---|---|---|
cleanup.policy | delete | Delete old segments, or compact by key | compact for latest-value-per-key topics |
retention.ms | 604800000 (7 days) | Max age before a segment may be deleted; -1 = no time limit | Match how far back consumers must be able to re-read |
retention.bytes | -1 (no limit) | Max size per partition before old segments go | Cap disk use on busy topics |
segment.bytes | 1073741824 (1 GiB) | Size at which a new segment file starts | Smaller on low-volume topics so retention can act sooner |
segment.ms | 604800000 (7 days) | Age at which a new segment starts, even if not full | Same reason as above |
log.retention.check.interval.ms (broker) | 300000 (5 min) | How often the broker looks for deletable segments | Rarely changed |
The broker-level equivalents (log.retention.hours = 168, log.retention.bytes, log.cleanup.policy…) set the default for every topic. The topic-level settings override them for one topic.
Deletion happens per segment
Remember from chapter 1 that a partition is a series of segment files (the book's volumes). Time and size retention never delete single records. They delete whole segments, oldest first. For time-based retention, the largest timestamp in the segment counts. A segment goes only when even its newest record is older than retention.ms. So data often lives longer than retention.ms: up to about one extra segment's worth of time, plus the time until the next retention check.
If both limits are set, a segment is deleted when either the time or the size rule says so.
segment.bytes 1 GiB or segment.ms 7 days, whichever comes first), 100 records per segment, and deletion as soon as the day turns (really: at the next retention check). The rules are real: whole segments only, judged by their newest record's timestamp, and an out-of-range consumer falls back to auto.offset.reset.A slow consumer can lose data silently. If a consumer's committed offset points into a segment that has already been deleted, the offset no longer exists. The consumer then applies auto.offset.reset. With the default latest, it jumps to the end and skips everything in between, with only a log line to show for it. For consumers that must not skip data, alert on lag well before the retention window runs out. You can also set auto.offset.reset=earliest (re-read what's left) or none (fail loudly).
Size a topic. A topic receives 2 MB/s on average, has 6 partitions and RF 3, and must keep 3 days of data. 1) Roughly how much disk does it need across the cluster? 2) Set its retention to 3 days. 3) What retention.bytes would cap each partition at the same amount if traffic is spread evenly?
Answer
1) 2 MB/s × 86 400 s/day × 3 days ≈ 518 GB of data, × RF 3 ≈ 1.56 TB across the cluster, plus headroom for the extra segment per partition and traffic peaks.
bin/kafka-configs.sh --bootstrap-server localhost:9092 --alter \
--entity-type topics --entity-name payments --add-config retention.ms=259200000
3) retention.bytes is per partition: 518 GB ÷ 6 ≈ 86 GB. Leaving it at -1 is also fine if the time limit is enough.
retention.ms=-1 with retention.bytes=-1. Some teams keep full event histories that way, and compacted topics do something similar without growing forever.retention.bytes is a floor, not a ceiling. In UnifiedLog.deleteRetentionSizeBreachedSegments, the broker computes how far the partition is over the limit and deletes an old segment only if the partition is still at least retention.bytes after removing it. So a partition hovers somewhere between the limit and the limit plus one segment. With 1 GiB segments and a small retention.bytes, that difference is big.
Future timestamps block deletion. Time retention compares the broker clock with each segment's largest record timestamp. A producer with a clock set years ahead makes that segment "not old yet" for years, and the broker logs a warning about future timestamps.
🔬 Go deeper: how deletion stays safe for readers
A background task in the log manager runs every log.retention.check.interval.ms. For each log it calls UnifiedLog.deleteOldSegments(), which (for the delete policy) runs deleteRetentionSizeBreachedSegments and deleteRetentionMsBreachedSegments. These walk segments oldest-first and stop at the first one that isn't deletable, so deletion is always a prefix of the log. The log start offset moves forward before files are removed. A partition always keeps at least one segment: if every segment would go, a fresh empty one is rolled first.
Readers aren't blocked. The implementation docs describe a copy-on-write segment list, so a fetch doing a binary search over segments sees an immutable snapshot while deletions proceed. A consumer asking for an offset below the new log start offset gets an OFFSET_OUT_OF_RANGE error (code 1), and that's what triggers auto.offset.reset.
- Retention removes data by time or size, never because it was consumed.
cleanup.policy:delete(default),compact(latest per key), or both.- Defaults:
retention.ms7 days,retention.bytes-1 (unlimited, and per partition). - Deletion is by whole segment, judged by the segment's newest timestamp, so data can outlive
retention.ms. - A consumer that falls behind retention gets out-of-range and follows
auto.offset.reset.latestsilently skips. - Size a topic: throughput × retention × RF, plus headroom.
How it really works
Open the hood: what a partition looks like on disk, what bytes travel on the wire, how the controllers keep the city registry, how the high watermark moves, how groups rebalance, and how the producer avoids duplicates. After this part the Part I rules stop being rules and start being consequences.
The storage engine: segments, indexes and the page cache
a ledger book is really a shelf of thin volumesIn Part I a partition was one bound ledger book. Time to confess: nobody binds a book that never ends. In the archive, each "book" is really a shelf of thin volumes. Clerks only ever write in the last volume; when it gets thick, they shelve it and start a fresh one. Kafka calls those volumes segments.
On disk, a topic called my-topic with two partitions is simply two directories, my-topic-0 and my-topic-1. Inside each directory the segment files are named after the offset of the first record they contain, zero-padded to 20 digits, so the first one is always 00000000000000000000.log. That file name is the segment's base offset.
A segment is not one file but a small family that shares the same base-offset name:
| File | What it holds | Why it exists |
|---|---|---|
.log | The record batches, in the same binary format the producer sent (the broker stamps offsets and leader epoch; Chapter 8) | The data. Appends only ever go to the newest (active) segment |
.index | Sparse map: offset → byte position in the .log. 8-byte entries (4-byte relative offset + 4-byte position) | Jump close to an offset without scanning the whole file |
.timeindex | Sparse map: timestamp → offset. 12-byte entries (8-byte timestamp + 4-byte relative offset) | "Give me the first record after 09:00" (offsetsForTimes, time-based retention) |
.txnindex | Aborted transactions that touch this segment | Lets read_committed consumers skip aborted data (Part III) |
.snapshot | Producer-state snapshot (producer ids, epochs, last sequences), taken when a segment rolls and named after the new segment's base offset | Rebuild idempotence state quickly after restart (Chapter 12) |
Next to them live a couple of per-partition files such as leader-epoch-checkpoint and partition.metadata. You will meet the leader-epoch file again in Part III.
Rolling: when does a new volume start?
The active segment is rolled (closed, and a new one started) when it would grow past segment.bytes (default 1 GiB), when it is older than segment.ms (default 7 days), or when one of its index files is full. Why care? Because retention and compaction work one whole segment at a time. Time-based retention looks at the largest timestamp in a segment. If a topic has retention.ms of one hour but keeps writing into the same 1 GiB segment for a day, that segment's newest record is always fresh, so its oldest records stay on disk far longer than an hour. Records leave in whole volumes, never one line at a time.
Reading: three hops to any offset
A consumer says "give me offset 2 600". The broker does three cheap hops. 1. Pick the segment: segments are kept in a sorted map keyed by base offset, and the broker takes the floor entry (the largest base offset ≤ 2 600). 2. Look in that segment's .index (memory-mapped, binary-searched) for the largest indexed offset ≤ 2 600, which gives a byte position. 3. Read the .log forward from that position until it meets the batch containing 2 600. Try it:
index.interval.bytes = 4096 bytes of appended data). Real index entries store offsets relative to the segment's base offset; the figure shows absolute offsets for readability.Notice what the index is not: it is not an entry per record. It is sparse. The broker adds an entry only after roughly index.interval.bytes (default 4096) of data has been appended since the last one. So the index stays tiny and hot in memory, and the forward scan is at most a few kilobytes. That is the whole trick: a binary search over a small array plus a short sequential read.
.log if needed. The .log itself is protected by a CRC on every batch, and recovery truncates at the last valid batch.Why "just files" is fast: page cache, sequential I/O, zero-copy
Kafka's design notes are blunt about it: "don't fear the filesystem". Linear reads and writes are the pattern disks and operating systems handle best (the docs quote about 600 MB/s linear versus about 100 kB/s random writes on one old disk array). The OS already spends all free memory on the page cache, so Kafka appends into that cache and doesn't build its own heap cache. That avoids storing everything twice, avoids GC pressure, and the cache stays warm when the broker process restarts.
On the way out, the broker uses sendfile (Java's FileChannel.transferTo): bytes go from page cache to the socket without a round-trip through user space. The usual path is four copies and two system calls; with sendfile only the final copy to the NIC buffer remains. Because the on-disk format equals the wire format, there is nothing to convert. The docs point out that when consumers are caught up, you often see no disk reads at all, because everything is served from cache.
TLS switches zero-copy off. Encryption happens in user space, so with SSL enabled Kafka can't use sendfile. Every byte is read into the JVM, encrypted and written out. That's the right trade for security, but budget CPU for it and don't compare plaintext benchmarks with TLS production.
Lagging consumers evict the cache. A consumer re-reading last week's data pulls cold segments from disk into page cache and can push out the hot tail that everyone else is reading. If "one replay job slowed down everyone", this is usually why.
The fsync question
By default Kafka never forces an fsync on a schedule: flush.messages and flush.ms both default to Long.MAX_VALUE. An acknowledged write may sit only in page cache. Is that reckless? No. Kafka gets durability from replication, not from the disk of one machine. A write acknowledged with acks=all is in the page cache of every in-sync replica, and a crashed replica must fully re-sync before it may rejoin the ISR (Chapter 10). The design notes put the cost of fsync-per-write at "two to three orders of magnitude" of throughput. The config docs literally say "we recommend you not set this".
| Config (topic / broker) | Default | What it does | Use it when |
|---|---|---|---|
segment.bytes / log.segment.bytes | 1073741824 (1 GiB) | Max size of one segment file | Smaller for compacted or short-retention topics so old data becomes eligible sooner |
segment.ms / log.roll.hours | 604800000 (7 d) / 168 | Force a roll after this age even if not full | Low-volume topics with short retention or compaction lag goals |
segment.jitter.ms | 0 | Random jitter subtracted from the roll time | Many partitions created together that would all roll at once |
index.interval.bytes | 4096 | Bytes appended between offset-index entries | Almost never. Smaller means bigger index and shorter scans |
segment.index.bytes | 10485760 (10 MiB) | Preallocated max size of each index; full index forces a roll | Rarely. "You generally should not need to change this" |
flush.messages / flush.ms | Long.MAX_VALUE | Force fsync every N messages / N ms | Single-replica setups that must survive power loss (accept the throughput hit) |
preallocate | false | Preallocate the file on disk when creating a segment | Specific filesystems where preallocation helps |
On a local 4.3 quickstart node (its log.dirs is /tmp/kraft-combined-logs), create a topic with tiny 1 MiB segments, write some data and look at the files. How many segment families do you expect after ~3 MB of data? What does kafka-dump-log.sh print per batch?
bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic seg-demo \
--partitions 1 --config segment.bytes=1048576
bin/kafka-producer-perf-test.sh --topic seg-demo --num-records 30000 --record-size 100 \
--throughput -1 --bootstrap-server localhost:9092
ls -l /tmp/kraft-combined-logs/seg-demo-0/
bin/kafka-dump-log.sh --files /tmp/kraft-combined-logs/seg-demo-0/00000000000000000000.log | head
bin/kafka-dump-log.sh --files /tmp/kraft-combined-logs/seg-demo-0/00000000000000000000.index | head
Answer
About 3 MB of payload plus batch overhead means roughly three or four .log files, each with its own .index and .timeindex (1 MiB is the minimum allowed segment.bytes). Each file name is the base offset of the first record inside. The .log dump prints one line per batch, like baseOffset: … lastOffset: … count: … baseSequence: … lastSequence: … producerId: … producerEpoch: … partitionLeaderEpoch: … isTransactional: … isControl: … deleteHorizonMs: … position: … CreateTime: … size: … magic: 2 compresscodec: none crc: … isvalid: true. The .index dump prints offset: … position: … pairs. An entry is added once more than 4 096 bytes have accumulated since the last one, so with the perf test's full ~16 KiB batches (default batch.size 16384) you'll see roughly one entry per batch. That's the sparse index you just animated.
🔬 Go deeper: the classes behind a partition directory
UnifiedLog (storage module) is the per-partition log object that the broker's ReplicaManager/Partition talk to. It owns a LocalLog, which holds LogSegments: a ConcurrentSkipListMap<Long, LogSegment> keyed by base offset. The "pick a segment" hop is literally floorEntry(offset) on that map. Readers get a consistent view while retention deletes segments. The docs describe this as a copy-on-write style segment list so a binary search can run on a stable snapshot.
Each LogSegment owns a FileRecords (the .log), a lazily-loaded OffsetIndex and TimeIndex (both extend AbstractIndex and are memory-mapped with a MappedByteBuffer) and a TransactionIndex. In LogSegment.append, the segment keeps a bytesSinceLastIndexEntry counter. Once it exceeds indexIntervalBytes, it appends (batch.lastOffset, physicalPosition) to the offset index and maybe a time-index entry. Index entries point at batches, not individual records.
LogSegment.shouldRoll returns true when the segment would exceed maxSegmentBytes, when a non-empty segment has waited longer than segment.ms − rollJitterMs, when the offset or time index is full, or when the new batch's max offset can't be converted to a relative offset. On the network side, PlaintextTransportLayer.transferFrom calls fileChannel.transferTo(position, count, socketChannel). That's the zero-copy path.
On startup, recovery walks the newest segment(s) and validates every batch by size and CRC. On corruption, the log is truncated to the last valid offset. The docs explain why both truncation and "garbage appended" must be handled: the OS doesn't order inode-size updates and data-block writes.
- A partition is a directory of segments; each segment is
.log+.index+.timeindex(+.txnindex,.snapshot), all named by base offset. - Rolls happen at
segment.bytes(1 GiB) orsegment.ms(7 d) or when an index fills. Retention deletes whole segments, never single records. - Offset lookup = floor segment by base offset → binary search in the sparse, memory-mapped index → short forward scan.
- Speed comes from sequential I/O, the OS page cache and
sendfilezero-copy (not available with TLS). - No fsync by default. Durability comes from replication to the ISR, not from one disk.
The record batch format (v2) and compression
one envelope, many letters, one stampKafka never really stores "a message". It stores record batches. Even a single send() becomes a batch of one. In archive terms, a batch is a sealed envelope delivered to the ledger office: the clerk stamps the envelope once (offset, checksum, producer id) instead of stamping every letter inside. This is the "message set" idea from the design docs. Group messages so that network round-trips, disk writes and checksums are paid per batch, not per record.
The same bytes travel producer → broker → disk → consumer unchanged whenever possible. That's why the format is a stable, versioned interface: the current magic value is 2 (introduced in Kafka 0.11; older formats are documented only for history). Here is the envelope, field by field. Click any field.
Records inside the envelope: deltas and varints
Each record inside the batch is tiny on purpose. It has a varint length, one unused attributes byte, a timestampDelta (varlong) and an offsetDelta (varint), both relative to the batch's base values, then key, value and headers, each prefixed by a varint length. Varints use the same encoding as Protobuf, so small numbers cost one byte. A record whose offset is baseOffset + 3 and that was produced 2 ms after the first costs one byte for each delta instead of 8 + 8.
Headers (key/value pairs of metadata, like tracing ids) are part of every record. Their key is never null, the value may be null, and their order is preserved from producer to consumer.
The producer doesn't know the final offsets, because the leader assigns them. Yet the batch has a baseOffset field and every record carries an offsetDelta. How can the broker assign offsets to a compressed batch without decompressing and recompressing it?
Think, then open
Only baseOffset (in the uncompressed header) changes. The deltas inside stay valid because they are relative. The broker writes a new base offset in the header and recomputes nothing inside. It doesn't even recompute the CRC for this, because the CRC covers the bytes from attributes onward, and baseOffset, batchLength and partitionLeaderEpoch sit before it. That's exactly why partitionLeaderEpoch was placed outside the CRC: the leader stamps it on every batch it receives.
Compression: end-to-end and batch-level
Compressing one message at a time compresses badly: the redundancy is between messages (the same JSON field names, the same user agents). So Kafka compresses the records section of a whole batch. The codec is recorded in bits 0–2 of attributes: 0 none, 1 gzip, 2 snappy, 3 lz4, 4 zstd. The compressed data follows directly after recordsCount.
And it stays compressed. The design docs describe the path: the producer compresses; the broker decompresses only to validate (for example, that the record count matches the header); the batch is written to disk still compressed; it is sent to consumers compressed; the consumer decompresses. Compression CPU is paid at the edges, and every broker hop, disk byte and replica fetch benefits.
| Config | Default | What it does | Use it when |
|---|---|---|---|
compression.type (producer) | none | Codec for batches this producer creates: none, gzip, snappy, lz4, zstd | Almost always worth turning on for text/JSON; combine with batching (linger.ms, batch.size) for better ratios |
compression.type (topic / broker) | producer | Final codec on disk. producer keeps whatever the producer used; uncompressed or a codec name forces that one | Leave at producer unless you must enforce a codec on disk |
compression.gzip.level | gzip default (−1) | gzip level 1–9 (or −1 for gzip's default) | Trading CPU for ratio with gzip |
compression.lz4.level | 9 | lz4 level 1–17 | Tuning lz4 |
compression.zstd.level | 3 | zstd level (up to 22) | Tuning zstd; higher = smaller, slower |
The level configs exist both on the producer and as topic configs.
A topic codec that differs from the producer's codec makes the broker recompress every batch. The broker can only assign offsets "in place" when the source and target codecs match. Otherwise it decompresses, validates and rebuilds the batch with the topic's codec, which costs CPU on the leader for every produce request. If you set compression.type=zstd on a topic, make the producers send zstd too, or keep the topic at producer.
linger.ms and batch.size affect disk usage too, not just latency.Produce the same 20 000 records twice, once uncompressed and once with zstd, into two topics. Compare the directory sizes and look at the compresscodec field in the dump. Predict first: which one is smaller, and does the consumer need any config to read the compressed topic?
for c in none zstd; do
bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic comp-$c --partitions 1
bin/kafka-producer-perf-test.sh --topic comp-$c --num-records 20000 --record-size 200 \
--throughput -1 --bootstrap-server localhost:9092 \
--command-property compression.type=$c linger.ms=20
done
du -sh /tmp/kraft-combined-logs/comp-*-0
bin/kafka-dump-log.sh --files /tmp/kraft-combined-logs/comp-zstd-0/00000000000000000000.log | head -3
Answer
The perf tool's default payload is random uppercase letters (A–Z), so zstd shrinks it but far less than real JSON with repeated field names. Measure your own data, don't assume. Whatever the ratio, compresscodec: zstd appears on each batch line, and the consumer needs no configuration: the codec travels in the batch attributes, and the client decompresses automatically. compression.type is a producer (and topic) setting only.
🔬 Go deeper: CRC-32C, control batches and what compaction keeps
CRC. The checksum is CRC-32C (Castagnoli) over everything from attributes to the end of the batch. Because it sits after magic, a reader must parse the magic byte before it knows how to interpret the bytes in between. Total on-disk size of a batch is batchLength + 12 (the 8-byte base offset plus the 4-byte length field itself).
Attributes bits. 0–2 codec, 3 timestamp type (CreateTime vs LogAppendTime), 4 isTransactional, 5 isControlBatch, 6 hasDeleteHorizonMs. A control batch holds a single control record whose key is (version, type) with type 0 = abort and 1 = commit. These are the transaction markers of Part III, filtered out before your application sees them.
Compaction keeps the envelope. When the cleaner removes records, it preserves the first and last offset and sequence numbers of the original batch, even if that leaves an empty batch. That's needed so a producer's last sequence number survives and idempotence (Chapter 12) keeps working after a leader change. The baseTimestamp is not preserved; with bit 6 set it is reused as the delete horizon for tombstones and aborted-transaction markers.
Broker side. LogValidator.validateMessagesAndAssignOffsetsCompressed decides between in-place offset assignment and buildRecordsAndAssignOffsets (rebuild). In-place is impossible when source and target codecs differ or when a magic conversion is needed. The header constants live in DefaultRecordBatch, where RECORD_BATCH_OVERHEAD = 61 bytes.
- Everything is a record batch (magic 2): a 61-byte header + records. Offsets, CRC, producer id/epoch/sequence live once per batch.
- Records use varint lengths and deltas for offset and timestamp. Headers are ordered key/value pairs.
- CRC-32C covers attributes → end, so the broker can set
baseOffsetandpartitionLeaderEpochwithout recomputing it. - Compression is per batch and end-to-end: producer compresses, broker validates and stores compressed, consumer decompresses.
- Topic
compression.type=producer(default) avoids broker recompression. A mismatched topic codec forces a rebuild on every produce.
KRaft: the cluster's metadata is itself a log
the city council keeps minutes, and everyone reads themWho decides which branch holds which book, which photocopy is current, which branches are open today? In our city, the council does, and it doesn't send letters around. It keeps minutes: a numbered, append-only record of every decision. Branches read the minutes and update their own copy of the registry. That's KRaft. Since Kafka 4.0 it's the only mode; ZooKeeper is gone.
Concretely, cluster metadata (topics, partitions, leaders, ISRs, broker registrations, configs, ACLs, feature levels) lives in a single-partition internal topic called __cluster_metadata. Every change is a record in that log: TopicRecord, PartitionRecord, PartitionChangeRecord, RegisterBrokerRecord, BrokerRegistrationChangeRecord, ConfigRecord, FeatureLevelRecord and so on. The current state of the cluster is simply "replay the log from the start" (or from a snapshot).
Roles: controllers and brokers
Each server sets process.roles to broker, controller, or broker,controller ("combined", fine for development, not recommended for critical deployments). Controllers form the metadata quorum: one is the active controller (the Raft leader), the others are hot standbys that replicate the log. You typically run 3 or 5. A majority must be alive, so 3 tolerate 1 failure and 5 tolerate 2 (the docs' rule: 2N + 1 controllers survive N failures).
Brokers are observers: they replicate the metadata log too but never vote. In kafka-metadata-quorum.sh describe --status you literally see CurrentVoters (controllers) and CurrentObservers (brokers).
TopicRecord plus one PartitionRecord per partition, usually in one batch), no snapshots, and voters/observers fetch continuously rather than in visible rounds. The "commit" line is the metadata log's high watermark: an entry is committed once a majority of voters have it. Timings are illustrative; the relevant defaults are named in the text.Raft, Kafka-style: pull, not push
Textbook Raft has the leader push entries to followers. KRaft's implementation (KafkaRaftClient) describes itself as a "Kafkaesque version of the Raft protocol". Leader election is more or less pure Raft (a Vote request carrying the candidate's last log offset), but replication is driven by followers fetching, using the same Fetch API that partition replicas use (Chapter 10). Because nobody pushes, a new leader sends BeginQuorumEpoch so voters learn who to fetch from. A voter that hasn't fetched successfully from the leader for controller.quorum.fetch.timeout.ms (default 2 000 ms) becomes prospective and runs a pre-vote (KIP-996: a Vote request with PreVote=true, which isn't persisted). Only if a majority grants it does it become a candidate and start a real election.
An entry is committed once a majority of voters have it. That's the metadata log's high watermark. The active controller only completes a request once its records are durable in the metadata log, so a decision it has acknowledged can't be "un-made" by a controller failover.
Broker liveness: heartbeats and fencing
When a broker starts, it registers with the active controller (a RegisterBrokerRecord lands in the log) and then sends a heartbeat every broker.heartbeat.interval.ms (default 2 000 ms). If the controller gets no heartbeat within broker.session.timeout.ms (default 9 000 ms), the broker's lease has expired. The controller fences it (a BrokerRegistrationChangeRecord with fenced = FENCE) and moves leadership of its partitions to other ISR members. The design docs define "alive" exactly like this: an active session with the controller, plus keeping up with the leader when acting as a follower.
…-epoch.checkpoint files in __cluster_metadata-0 ). A snapshot is generated after metadata.log.max.record.bytes.between.snapshots (default 20 MiB) of new records or metadata.log.max.snapshot.interval.ms (default 1 hour). A node that's far behind receives a snapshot via FetchSnapshot instead of replaying everything.--bootstrap-controller.kafka-storage.sh format before first start?Static vs dynamic quorums (KIP-853)
Older clusters list every controller in controller.quorum.voters: a static quorum, where membership can't change without a full reconfiguration. With kraft.version 1 (release 4.1+ for upgrades of existing clusters), quorums are dynamic: nodes find the quorum through controller.quorum.bootstrap.servers (like bootstrap.servers for clients), and you add or remove controllers online with kafka-metadata-quorum.sh add-controller / remove-controller. The quorum is formatted as dynamic when controller.quorum.voters is absent and you format with --standalone, --initial-controllers or --no-initial-controllers.
| Config | Default | What it does | Use it when |
|---|---|---|---|
process.roles | (required) | broker, controller or both | Separate roles in production; combined only for dev |
controller.quorum.bootstrap.servers | empty | Where to find the quorum (dynamic quorums) | All new clusters; set on every broker and controller |
controller.quorum.voters | empty | Fixed voter list (static quorums) | Only legacy static clusters (deprecated by kraft.version 1) |
broker.heartbeat.interval.ms | 2000 | Broker → controller heartbeat period | Rarely changed |
broker.session.timeout.ms | 9000 | Broker lease length without heartbeats before fencing | Trade faster failover vs false fencing on GC pauses/networks |
controller.quorum.fetch.timeout.ms | 2000 | Voter without a successful fetch → becomes prospective (pre-vote), then candidate; leader without majority fetches → resigns | Rarely; flaky networks |
controller.quorum.election.timeout.ms | 1000 | Max wait before triggering a new election | Rarely |
metadata.log.max. | 20971520 | New bytes since last snapshot before a new snapshot | Very large clusters with heavy metadata churn |
On your quickstart node, create a topic and then find its TopicRecord in the metadata log. Before running: what do you expect LeaderId and the number of CurrentVoters to be on a single combined node?
bin/kafka-metadata-quorum.sh --bootstrap-server localhost:9092 describe --status
bin/kafka-metadata-quorum.sh --bootstrap-server localhost:9092 describe --replication
bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic orders --partitions 3
bin/kafka-dump-log.sh --cluster-metadata-decoder \
--files /tmp/kraft-combined-logs/__cluster_metadata-0/00000000000000000000.log | grep -A2 orders
Answer
On a single combined node, LeaderId is that node's id (1 in the quickstart config), there is exactly one voter and no observers (the combined node is listed once, as the voter). The dump shows a TOPIC_RECORD payload with "name":"orders" and a topic id, followed by one PARTITION_RECORD per partition with its replicas, ISR, leader and epochs. That's the council minute for "create orders". HighWatermark in --status is the metadata log's commit point.
🔬 Go deeper: from minute to local registry
The active controller is a QuorumController (metadata module). It handles admin requests on a single event thread, turns them into metadata records, and appends them through the Raft client. Broker liveness is tracked by BrokerHeartbeatManager; the broker side of registration and heartbeats is BrokerLifecycleManager. The quorum's own state machine (unattached, prospective, candidate, leader, follower…) lives in QuorumState, and the protocol in KafkaRaftClient. Its APIs are Vote, BeginQuorumEpoch, EndQuorumEpoch (graceful resignation), Fetch and FetchSnapshot.
On every node, a MetadataLoader reads committed batches and builds a MetadataDelta on top of the previous immutable MetadataImage. Publishers then apply the new image, for example to make the local ReplicaManager become leader or follower for partitions. The leader's appends linger up to controller.quorum.append.linger.ms (default 25 ms) to batch writes.
Storage bootstrap: kafka-storage.sh format --standalone writes meta.properties and a bootstrap snapshot 00000000000000000000-0000000000.checkpoint containing KRaftVersionRecord and VotersRecord. With --initial-controllers, that VotersRecord lists all controllers, and the value must be identical on every controller.
- All cluster metadata is a replicated log,
__cluster_metadata. State = replay records (or load a snapshot). - Controllers vote (Raft, majority quorum: 3 → 1 failure, 5 → 2). Brokers are observers that fetch and apply.
- Kafka's Raft is pull-based: followers fetch.
BeginQuorumEpochannounces a new leader. Commit = majority of voters. - Brokers heartbeat every 2 s; no heartbeat for
broker.session.timeout.ms(9 s) → fenced, and leaders move. - Dynamic quorums (KIP-853,
kraft.version≥ 1) usecontroller.quorum.bootstrap.serversand support add/remove-controller.
The replication protocol: LEO, high watermark and the ISR
a line counts only when every in-sync photocopy has itPart I gave you the rules: replicas, a leader, an ISR, and acks=all waits for the ISR. Now let's watch the machinery that enforces them. It runs on two numbers per replica and one simple loop.
Followers pull. A follower is basically a consumer of its leader. It sends Fetch requests with a FetchOffset ("give me everything from offset N"), appends what it gets to its own log, and asks again. The design docs point out the bonus: pulling lets the follower batch naturally. In the archive: each branch that keeps a photocopy sends a runner to the main branch saying "I have lines up to 41, give me 42 onward".
Two numbers you must know
- LEO (log end offset): the offset of the next record a replica would write. Each replica has its own.
- HW (high watermark): the offset up to which records are committed, meaning every in-sync replica has them. On the leader, the HW is the smallest LEO among the ISR (and caught-up replicas), and it only moves while the ISR has at least
min.insync.replicasmembers.
The clever part: the leader learns each follower's LEO for free, because a follower's FetchOffset is its LEO. And followers learn the HW for free, because every fetch response carries the leader's HighWatermark. There's no separate "ack" message. The fetch loop is the replication protocol.
replica.fetch.wait.max.ms, default 500 ms, when there's no new data), and ISR removal happens after replica.lag.time.max.ms (default 30 s). The seconds shown are illustrative.What the high watermark controls
Consumers only see committed records. A fetch from a normal consumer returns data only below the HW. Records above it exist on the leader but might vanish if the leader dies before followers copy them, so Kafka won't hand them out. The docs put it plainly: "only committed messages are ever given out to the consumer", regardless of the producer's acks. Records become visible only once they're on all in-sync replicas and the ISR has at least min.insync.replicas members.
acks=all waits for the HW. The leader appends the batch, then parks the produce request until the HW has passed the batch's last offset, i.e. until every ISR member has fetched it. Only then does the producer get its acknowledgement. acks=1 answers right after the leader's local append; acks=0 doesn't wait at all.
min.insync.replicas guards the door. With acks=all, if the ISR is smaller than min.insync.replicas, the leader rejects the write with NOT_ENOUGH_REPLICAS. The partition-level code also only advances the HW when the ISR is at least min.insync.replicas. Without this, "acks=all" would degrade silently to "acks=leader-only" when followers drop out. Writes with acks=0 or 1 are still accepted below the minimum, but they stay invisible to consumers until the ISR is back up to it.
ISR shrink and expand
The leader periodically checks each follower. A follower that hasn't caught up to the leader's LEO within replica.lag.time.max.ms (default 30 000 ms) is out of sync. This covers both a stuck follower (no fetches at all) and a slow one (fetching, but never reaching the end). The leader asks the controller to shrink the ISR (an AlterPartition request; the change becomes a metadata record). Once the ISR is smaller, the HW can move again, and acks=all producers unblock.
When the lagging follower catches up (its LEO reaches the HW within the current leader epoch), the leader asks to expand the ISR again. Note the time-based definition: Kafka doesn't count "messages behind". A follower 10 000 records behind that's catching up fast stays in; one that's only 5 behind but hasn't reached the end for 30 s goes out.
RF = 3 with min.insync.replicas=1 + acks=all is a trap. When two followers fall out of the ISR, the ISR is just the leader, and acks=all means "the leader has it". Kafka's own docs describe this: with only one in-sync replica left, acks=all writes succeed, but they're lost if that replica then fails. The common production recipe is RF = 3, min.insync.replicas=2, acks=all: one broker can fail with no data loss and no write outage.
Diagnosing "producers are timing out, consumers are stuck".
- List partitions whose ISR is below min ISR:
bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --under-min-isr-partitions. Those rejectacks=allwrites. - List partitions whose ISR is smaller than their assigned replica set:
--describe --under-replicated-partitions. The HW waits on the slowest in-sync follower until it's dropped. - Look at which broker is common to those partitions. Check its disk, network and GC. A slow follower drags
acks=alllatency untilreplica.lag.time.max.msexpels it. - Only then consider tuning
num.replica.fetchers(default 1) to add parallel fetcher threads.
| Symptom | Likely cause | Fix |
|---|---|---|
NotEnoughReplicasException on produce | ISR size < min.insync.replicas with acks=all | Bring the failed/lagging brokers back; don't "fix" it by lowering min ISR in production |
Produce latency spikes with acks=all, then recovers | A slow follower still in the ISR holds the HW back until it's removed | Find the slow broker (disk/GC/network); consider replica fetcher tuning |
| ISR flaps (shrink/expand repeatedly) | Follower hovers around the lag limit | Fix the follower's resources; raising replica.lag.time.max.ms only hides it |
Consumer lag at 0 but producer (acks=0/1, or a send() not yet acknowledged) says "sent" | Records above HW aren't visible yet | Expected: they appear once followers replicate |
🔬 Go deeper: Partition.maybeIncrementLeaderHW and friends
On the leader, kafka.cluster.Partition owns the ISR logic. maybeIncrementLeaderHW runs when the ISR changes or any replica's LEO changes. Its doc comment says the HW is "the smallest log end offset among all replicas that are in sync; or are considered caught-up and are allowed to join the ISR", and that "the HW can only advance if the ISR size is equal or larger than the min ISR". Replicas added by an in-flight AlterPartition already count, which the code calls the "maximal ISR" (KIP-497): being more restrictive is always safe.
getOutOfSyncReplicas(maxLagMs) uses each replica's lastCaughtUpTimeMs, the last time it was fully caught up, so stuck and slow followers are handled by one rule. maybeExpandIsr requires the follower's LEO ≥ HW and being caught up within the current leader epoch. Otherwise a follower could become leader before fetching committed data between the HW and LEO.
Followers run ReplicaFetcherThreads (num.replica.fetchers per source broker). Waiting acks=all produces sit in a purgatory as DelayedProduce operations inside ReplicaManager until the HW passes them or the request times out. Part III covers the purgatory and the leader-epoch-based truncation that makes failover safe.
- Followers pull with Fetch. Their
FetchOffsettells the leader their LEO, and the response tells them the HW. - HW = smallest LEO in the ISR, frozen while the ISR is below
min.insync.replicas. Consumers read only below it, andacks=allis acknowledged once the HW passes the batch. - A follower that hasn't caught up for
replica.lag.time.max.ms(30 s) leaves the ISR, which unblocks the HW. min.insync.replicasrejectsacks=allwrites when the ISR is too small. The usual recipe is RF 3 / min ISR 2.
Group coordination and rebalances: classic vs KIP-848
how a team of clerks hands books around without dropping oneIn Part I a consumer group was a team of clerks sharing ledger books: each book goes to exactly one clerk, and each clerk keeps a bookmark (the committed offset). The hard part isn't reading. It's changing who reads what when a clerk arrives, leaves or collapses, without two clerks reading the same book and without a book left unattended. That handover is a rebalance, and Kafka has two very different protocols for it.
The group coordinator and __consumer_offsets
Every group has a group coordinator: a broker-side component on the broker that leads one partition of the internal topic __consumer_offsets. Which partition? abs(groupId.hashCode()) % offsets.topic.num.partitions (default 50 partitions, replication factor 3). A consumer finds its coordinator with a FindCoordinator request, then sends all group traffic (joins, heartbeats, offset commits) there. Commits and group state are written as records into that partition, so they're replicated like any other data, and a new coordinator can rebuild them by replaying the partition after a failover.
Classic protocol: JoinGroup / SyncGroup
The classic protocol (the client default in 4.3: group.protocol=classic) puts the brains in the client:
- Something changes (a member joins or leaves, a member misses heartbeats, subscriptions change). The coordinator moves the group to
PreparingRebalanceand every member must sendJoinGroup. - The coordinator waits for members to rejoin, picks one as the group leader and gives it everyone's subscriptions.
- The leader runs the client-side assignor (
partition.assignment.strategy) and sends the plan inSyncGroup. The coordinator hands each member its share.
That's a global synchronization barrier: nobody gets a new assignment until everyone has joined. How painful it is depends on the rebalance flavour:
- Eager (e.g.
RangeAssignor,RoundRobinAssignor,StickyAssignor): each consumer must revoke all its partitions before joining. The whole group stops. That's the famous "stop-the-world" rebalance. - Cooperative (
CooperativeStickyAssignor): consumers keep their partitions while rebalancing. The assignor withholds partitions that must move; their owners revoke just those, and a second rebalance gives them to the new owner. Everyone else keeps working.
The default partition.assignment.strategy is [RangeAssignor, CooperativeStickyAssignor]. That means eager Range by default, but you can switch to cooperative with a single rolling bounce that removes RangeAssignor from the list.
The new consumer protocol (KIP-848)
KIP-848 is GA since Kafka 4.0 and enabled by default on the server. Clients opt in with group.protocol=consumer. It moves the brains to the broker and removes the barrier:
- Members send a periodic
ConsumerGroupHeartbeat(withMemberEpoch, subscribed topics or a regex, and the partitions they own). There's no JoinGroup/SyncGroup. - The coordinator computes a target assignment with a server-side assignor (
group.consumer.assignors:uniformby default, orrange). - Each member is reconciled individually through heartbeat responses: first "revoke these", then "you now own these". A partition is only given to its new owner once the old owner has confirmed it released it. That's what keeps two clerks off the same book.
- Heartbeat interval and session timeout become broker configs (
group.consumer.heartbeat.interval.ms5 000,group.consumer.session.timeout.ms45 000). The client configssession.timeout.ms,heartbeat.interval.msandpartition.assignment.strategycan't be used with it.
poll(), heartbeat intervals and your rebalance listener code. The final assignments shown are one valid outcome; real assignors may pick different partitions.Failure detection: two different clocks
A consumer can be "dead" in two ways. Crashed: heartbeats stop, and after the session timeout (session.timeout.ms, default 45 s, classic; group.consumer.session.timeout.ms, default 45 s, new protocol) the coordinator removes it. Until then, its partitions are not read by anyone. Stuck: the process heartbeats happily, but your code hasn't called poll() for more than max.poll.interval.ms (default 300 000 ms = 5 min). The consumer is considered failed, leaves the group, and a rebalance hands its partitions to others. When the slow code finally commits, it gets a commit failure because it no longer owns the partitions.
group.instance.id. The coordinator then recognises a restarted member as the same one and doesn't reassign its partitions, as long as it comes back before the session timeout. Duplicate ids are fenced with FencedInstanceIdException. Streams apps set one id per instance.group.protocol=consumer. When the first one joins, the group converts from Classic to Consumer and interoperates with the remaining classic members, provided the classic assignor doesn't embed custom metadata. The docs map RangeAssignor → range and the sticky/round-robin ones → uniform.KafkaConsumer defaults to the consumer protocol, and in 6.0 it only supports it, while brokers keep supporting classic for older clients.| Config | Default | What it does | Use it when |
|---|---|---|---|
group.protocol (client) | classic | Choose classic or KIP-848 consumer protocol | Set consumer for new apps on 4.x brokers |
partition.assignment.strategy | [Range, CooperativeSticky] | Client-side assignors (classic only), by preference | Drop Range to get cooperative rebalancing |
session.timeout.ms | 45000 | No heartbeat for this long → member removed (classic) | Faster failure detection vs false positives |
heartbeat.interval.ms | 3000 | Heartbeat period (classic) | Keep well below the session timeout |
max.poll.interval.ms | 300000 | Max gap between poll() calls before the member is considered failed | Raise for slow batch processing, or lower max.poll.records |
group.instance.id | null | Static membership id | Rolling deploys, stateful consumers |
group.consumer.heartbeat.interval.ms (broker) | 5000 | Heartbeat period dictated to KIP-848 members | Rarely |
group.consumer.session.timeout.ms (broker) | 45000 | Session timeout for KIP-848 members | Faster failure detection cluster-wide |
group.consumer.assignors (broker) | uniform, range | Server-side assignors; first is default | Custom server-side assignor |
group.initial.rebalance.delay.ms (broker) | 3000 | Delay the first classic rebalance of an empty group so more members can join | Tests (set 0) or large groups starting together |
Start two console consumers in the same group, one classic and one with the new protocol (in two terminals, one command each), then describe the group. What type does the group report, and what happens to the second consumer?
bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic rb-demo --partitions 6
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic rb-demo --group g1 \
--command-property group.protocol=consumer
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic rb-demo --group g1
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --list --type
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group g1 --members --verbose
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group g1 --state
Answer
The first consumer creates a Consumer-type group. The classic member still joins: the coordinator supports mixed groups so you can migrate with a rolling restart, and it serves the classic member through the classic JoinGroup/SyncGroup APIs. --list --type shows the group's TYPE (Consumer). --members alone lists both members with a #PARTITIONS count (6 split between them); add --verbose to see the actual assigned partitions. --state shows the coordinator, assignment strategy, state and member count. Stop one consumer and describe again: its partitions move to the other.
🔬 Go deeper: inside the new group coordinator
Since 4.0 the coordinator is the Java group-coordinator module. GroupCoordinatorService routes each request to a coordinator shard for the right __consumer_offsets partition (partitionFor = Utils.abs(groupId.hashCode()) % numPartitions). GroupMetadataManager holds ClassicGroup and ConsumerGroup objects. Classic groups move through states like PreparingRebalance → CompletingRebalance → Stable.
For KIP-848, TargetAssignmentBuilder computes the desired assignment for a new group epoch, and CurrentAssignmentBuilder is "the reconciliation engine": per member it walks the states STABLE, UNREVOKED_PARTITIONS (the member must revoke some partitions before moving to the next epoch) and UNRELEASED_PARTITIONS (the member moved to the new epoch but waits for partitions that previous owners haven't released yet). A member with a stale epoch gets FENCED_MEMBER_EPOCH: "the member must abandon all its partitions and rejoin".
Wire APIs: classic uses FindCoordinator (key 10), JoinGroup (11), Heartbeat (12), LeaveGroup (13) and SyncGroup (14); the new protocol uses ConsumerGroupHeartbeat (68) with fields MemberEpoch, InstanceId, RebalanceTimeoutMs, SubscribedTopicNames/SubscribedTopicRegex (RE2J, evaluated on the server), ServerAssignor and TopicPartitions.
- The coordinator for a group lives on the leader of
__consumer_offsetspartitionabs(hash(groupId)) % 50. - Classic = JoinGroup/SyncGroup with a client-side assignor and a global barrier. Eager revokes everything; cooperative revokes only what moves (two rounds).
- KIP-848 (
group.protocol=consumer) = heartbeat-driven, server-side assignor, per-member incremental reconciliation, no barrier. - Crash → partitions idle until the session timeout (45 s). Slow
poll()→ out aftermax.poll.interval.ms(5 min). group.instance.id(static membership) avoids rebalances on quick restarts.
The idempotent producer: retries without duplicates
numbered envelopes make the clerk say "already got this one"Here's the classic distributed-systems trap. The producer sends a batch; the leader appends it; the acknowledgement gets lost on the way back (a timeout, a dropped connection). The producer can't tell "not written" from "written but not acknowledged", so it retries. Before Kafka 0.11 that meant a duplicate in the log: at-least-once delivery. The design docs compare it to inserting into a database table with an auto-generated key.
The fix is the one every good clerk uses: number the envelopes. If each sender writes "envelope #7" on every envelope, the clerk can say "I already filed #7" and hand back the old receipt instead of filing it twice. Kafka does exactly this, and it's on by default (enable.idempotence=true).
The three numbers
- Producer ID (PID): a 64-bit id the producer gets from the cluster at start-up (
InitProducerIdrequest). It identifies this producer session. - Producer epoch: a 16-bit counter for the same PID. It increases when the session is "reset" (for example, after certain errors), so older in-flight writes from a previous incarnation can be recognized.
- Sequence number: per PID and per partition, starting at 0 and increasing by one per record. A batch carries its
baseSequence, and its last sequence follows from the record count.
All three live in the batch header you met in Chapter 8 (producerId, producerEpoch, baseSequence). They cost nothing extra on the wire.
What the broker checks
For every partition, the leader keeps a small producer state per PID: the current epoch and metadata about the last 5 batches it appended (sequence range and offsets). When a batch arrives:
- If its sequence range matches one of those last 5 batches, it's a duplicate. The broker doesn't append it; it answers with the offsets of the original batch, and the producer is happy.
- If its first sequence is exactly last sequence + 1, it's the next batch: append it.
- If there's a gap (or it's out of order), the broker rejects it with
OUT_OF_ORDER_SEQUENCE_NUMBER. - If its epoch is older than the one the broker knows, it's rejected with
INVALID_PRODUCER_EPOCH.
retries defaults to Integer.MAX_VALUE, bounded by delivery.timeout.ms).Why "max 5 in flight"?
The broker only remembers the last 5 batches per producer per partition. So the producer must never have more than 5 unacknowledged requests on one connection. Otherwise a retry could refer to a batch the broker has already forgotten, and duplicate detection would fail. That's why idempotence requires max.in.flight.requests.per.connection ≤ 5 (default 5), plus retries > 0 and acks=all. And the producer docs promise that with idempotence on, ordering is preserved for any allowed value. The broker's refusal to accept sequence gaps is what makes that possible: a batch can't jump ahead of an earlier one that has to be retried.
Idempotence silently turns itself off if your configs conflict and you didn't enable it explicitly. The rule from the producer docs: idempotence is on by default "if no conflicting configurations are set". If you set acks=1 (or 0) or retries=0 without setting enable.idempotence, the producer logs one INFO line and disables idempotence, so retries can duplicate again. With enable.idempotence=true set explicitly, the same configs throw a ConfigException at start-up instead. Set it explicitly so a bad config fails loudly. (The in-flight limit is stricter: since 4.0, max.in.flight.requests.per.connection > 5 with idempotence enabled is always a ConfigException; there's no silent fallback any more.)
send() twice for the same thing, or the producer restarts (new PID), Kafka sees two different messages. Cross-partition atomicity and consume-transform-produce need transactions (Part III), which build on exactly this PID/epoch/sequence machinery.producer.id.expiration.ms (default 86 400 000 ms = 1 day), and sooner if the producer's last write is deleted by retention. The docs note that keeping it at or above delivery.timeout.ms helps avoid expiry during retries..snapshot files written at segment rolls (Chapter 7). That's why log compaction preserves each batch's first and last offset and sequence, and can keep an emptied batch in the log so that a producer's last sequence number survives.| Config | Default | What it does | Use it when |
|---|---|---|---|
enable.idempotence | true | PID + sequence dedupe of retries | Always. Set it explicitly to fail loudly on conflicts |
acks | all | Required to be all for idempotence | Keep all |
max.in.flight.requests.per.connection | 5 | Unacked requests per connection; must be ≤ 5 with idempotence | Leave at 5 (ordering is still preserved) |
retries | 2147483647 | Retry count; must be > 0 for idempotence | Leave it; bound time with delivery.timeout.ms |
producer.id.expiration.ms (broker) | 86400000 | How long a leader keeps idle producer state | Very slow or bursty producers |
Produce a few records with the console producer and inspect the batch headers. Which fields show that idempotence was active, and what would they show with enable.idempotence=false?
bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic idem-demo --partitions 1
bin/kafka-console-producer.sh --bootstrap-server localhost:9092 --topic idem-demo
> a
> b
bin/kafka-dump-log.sh --files /tmp/kraft-combined-logs/idem-demo-0/00000000000000000000.log
Answer
Each batch line shows a real producerId (≥ 0), a producerEpoch, and baseSequence/lastSequence counting up from 0 across batches (0, then 1 if each line went in its own batch). With idempotence disabled (--command-property enable.idempotence=false on the console producer), you'd see producerId: -1, producerEpoch: -1 and baseSequence: -1: no identity, so no deduplication.
🔬 Go deeper: ProducerStateManager and the duplicate path
Each UnifiedLog has a ProducerStateManager holding a ProducerStateEntry per PID. The entry keeps the epoch and a queue of BatchMetadata, capped at ProducerStateEntry.NUM_BATCHES_TO_RETAIN = 5. On a client append, UnifiedLog calls findDuplicateBatch. It only matches when the epoch equals the entry's epoch and the (baseSequence, lastSequence) range equals a retained batch. On a hit, the append is skipped and the returned LogAppendInfo carries the duplicate's first/last offset and timestamp.
Otherwise ProducerAppendInfo.checkSequence validates. With a new epoch, the sequence must restart at 0. With the same epoch, the first sequence must directly follow the current last sequence, or an OutOfOrderSequenceException is thrown. An exception: if the broker has no epoch for the PID at all (state lost to retention or DeleteRecords), any sequence is accepted. On the client, TransactionManager (used by idempotent producers too) tracks sequences per partition and can bump the idempotent epoch (bumpIdempotentEpochAndResetIdIfNeeded) to recover from sequence errors without a restart.
- Idempotence = PID + epoch + per-partition sequence stamped into every batch header. On by default.
- The leader remembers the last 5 batches per producer per partition. A retry of one of them returns the original offsets, with no second append.
- Gaps →
OUT_OF_ORDER_SEQUENCE_NUMBER; stale epoch →INVALID_PRODUCER_EPOCH. - Requires
acks=all,retries > 0,max.in.flight.requests.per.connection ≤ 5. Ordering holds even with 5 in flight. - It protects against the producer's own retries only. App-level resends and cross-partition atomicity need transactions.
Expert internals
The machinery behind the guarantees: how transactions really commit, how compaction rewrites the ledger without moving a single line number, what a request lives through inside a broker, how replicas agree on history after a crash, how old segments move to object storage, how share groups turn a log into a queue, and how Kafka Streams keeps state alive across instances.
Transactions & exactly-once
all the pages get stamped, or none of them doThe idempotent producer from Part II solved one problem: a retry of the same batch to the same partition doesn't create a duplicate. That's it. It says nothing about two different partitions, nothing about a producer that crashes and restarts, and nothing about the consumer offset you want to move at the same time as you write your output.
Think about the classic "read → process → write" loop. You read record 41 from orders, write a result to invoices, then commit offset 42. Crash between the write and the commit, and on restart you read 41 again and write a second invoice. Commit first, crash before the write, and the invoice is never written. Both orders are wrong. You need the output write and the offset move to be one atomic act.
In the city ledger archive, your clerk copies lines from the "orders" book into two other books ("invoices" and "audit") and then moves the bookmark in the orders book. All three books may live in different branches. How would you make sure a reader never sees the invoice without the audit line, and never sees either if the clerk faints halfway? You can't erase ink — the books are append-only.
Kafka's answer: write everything immediately, decide visibility later. The records go into the logs straight away, flagged as "part of transaction X". When the producer commits, a tiny control record — a commit or abort marker — is appended to every partition that took part. Readers that ask for isolation.level=read_committed hold back transactional records until they see the marker, and skip the ones that end up aborted. In ledger terms: the clerk writes in pencil-grey ink, and at the end a notary stamps "VALID" or "VOID" in every book touched.
The cast
A transactional producer is an idempotent producer with a name. You set transactional.id (setting it implies enable.idempotence). That name is stable across restarts — that's the whole point. It's hashed onto one partition of the internal topic __transaction_state (50 partitions by default, replication factor 3, min ISR 2), and the leader of that partition acts as the producer's transaction coordinator. The coordinator keeps the transaction's state machine and writes every state change to __transaction_state, so a new coordinator can pick up after a failover.
The API is five calls: initTransactions() once, then per transaction beginTransaction(), send()…, optionally sendOffsetsToTransaction(offsets, consumer.groupMetadata()), then commitTransaction() or abortTransaction(). On the consumer side you set isolation.level=read_committed and enable.auto.commit=false — the offsets travel inside the transaction instead.
billing-1epoch 5What happens on commit
The coordinator's state machine moves Empty → Ongoing → PrepareCommit → CompleteCommit (or PrepareAbort → CompleteAbort). The moment PrepareCommit is durably written to __transaction_state, the decision is final — even if the coordinator dies, the next one will finish the job. Then it sends WriteTxnMarkers requests to the leaders of every partition in the transaction (including the __consumer_offsets partition holding your group's offsets). Once all markers are written, it records CompleteCommit.
On the reading side, every partition tracks a last stable offset (LSO): the point before which every transactional record has been decided. A read_committed consumer only gets records below the LSO, and the fetch response carries a list of AbortedTransactions so the client can drop records from aborted ones. That's why one long-running open transaction stalls every read_committed consumer of that partition — even consumers that don't care about that producer. Records behind the open transaction are withheld until it finishes.
The open transaction blocks everyone. Since read_committed consumers stop at the LSO, a producer that begins a transaction and then hangs holds back the whole partition until the coordinator aborts it after transaction.timeout.ms (producer default 60000 ms; it can't exceed the broker's transaction.max.timeout.ms, default 15 minutes). Keep transactions short, and alert on consumer lag that sits exactly at the LSO while the high watermark keeps moving.
Zombie fencing — why the id must be stable
Imagine instance A of your service stalls in a long GC pause. The platform thinks it's dead and starts instance B with the same transactional.id. B calls initTransactions(); the coordinator bumps the producer epoch for that id and aborts whatever A left open. When A wakes up and tries to write with the old epoch, the partition leader rejects it with INVALID_PRODUCER_EPOCH (surfaced as InvalidProducerEpochException, which asks the app to abort); as soon as A talks to the coordinator again — to commit or abort — it gets the fatal ProducerFencedException and must close. The epoch is the notary's seal: only the newest seal is honoured. Without a stable id there's nothing to fence on, and A and B would happily write duplicates side by side.
transactional.id. The trick is that the consumer's position is itself just a record in __consumer_offsets, so the producer writes it inside the transaction via sendOffsetsToTransaction. The consumer is merely configured to read only committed data.isolation.level. They read up to the high watermark and see records of open and aborted transactions. Transactions only protect readers who opt in.KIP-890 and transaction version 2
The original protocol had a gap: a produce request that arrived late (say, a retry delayed in the network) could land after a transaction finished and silently become part of the next one, or linger as a "hanging" transaction that pins the LSO forever. KIP-890 ("transactions server-side defense") closes this in two steps. First, partition leaders verify with the coordinator that a partition really is part of an ongoing transaction before accepting transactional data (transaction.partition.verification.enable, default true). Second, with transaction.version=2 (TV2), the producer epoch is bumped at the end of every transaction, so a straggler from transaction N carries a stale epoch and is rejected by construction.
TV2 also trims round trips. The client no longer sends AddPartitionsToTxn itself: the partition leader adds the partition to the transaction on the server side while handling the produce request. sendOffsetsToTransaction likewise skips the separate AddOffsetsToTxn call and goes straight to TxnOffsetCommit. The server side has been on since 4.0 and is controlled by the transaction.version feature; 4.x producers switch to it at the start of their next transaction once they learn the cluster supports it — no restart needed. Downgrades are safe too.
On a local 4.3 cluster, check which transaction version the cluster runs, then list the transactions the coordinators know about and describe one. Which commands?
Answer
bin/kafka-features.sh --bootstrap-server localhost:9092 describe
bin/kafka-transactions.sh --bootstrap-server localhost:9092 list
bin/kafka-transactions.sh --bootstrap-server localhost:9092 describe --transactional-id billing-1
# watch only committed data:
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic invoices \
--isolation-level read_committed --from-beginning
Look for transaction.version in the describe output. If a partition's read_committed consumers are stuck, kafka-transactions.sh find-hanging --broker-id 1 looks for hanging transactions and abort --topic … --partition … --start-offset … aborts one by hand.
| Config | Default | What it does | Use it when |
|---|---|---|---|
transactional.id (producer) | null | Enables transactions; stable identity used for fencing | Any consume–transform–produce or multi-partition atomic write; one id per input shard/instance |
transaction.timeout.ms (producer) | 60000 | Coordinator aborts a transaction open longer than this | Lower it so a hung producer unblocks read_committed readers sooner |
isolation.level (consumer) | read_uncommitted | read_committed → only decided data up to the LSO | Always for downstream of transactional producers |
transaction.max.timeout.ms (broker) | 900000 (15 min) | Upper bound a producer may request | Rarely; raise only for long batch jobs |
transactional.id.expiration.ms (broker) | 604800000 (7 d) | Idle transactional ids are expired from the coordinator | Many short-lived ids (dynamic ids per job) |
transaction.state.log.replication.factor / .min.isr / .num.partitions | 3 / 2 / 50 | Durability and spread of __transaction_state | Dev clusters with < 3 brokers must lower the RF |
🔬 Go deeper: coordinator internals and the TV2 code path
Finding the coordinator. TransactionStateManager.partitionFor(transactionalId) is Utils.abs(transactionalId.hashCode) % transactionTopicPartitionCount. The client discovers it with FindCoordinator (key type transaction), then calls InitProducerId, which returns the producer id and a fresh epoch.
States. org.apache.kafka.coordinator.transaction.TransactionState defines EMPTY, ONGOING, PREPARE_COMMIT, PREPARE_ABORT, COMPLETE_COMMIT, COMPLETE_ABORT, DEAD, PREPARE_EPOCH_FENCE. PREPARE_EPOCH_FENCE is the path taken when the coordinator must abort an open transaction and fence its producer epoch — both when a new InitProducerId arrives for the id and when abortTimedOutTransactions aborts a transaction that exceeded its timeout.
Markers. TransactionMarkerChannelManager batches WriteTxnMarkers requests per destination broker. Each marker carries ProducerId, ProducerEpoch, TransactionResult (true = COMMIT), CoordinatorEpoch and, from request v2, a TransactionVersion field.
TV2 on the client. In TransactionManager.maybeAddPartition, when isTransactionV2Enabled() is true, the partition is only tracked locally — no AddPartitionsToTxn request is queued. sendOffsetsToTransaction uses txnOffsetCommitHandler directly, skipping AddOffsetsToTxn. The broker side, when a produce arrives for a partition not yet in the transaction, asks the coordinator to add it and, on CONCURRENT_TRANSACTIONS (the previous transaction's markers still being written), retries with add.partitions.to.txn.retry.backoff.ms (default 20) up to add.partitions.to.txn.retry.backoff.max.ms (default 100). The docs note this can show up as higher produce latency while end-to-end transaction latency stays the same.
Feature levels. TransactionVersion: TV_0 original, TV_1 flexible transactional state records, TV_2 "epoch bump per transaction and optimizations"; LATEST_PRODUCTION = TV_2.
Error classes. The docs group the transactional producer's exceptions into: retriable (handled internally), refresh-retriable, abortable (abort the transaction and rewind the consumer), application-recoverable (restart the producer), invalid-configuration, and generic KafkaException.
- Transactional records go into the logs immediately; commit/abort markers decide visibility later.
- The coordinator is the leader of one
__transaction_statepartition, chosen by hashingtransactional.id. - read_committed consumers stop at the LSO; one long-open transaction stalls the partition for them.
- Offsets are committed inside the transaction with
sendOffsetsToTransaction— that's the exactly-once part. - Stable
transactional.id+ epoch bump = zombie fencing (stale produces getINVALID_PRODUCER_EPOCH; the zombie's next coordinator call getsProducerFencedException). - TV2 (KIP-890): epoch bumped every transaction, server-side AddPartitions, fewer round trips, no restart needed to adopt it.
Log compaction
keep the last word on every account, lose the gossipTime-based retention is blunt: after seven days the whole segment goes, whether or not it holds the only copy of a customer's current address. For a stream of state — "account 123 now has email X" — you want something smarter: forget the superseded entries, keep the latest one for every key, forever. That's cleanup.policy=compact.
In the ledger archive, a compacted book is one where an archivist periodically recopies old pages, leaving out every line that a later line for the same account has overruled. The crucial detail: the line numbers are copied too. Line 38 stays line 38, even if lines 36 and 37 were dropped. Offsets never change; compaction only creates gaps.
A consumer's committed offset is 37, but offset 37 was compacted away. Where does its next fetch start? And if a key should be deleted, how do you express that in a log where you can only append?
Head, tail and the cleaner
A compacted log has two regions. The tail (older, already cleaned) holds at most one record per key. The head (newer, "dirty") is an ordinary log with dense offsets. The log cleaner — a pool of background threads, log.cleaner.threads, default 1 — picks the log with the highest dirty ratio, builds a compact map of key → last offset for the dirty section, and recopies segments from the beginning, dropping every record whose key appears again later. Cleaned segments are swapped in as it goes, so the extra disk needed is about one segment, not a copy of the log.
Answer to the brain question, part one: every offset stays a valid position. Reading from a compacted-away offset simply returns the next record that still exists — the docs' example is that a read starting at 36, 37 or 38 all begin at 38.
min.cleanable.dirty.ratio (0.5) or max.compaction.lag.ms forces it — here you run it by hand. Keys, values and timings are illustrative.Tombstones: deleting in an append-only world
To delete a key you append a record with that key and a null value — a tombstone. The cleaner removes every earlier record for the key. The tombstone itself must linger for a while, or consumers that are rebuilding state would never learn the key was deleted. How long? delete.retention.ms, default 24 hours. In the v2 batch format the cleaner stamps a delete horizon into the batch the first time it sees the tombstone (now + delete.retention.ms) and drops the tombstone on a later pass after that time.
A consumer that lags more than delete.retention.ms can miss deletes. The docs say it plainly: tombstone removal happens concurrently with reads, so a consumer that takes longer than that to reach the head may never see the delete marker — and keeps the deleted key in its cache or database forever. If you rebuild state from a compacted topic, make sure a full replay finishes well within that window, or raise it.
What compaction promises (and doesn't)
From the design docs: a consumer that keeps up with the head sees every record; ordering is never changed; offsets never change; a consumer reading from the start sees at least the final value of every key. What it does not promise is "exactly one record per key right now". The active segment is never cleaned, nothing past the last stable offset is cleaned, and records younger than min.compaction.lag.ms are protected. So a compacted topic can contain duplicates at any time — your consumer must treat it as "latest wins", never as a unique-key table.
cleanup.policy=compact,delete. Old segments go by time or size, the survivors get compacted.| Config | Default | What it does | Use it when |
|---|---|---|---|
cleanup.policy | delete | compact, delete, both, or empty list (infinite retention) | State/changelog topics: compact |
min.cleanable.dirty.ratio | 0.5 | Dirty share of the log needed before the cleaner picks it | Lower → fewer duplicates, more cleaning I/O |
delete.retention.ms | 86400000 (24 h) | How long tombstones survive after being stamped | Raise if full replays take longer |
min.compaction.lag.ms | 0 | Minimum age before a record may be compacted | Consumers need to see every update for a while |
max.compaction.lag.ms | Long.MAX_VALUE | Maximum time a record stays ineligible for compaction | Low-traffic topics that never reach the dirty ratio (e.g. GDPR deletes must happen) |
log.cleaner.threads | 1 | Cleaner threads per broker | Many compacted partitions falling behind |
log.cleaner.dedupe.buffer.size | 134217728 (128 MiB) | Memory for the key→offset maps, across all cleaner threads | Huge key spaces; each pass can cover more |
log.cleaner.io.max.bytes.per.second | Double.MAX_VALUE (unthrottled) | Throttle cleaner read+write I/O | Cleaner competes with producers/consumers for disk |
Create a compacted topic with tiny segments so the cleaner has something to do, write keyed updates and a tombstone, then look at the raw segment. Which commands?
Answer
bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic accounts \
--partitions 1 --config cleanup.policy=compact --config segment.ms=10000 \
--config min.cleanable.dirty.ratio=0.01 --config delete.retention.ms=60000
bin/kafka-console-producer.sh --bootstrap-server localhost:9092 --topic accounts \
--reader-property parse.key=true --reader-property key.separator=: \
--reader-property null.marker=NULL
> A:1
> B:1
> A:2
> B:NULL # tombstone for B
bin/kafka-dump-log.sh --files /tmp/kraft-combined-logs/accounts-0/00000000000000000000.log --print-data-log
After a segment roll and a cleaner pass (it sleeps log.cleaner.backoff.ms, 15 s, when idle), the dump shows gaps in offsets and only the latest value per key. The log directory path depends on your log.dirs.
🔬 Go deeper: how the cleaner decides what to keep
Choosing a log. LogCleanerManager computes a LogToClean per compacted partition with cleanableRatio = cleanableBytes / totalBytes and picks the dirtiest. The cleanable range ends at the first uncleanable offset, the minimum of: the last stable offset, the active segment's base offset, and (if min.compaction.lag.ms > 0) the first segment still inside the lag.
The offset map. Cleaner builds a SkimpyOffsetMap (MD5-hashed keys; dedupe.buffer.size split across threads); the docs say each entry takes exactly 24 bytes, so "with 8GB of cleaner buffer one cleaner iteration can clean around 366GB of log head (assuming 1kB messages)". The map is filled up to log.cleaner.io.buffer.load.factor (0.9).
The retain rule (Cleaner.shouldRetainRecord): keep a record only if its offset ≥ the map's offset for its key, and it has a value or its tombstone isn't past the batch's deleteHorizonMs. Records without a key are invalid in a compacted topic and get dropped.
Transactions. The cleaner also removes records of aborted transactions (it collects them from the transaction index) and, piggy-backing on tombstone retention, eventually removes transaction markers — but never before all records of that transaction are gone.
Metrics. Watch uncleanable-partitions-count, max-clean-time-secs and max-compaction-delay-secs. A partition marked uncleanable after an error stays uncompacted and grows. Note log.cleaner.enable is deprecated since 4.1 and slated for removal in 5.0 — don't set it to false, because __consumer_offsets is compacted too.
- Compaction keeps at least the latest record per key; offsets and order never change, only gaps appear.
- The active segment, anything past the LSO and records younger than
min.compaction.lag.msare never cleaned. - Delete = tombstone (key + null value); tombstones live
delete.retention.ms(24 h) after being stamped. - Lagging longer than
delete.retention.mscan make a consumer miss deletes. - The cleaner runs when the dirty ratio >
min.cleanable.dirty.ratio(0.5) ormax.compaction.lag.msforces it. - Consumers of compacted topics must be "latest wins" — duplicates per key are normal.
The broker's request pipeline & purgatory
a post office with a waiting room for impatient lettersEvery produce, fetch, heartbeat and metadata call is a request that travels through the same assembly line inside the broker. When latency spikes, "Kafka is slow" is useless; "produce requests spend 900 ms in the request queue" tells you exactly which part of the line is jammed. This chapter gives you that vocabulary.
Picture the archive branch's front desk. A doorman (the acceptor) lets visitors in and hands each to one of a few counter clerks (network processors). Clerks don't do the work — they read the form and drop it into an in-tray (the request queue). Behind the scenes, archivists (I/O handler threads) take forms from the tray, write in the ledgers or look things up, and put answers in each clerk's out-tray (response queue). Clerks hand the answers back. And some forms can't be answered yet — "tell me when three branches have a copy" — so they go to a waiting room: purgatory.
The stages
1. Acceptor. One thread per listener accepts TCP connections and assigns each to a processor. The docs describe the model as "a single acceptor thread and N processor threads which handle a fixed number of connections each".
2. Network processors (num.network.threads, default 3 — per listener). Each runs a non-blocking selector, reads complete requests off its sockets, and puts them in the shared request queue. After reading a request from a connection it mutes that connection until the response is sent, which is how Kafka keeps per-connection ordering: one request per connection is being processed at a time on the broker.
3. Request queue (queued.max.requests, default 500). When full, network threads block — they stop reading new requests. This is back-pressure, deliberately.
4. I/O handler threads (num.io.threads, default 8). They run KafkaApis.handle: validate, append to the log (page cache), or read for a fetch. This is where disk, CPU for decompression/validation, and lock contention show up.
5. Purgatory. If a request can't be completed right now, it's parked as a delayed operation and the I/O thread moves on. A produce with acks=all waits in the produce purgatory until the in-sync followers have fetched the data (or the request's timeout — the producer's request.timeout.ms, default 30 s — expires). A fetch waits in the fetch purgatory until fetch.min.bytes (default 1) are available or fetch.max.wait.ms (default 500) passes. That's how long-polling works without burning a thread per waiting consumer.
6. Response queue → processor → socket. Each processor has its own response queue; it writes the response and unmutes the connection.
kafka.network:type=RequestMetrics.Reading the latency breakdown
For each request type (Produce, FetchConsumer, FetchFollower, …) the broker publishes TotalTimeMs, split into:
| Metric | Stage | If it's high… |
|---|---|---|
RequestQueueTimeMs | waiting in the request queue | I/O threads saturated — check RequestHandlerAvgIdlePercent |
LocalTimeMs | processing on the leader (append/read) | disk, page-cache misses, compression/validation cost, lock contention |
RemoteTimeMs | waiting in purgatory (followers for acks=all; data for fetches) | slow followers / ISR problems for produce; for consumer fetch it's mostly fetch.max.wait.ms and is normal |
ResponseQueueTimeMs | waiting for the network thread | network threads busy — check NetworkProcessorAvgIdlePercent |
ResponseSendTimeMs | writing the response | slow clients / network, big fetch responses |
The monitoring docs suggest both idle-percent gauges stay "ideally > 0.3". RequestChannel's RequestQueueSize and the purgatory gauges (PurgatorySize for Produce and Fetch) complete the picture — the docs note the produce purgatory is "non-zero if ack=-1 is used" and the fetch one's "size depends on fetch.wait.max.ms in the consumer".
RemoteTimeMs on FetchConsumer — is my cluster broken?fetch.max.wait.ms waiting for data. That's by design. RemoteTime on Produce is the one to worry about: it's the time followers take to replicate for acks=all.num.io.threads to 64?LocalTimeMs and disk metrics first. (And you couldn't jump 8 → 64 in one dynamic change anyway: thread-pool updates are limited to currentSize / 2 … currentSize * 2 per step.)Producer p99 latency jumped from 20 ms to 800 ms. Triage in five steps:
- Look at
TotalTimeMs,request=Produceon the brokers: if it's low, the delay is client-side (batching,linger.ms,max.block.ms, network). - Split it: which of Queue / Local / Remote / ResponseQueue / ResponseSend dominates?
- Queue high →
RequestHandlerAvgIdlePercentnear 0 → I/O threads saturated; check what they're doing (Local). - Remote high → followers slow: check follower fetch
TotalTimeMs,request=FetchFollower, ISR shrinks, the network between brokers. - ResponseQueue/Send high → network threads (
NetworkProcessorAvgIdlePercent) or huge responses.
Your broker runs with defaults. Name the three thread/queue settings of this chapter and say which one you'd check first when RequestQueueTimeMs rises. Then read a metric from the command line.
Answer
num.network.threads (3), queued.max.requests (500), num.io.threads (8). Rising queue time means requests wait for an I/O thread — check RequestHandlerAvgIdlePercent and LocalTimeMs before touching num.io.threads. Thread counts are dynamically updatable broker configs:
bin/kafka-configs.sh --bootstrap-server localhost:9092 --entity-type brokers \
--entity-default --alter --add-config num.io.threads=16
bin/kafka-jmx.sh --jmx-url service:jmx:rmi:///jndi/rmi://localhost:9999/jmxrmi \
--object-name kafka.server:type=KafkaRequestHandlerPool,name=RequestHandlerAvgIdlePercent
(The JMX tool needs the broker started with JMX_PORT=9999.)
🔬 Go deeper: the classes behind the stages
kafka.network.SocketServer creates an Acceptor per listener and a pool of Processors. A processor does requestChannel.sendRequest(req) and then selector.mute(connectionId); the connection is unmuted on ChannelMuteEvent.RESPONSE_SENT (or later if a quota throttle is active — Part IV).
kafka.network.RequestChannel holds the bounded request queue (queueSize = queued.max.requests). KafkaRequestHandler threads (the pool is KafkaRequestHandlerPool) poll it and call KafkaApis.handle, which dispatches on the API key.
DelayedOperationPurgatory (server-common) keeps delayed operations watched by keys (e.g. a partition) and a hierarchical TimingWheel in a SystemTimer for expiry. tryCompleteElseWatch tries to finish the operation immediately and otherwise registers it; events like "HW advanced" call checkAndComplete for the key.
DelayedProduce completes when every partition is satisfied: this broker is no longer leader, the replica is gone, or enough replicas have caught up (or an error). DelayedFetch lists its completion cases A–H in the source, including "the accumulated bytes … exceeds the minimum bytes", the fetch offset not being on the last segment, leadership change and "a diverging epoch was found" (next chapter). fetch.purgatory.purge.interval.requests and producer.purgatory.purge.interval.requests (both 1000) control how often completed entries are purged.
- Acceptor → network processors (3) → request queue (500) → I/O threads (8) → purgatory if needed → response queue → processor.
- A full request queue blocks network threads: back-pressure, by design.
- Connections are muted while a request is in flight on the broker, preserving per-connection order.
- Purgatory parks acks=all produces and long-poll fetches without holding a thread.
- Diagnose with
TotalTimeMs= Queue + Local + Remote + ResponseQueue + ResponseSend, plus the two idle-percent gauges (> 0.3).
Leader epochs, truncation, unclean election & ELR
whose version of history wins after a crash?Part II showed the happy path of replication: followers fetch, the high watermark (HW) advances, committed records are safe. This chapter is about the unhappy path: a leader dies holding records its followers never got, a new leader writes different records at the same offsets, and the old leader comes back. Two photocopies of the same ledger book now disagree about what's on line 6. Somebody has to tear out pages — the question is who, and how many.
A returning follower knows its own log and its own (possibly stale) high watermark. Why is "truncate everything above my high watermark, then fetch" not safe? Hint: followers learn the HW one fetch round after the leader advances it.
Why the high watermark wasn't enough
Early Kafka truncated a restarting follower to its own HW. KIP-101 describes what went wrong. Because a follower learns the new HW only on its next fetch, its local HW lags. If it truncates to that stale HW and then becomes leader before re-fetching, records that were already committed vanish. And after a multi-broker power loss, replicas could end up with different records at the same offsets — divergent lineages that no amount of fetching would repair.
Leader epochs: numbering the reigns
The fix: every time the controller elects a new leader for a partition, the leader epoch goes up (the KRaft controller also bumps it on some other partition changes, such as certain reassignments), and every batch the leader writes is stamped with it (the partitionLeaderEpoch field in the batch header you met in Part II). Each replica also keeps a small leader epoch cache — a list of epoch → first offset of that epoch, persisted in the partition directory as leader-epoch-checkpoint.
Now a returning follower doesn't guess. It asks the leader: "the last epoch I have is 1 — where did epoch 1 end in your log?" The leader looks in its cache: epoch 2 started at offset 6, so epoch 1 ended at 6. The follower truncates everything from offset 6 up and fetches the leader's version. Only records that genuinely diverge are removed. In the ledger analogy: each reign of a head archivist uses a different ink colour, and the register at the front of the book says on which line each colour started. Comparing registers tells you exactly where two copies part ways.
LastFetchedEpoch in their Fetch requests and get a DivergingEpoch back; older paths use a separate OffsetForLeaderEpoch request — the outcome is the same truncation point.Unclean leader election: availability over truth
If every in-sync replica is gone, Kafka faces the dilemma the design docs spell out: wait for an ISR member to come back (maybe never), or promote whatever replica is alive, even if it's missing committed records. unclean.leader.election.enable (default false) chooses between them. When it's on and an out-of-sync replica becomes leader, its log becomes the source of truth: committed records it lacked are gone, and the other replicas truncate to match when they return. Consumers that had already read past that point detect it — with no automatic reset policy the consumer throws LogTruncationException with the first diverging offset.
In KRaft, turning the flag on dynamically doesn't act instantly: the controller checks leaderless partitions every unclean.leader.election.interval.ms (default 5 minutes). To force it now, the config docs point to kafka-leader-election.sh with the unclean option.
Unclean election is data loss you asked for. Records acknowledged with acks=all can disappear, and consumers can see offsets reused for different data. Use it as a deliberate, per-topic emergency lever (e.g. a metrics topic where availability beats completeness), never as a cluster default for business data.
ELR: electing safely outside the ISR (KIP-966)
There's a middle ground the ISR alone can't see. With min.insync.replicas enforced, the HW can't advance while the ISR is smaller than min ISR. So a replica that was in the ISR when it dropped below min ISR still has every committed record — nothing new could be committed after it left. Such replicas are safe leaders even though they're not in the ISR any more.
Eligible Leader Replicas track exactly those. The KRaft controller stores them in the partition's metadata record (EligibleLeaderReplicas, plus LastKnownElr). When the ISR shrinks below min ISR, former ISR members move into the ELR — except replicas that had an unclean shutdown, which may have lost unflushed data. When the ISR is back at or above min ISR, the ELR is cleared. Leader election order becomes:
- If the ISR is not empty, pick from the ISR.
- Else, if the ELR is not empty, pick an unfenced ELR member.
- Else, the last known leader if it's unfenced (roughly the pre-4.0 behaviour when all replicas were offline).
ELR arrived in 4.0 behind the eligible.leader.replicas.version feature (off by default in 4.0) and is enabled by default on new clusters since 4.1. Enabling it has side effects on min.insync.replicas: it gets a cluster-level value, the broker-level setting is removed and can't be altered, and changing it at cluster or topic level clears ELR state.
On a local 4.3 cluster: check whether ELR is enabled, see a partition's ELR, and (in a lab only!) force an unclean election for a leaderless partition.
Answer
bin/kafka-features.sh --bootstrap-server localhost:9092 describe # eligible.leader.replicas.version
bin/kafka-features.sh --bootstrap-server localhost:9092 upgrade --feature eligible.leader.replicas.version=1
bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic orders # shows Elr / LastKnownElr
bin/kafka-leader-election.sh --bootstrap-server localhost:9092 \
--election-type unclean --topic orders --partition 0
The docs say ELR fields are exposed through the DescribeTopicPartitions API, which the admin client uses when describing topics.
🔬 Go deeper: where epochs live in code and protocol
org.apache.kafka.storage.internals.epoch.LeaderEpochFileCache — "a cache of (LeaderEpoch => Offset) mappings for a particular replica", where the offset is "the offset of the first message in each epoch". It's checkpointed by LeaderEpochCheckpointFile to leader-epoch-checkpoint.
FetchRequest v12+ carries LastFetchedEpoch; if the leader detects divergence, the FetchResponse contains DivergingEpoch: "the largest epoch and its end offset such that subsequent records are known to diverge". DelayedFetch completes immediately in that case ("Case H") so the follower can truncate. OffsetForLeaderEpoch still exists; its CurrentLeaderEpoch fences stale callers with FENCED_LEADER_EPOCH or UNKNOWN_LEADER_EPOCH. Consumers use the same machinery to validate their position after a leader change.
ELR logic lives in the controller's PartitionChangeBuilder: maybePopulateTargetElr() clears ELR when the target ISR ≥ min ISR, otherwise unions the current ISR into the ELR, removing the target ISR and unclean-shutdown replicas; an unclean election clears the ELR fields. PartitionRecord version 2 added the tagged fields EligibleLeaderReplicas and LastKnownElr.
- Every leader election bumps the leader epoch; batches are stamped with it; replicas keep an epoch → start-offset cache.
- Returning followers truncate to where their last epoch ended on the leader — not to their stale HW (KIP-101).
- Only uncommitted (never acked with acks=all) records get truncated in a clean failover.
unclean.leader.election.enable=falseby default; turning it on trades committed data for availability.- ELR (KIP-966, default on new clusters since 4.1) lets the controller elect a safe non-ISR replica when the ISR fell below min ISR.
Tiered storage (KIP-405)
recent pages at the desk, old volumes in the warehouseA Kafka broker's disk does two jobs at once: it's the hot write path, and it's the long-term archive. Most reads are tail reads served from the page cache; old data is touched rarely, for backfills and recovery — the docs make exactly this point. Yet keeping 90 days of data means buying 90 days of fast local disk, replicated three times, and moving all of it whenever you add a broker or replace a failed one.
Tiered storage splits the two jobs. The local tier is the log you know. The remote tier is an external store (the docs mention HDFS or S3) that receives completed segments. In the archive: the branch keeps the last few volumes on the reading-room shelves; finished volumes are shipped to a central warehouse, and a card index says which warehouse box holds which lines.
Which segments may safely be shipped to the warehouse? Think about the active segment, and about records that aren't committed yet (or belong to open transactions).
The moving parts
RemoteStorageManager (RSM) — a plugin interface that copies segment data and its indexes to the remote store, fetches them back, and deletes them (copyLogSegmentData, fetchLogSegment, fetchIndex, deleteLogSegmentData). Kafka ships no production implementation: you configure a vendor or open-source plugin with remote.log.storage.manager.class.name and .class.path.
RemoteLogMetadataManager (RLMM) — keeps strongly consistent metadata about remote segments. The default, TopicBasedRemoteLogMetadataManager, stores it in the internal topic __remote_log_metadata; you must set remote.log.metadata.manager.listener.name so its internal clients know which listener to use.
RemoteLogManager (RLM) — the broker component that runs per-partition tasks: a copy task on the leader, an expiration task that deletes remote segments past retention, and a follower task. Segments eligible for copying are those that are not the active segment and whose end offset is below the last stable offset — "as remote storage should contain only committed/acked messages". That answers the brain question.
remote.log.manager.copier.thread.pool.size threads and remote reads on remote.log.reader.threads.Two retention clocks
A tiered topic (remote.storage.enable=true) has two retention settings. local.retention.ms / local.retention.bytes decide how long segments stay on the broker; retention.ms / retention.bytes decide how long the data exists at all (in the remote tier). The local values default to -2, meaning "use the full retention" — so you must set them explicitly to get any benefit, and they must not exceed the total retention. Crucially, a local segment becomes eligible for deletion only after it has been uploaded.
Reading old data
Consumers don't change at all. When a fetch asks for an offset that's no longer local, the leader serves it from the remote store: the read runs on the remote-reader thread pool (remote.log.reader.threads, default 10) and the fetch waits in a dedicated DelayedRemoteFetch purgatory (up to remote.fetch.max.wait.ms, default 500). Expect higher latency for these cold reads and budget for the object store's request costs.
An object-store outage fills local disks. Since local deletion waits for a successful upload, a broken plugin, bad credentials or an unreachable bucket means segments pile up locally no matter what local.retention.ms says. Alert on remote copy lag and errors, and size local disks with headroom for an outage.
Limitations listed in the 4.3 docs: no support for compacted topics; tiered storage must be disabled on every topic before disabling it at the broker level; admin actions need clients 3.0+; no support for segments missing a producer snapshot file (topics created before 2.8.0).
Turning it off per topic (KRaft): remote.log.copy.disable=true makes remote data read-only (then set local.retention.* to match total retention or -2, or the disk can fill), or remote.storage.enable=false,remote.log.delete.on.disable=true deletes all remote data.
New in 4.3: the dynamic broker config follower.fetch.last.tiered.offset.enable (default false, KIP-1023). With it, a newly added follower that has no local data skips straight to the leader's earliest pending-upload offset instead of re-fetching data already in remote storage — the upgrade notes say this "reduces bootstrap time significantly for large tiered-storage topics".
| Config | Default | What it does | Use it when |
|---|---|---|---|
remote.log.storage.system.enable (broker) | false | Turns on tiered storage in the broker | Cluster-wide opt-in; needs an RSM plugin |
remote.log.storage.manager.class.name / .class.path | none | Your RSM plugin (Kafka ships none for production) | Always, when enabled |
remote.log.metadata.manager.listener.name | none | Listener for the default topic-based RLMM clients | Mandatory with the default RLMM |
remote.storage.enable (topic) | false | Tier this topic | Long-retention, append-only (non-compacted) topics |
local.retention.ms / local.retention.bytes | -2 (= use retention.ms / retention.bytes) | How much stays on local disk | Set to hours/days to actually shrink local disk |
remote.log.reader.threads | 10 | Threads serving remote reads | Many consumers backfilling from old offsets |
remote.log.manager.copy.max.bytes.per.second | Long.MAX_VALUE | Upload throttle | Uploads compete with replication/clients for bandwidth |
follower.fetch.last.tiered.offset.enable | false | Empty new follower skips already-tiered data (4.3) | Adding brokers / reassigning big tiered partitions |
The official quick start uses the test-only LocalTieredStorage plugin (built from the source tree) to simulate a remote store in a local directory. After configuring the broker, how do you create a tiered topic that keeps only a second locally, push enough data to roll segments, and prove offset 0 is served from "remote"?
Answer
bin/kafka-topics.sh --create --topic tieredTopic --bootstrap-server localhost:9092 \
--config remote.storage.enable=true --config local.retention.ms=1000 --config retention.ms=3600000 \
--config segment.bytes=1048576 --config file.delete.delay.ms=1000
bin/kafka-producer-perf-test.sh --bootstrap-server localhost:9092 --topic tieredTopic \
--num-records 1200 --record-size 1024 --throughput -1
ls /tmp/kafka-remote-storage/kafka-tiered-storage/ # uploaded .log/.index/.timeindex/... files
bin/kafka-console-consumer.sh --topic tieredTopic --from-beginning --max-messages 1 \
--bootstrap-server localhost:9092 --formatter-property print.offset=true
These are the docs' own commands. The local segment disappears only after its upload; the consumer read of offset 0 then has to go to the remote tier.
🔬 Go deeper: tasks, metadata and the fetch path
org.apache.kafka.server.log.remote.storage.RemoteLogManager schedules RLMCopyTask (leader: candidateLogSegments() skips the active segment and anything whose next segment starts beyond the LSO), RLMExpirationTask (enforces retention.ms/bytes on remote data) and RLMFollowerTask. Tasks run every remote.log.manager.task.interval.ms (30 s).
For each uploaded segment the RSM receives the log file plus offset index, time index, transaction index, producer snapshot and a leader-epoch checkpoint — visible in the quick-start listing as .index, .timeindex, .snapshot, .leader_epoch_checkpoint files. The RLMM records segment lifecycle events in __remote_log_metadata (default 50 partitions, RF 3, retention -1 = unlimited).
On the fetch path, ReplicaManager detects an offset below the local log start, creates a remote read and parks the fetch as DelayedRemoteFetch in its own purgatory; that purgatory's purge interval is 0 so completed results can be garbage-collected immediately. Remote index files are cached locally (remote.log.index.file.cache.total.size.bytes, 1 GiB).
- Two tiers: local segments on the broker, completed segments in a remote store via an RSM plugin (none shipped for production).
- Only non-active segments below the LSO are uploaded; local copies are deleted only after a successful upload.
local.retention.*defaults to -2 (= full retention) — set it, or nothing shrinks.- Old-offset reads are transparent to clients but slower: remote reader threads +
DelayedRemoteFetch. - Not for compacted topics. 4.3 adds
follower.fetch.last.tiered.offset.enablefor faster new followers.
Kafka Streams internals
a stream processor that keeps its notebook in the archiveKafka Streams is a library, not a cluster. You write a topology — "read orders, group by customer, count, write order-counts" — and run as many copies of your app as you like. The library splits the work, keeps local state, survives crashes and can give you exactly-once, using nothing but the Kafka features from this and the previous parts: consumer groups, compacted topics and transactions.
Archive analogy: each clerk keeps a private notebook (a state store) with running totals. Every change to the notebook is also written as a line in a dedicated ledger book in the archive (the changelog topic, compacted). If the clerk quits, a replacement rebuilds the notebook by reading that ledger — or, faster, takes over from a colleague who has been keeping a shadow copy all along (a standby replica).
Topology, sub-topologies, tasks
Your topology is grouped into sub-topologies: sets of connected processors that are linked to each other only through Kafka topics — typically a repartition topic, created when data must be re-keyed. Each sub-topology is instantiated once per input partition as a task, named <subtopology>_<partition> — so 0_2 is sub-topology 0 on partition 2. The docs stress that "the assignment of partitions to tasks never changes": a task is the fixed unit of parallelism, and the maximum parallelism is bounded by the number of input partitions. Threads (num.stream.threads, default 1) and instances only decide where tasks run.
State stores and changelogs
A stateful task owns a local store (RocksDB or in-memory). For each store Kafka Streams maintains a changelog topic named <application.id>-<storeName>-changelog, with one partition per task, and log compaction enabled (windowed stores use delete,compact). Repartition topics use delete with infinite retention; Streams purges them itself once records are consumed. When a task moves to another instance, its store is restored by replaying its changelog partition before processing resumes — that restore is usually the dominant cost of a rebalance.
counter-app-counts-changelog partitions 0–3 (compacted)Standby replicas and warm-up
num.standby.replicas (default 0) keeps shadow copies of each task's store on other instances by continuously reading the changelog. On failover the task is assigned where a standby exists, so only a small tail needs replaying. Since 2.6 the classic assignor also only assigns a stateful task to an instance with a caught-up copy if one exists (within acceptable.recovery.lag, default 10000 records), warming others up in the background with up to max.warmup.replicas (default 2) extra "warm-up" tasks.
Exactly-once v2
processing.guarantee is at_least_once by default; exactly_once_v2 turns on the Chapter-13 machinery end to end: output records, changelog writes and input offsets are committed in one transaction per commit, consumers read with read_committed, and commit.interval.ms drops from 30000 to 100 ms by default. The docs warn that EOS with a replication factor below 3 "effectively voids EOS" — use RF 3 and min.insync.replicas=2 for all involved topics.
Without standbys, a crash can mean minutes of downtime per task. Replaying a multi-GB changelog before a task processes anything again is the classic Streams outage. Budget restore time, enable standby replicas for large stores, and don't let changelog topics lose compaction (check cleanup.policy on the internal topics).
The Streams rebalance protocol (KIP-1071)
Classically, Streams runs on a consumer group with a client-side assignor (StreamsPartitionAssignor) that computes task assignments during a group-wide rebalance. KIP-1071 applies the KIP-848 idea to Streams: the app registers a dedicated streams group, sends its topology to the broker in StreamsGroupHeartbeat (sub-topologies, source, changelog and repartition topics), and the broker computes active and standby task assignments continuously and incrementally (the heartbeat has a WarmupTasks field, but warm-up tasks are not supported yet). The core feature set is production-ready in 4.2, and the feature is enabled by default on new 4.2 clusters (streams.version=1); clients opt in with group.protocol=streams.
The docs list what's not yet supported: static membership, significant topology changes (need a new group), the high-availability assignor (so no warm-up tasks or rack awareness — only a sticky assignor), regex subscriptions, online migration, and supplying the main consumer via a custom KafkaClientSupplier. Migration is offline only; the upgrade notes recommend against migrating classic → streams on 4.2.0 because of a broker bug fixed in 4.2.1.
| Config | Default | What it does | Use it when |
|---|---|---|---|
processing.guarantee | at_least_once | exactly_once_v2 = transactions + read_committed | Results must not double-count |
num.standby.replicas | 0 | Shadow copies of each task's state | Large state, fast failover needed |
num.stream.threads | 1 | Threads per instance running tasks | More cores than instances |
commit.interval.ms | 30000 (100 with EOS) | How often offsets/transactions are committed | Latency vs throughput under EOS |
acceptable.recovery.lag / max.warmup.replicas | 10000 / 2 | "Caught-up" threshold and warm-up budget (classic assignor) | Tuning task movement on scale-out |
group.protocol | classic | streams = KIP-1071 broker-side assignment | 4.2+ brokers and clients, no unsupported features in use |
group.streams.num.standby.replicas (broker) / streams.num.standby.replicas (group) | 0 (max 2) | Standbys under the streams protocol, set on the broker/group | Streams groups — the broker assigns standbys |
You run a Streams app with group.protocol=streams and application.id=counter-app on a 4.3 cluster. How do you check the feature, list/describe the streams group with its members and task assignment, and give it one standby replica per task?
Answer
bin/kafka-features.sh --bootstrap-server localhost:9092 describe # streams.version
bin/kafka-streams-groups.sh --bootstrap-server localhost:9092 --list
bin/kafka-streams-groups.sh --bootstrap-server localhost:9092 --describe --group counter-app --members --verbose
bin/kafka-configs.sh --bootstrap-server localhost:9092 --alter --entity-type groups \
--entity-name counter-app --add-config streams.num.standby.replicas=1
Under the streams protocol, standbys are a broker/group setting (bounded by group.streams.max.standby.replicas, default 2), not the client's num.standby.replicas.
🔬 Go deeper: what the broker learns about your app
StreamsGroupHeartbeatRequest carries MemberEpoch, a Topology (with Epoch and Subtopologies, each listing SourceTopics, StateChangelogTopics, RepartitionSinkTopics, RepartitionSourceTopics and CopartitionGroups), the member's current ActiveTasks / StandbyTasks / WarmupTasks, its ProcessId, UserEndpoint (for interactive queries), and TaskOffsets / TaskEndOffsets so the broker can judge how caught-up each member's state is. ShutdownApplication lets one member request a coordinated shutdown of the whole app.
Broker defaults (GroupCoordinatorConfig): streams session timeout 45000 ms (bounds 45000–60000), heartbeat 5000 ms (5000–15000), initial rebalance delay 3000 ms, assignment interval 1000 ms (new in 4.3, KIP-1263).
org.apache.kafka.streams.processor.TaskId.toString() is subtopology + "_" + partition (prefixed with the topology name for named topologies). With EOS v2 each stream thread uses one transactional producer for all its tasks.
- Topology → sub-topologies (linked only via topics, e.g. repartition topics) → one task per input partition; tasks are the fixed unit of parallelism.
- State stores are backed by compacted changelog topics; restoring = replaying the changelog.
num.standby.replicas(default 0) keeps warm copies for fast failover.exactly_once_v2= transactions over output, changelogs and offsets; commit interval 100 ms; use RF 3.- KIP-1071 streams groups (
group.protocol=streams, core GA in 4.2): broker-side task assignment, with documented gaps (static membership, warm-ups, regex, online migration).
Mastery: operations
Running Kafka for other people: fair sharing between tenants, tuning with a method instead of folklore, diagnosing incidents from symptoms, copying data between cities, and upgrading without drama.
Quotas & multi-tenancy
the archive's rule: nobody hogs the counterUp to now your archive served one well-behaved city department. Real clusters are shared: payments, search, analytics and one intern's load test all write to the same branches. The official docs call the result "noisy neighbors": one tenant saturates the network or the request threads, and everyone else's latency goes through the roof.
In the ledger-archive picture, a quota is a counter rule: "each patron group may carry out at most X pages per second at this branch". A patron who goes faster isn't turned away. The clerk just says "sit down for a moment" and doesn't serve them until they're back under the limit. That is exactly how Kafka works: quotas throttle by delaying, not by rejecting (with one exception, below).
What can you limit?
Kafka has two families of client quotas. Network bandwidth quotas are byte-rate thresholds: producer_byte_rate and consumer_byte_rate, in bytes/sec. Request rate quotas (request_percentage) cap the share of broker network and I/O thread time a group may use. A quota of n% means n% of one thread, so the total capacity is (num.io.threads + num.network.threads) × 100%. On top of those, controller_mutation_rate limits how fast a client may create topics, create partitions and delete topics, counted in partitions.
There are also broker-side connection limits, which aren't tied to a client identity: max.connections.per.ip, max.connection.creation.rate, and a per-IP connection_creation_rate quota. All of them default to "unlimited" (Integer.MAX_VALUE).
The multi-tenancy docs claim that request-rate quotas often matter more than bandwidth quotas. How can a tenant hurt everyone else while moving hardly any bytes? (Think about thousands of tiny produce requests with linger.ms=0, or a consumer with fetch.max.wait.ms set near zero.)
Who is "a client"? Users, client-ids and precedence
A quota applies to a group of clients. The identity is the authenticated user principal, the application-chosen client.id, or the pair (user, client-id). Every connection in a quota group shares the quota. If (user=test-user, client-id=test-client) gets 10 MB/s, all producer instances with that pair split those 10 MB/s together. For a given connection, the most specific matching quota wins, in this order:
- matching user and client-id
- matching user, default client-id
- matching user
- default user, matching client-id
- default user, default client-id
- default user
- matching client-id
- default client-id
Quota overrides are written to the KRaft metadata log. Every broker reads them and they take effect immediately, so you never need a rolling restart to re-tune a tenant. By default there is no quota at all: clients get unlimited throughput until you set one.
quota.window.num (default 11) samples of quota.window.size.seconds (default 1 s), and the delay formula is the one in QuotaUtils.throttleTime. The latency effect in "Remove all quotas" is qualitative.How throttling actually works
When a group goes over its quota, the broker computes how long the client must stay silent for its average rate to drop back under the limit. It returns the response immediately, with that delay in the ThrottleTimeMs field (a fetch response carries no data in that case). Then it mutes the channel: it stops reading from that client's socket until the delay is over. A modern client sees a non-zero throttle time and holds back on its own. An old or misbehaving client that keeps sending anyway still gets nowhere, because its requests sit unread in a muted socket. The docs describe this as blocking the client "from both sides".
Why per broker and not cluster-wide? A cluster-wide limit would need the brokers to share their quota usage with each other in real time. The design docs say bluntly that this "can be harder to get right than the quota implementation itself!" So a 10 MB/s quota means 10 MB/s at each broker. A tenant whose partitions sit on six brokers can move up to six times that in total.
produce-throttle-time-avg / fetch-throttle-time-avg on the client and in the broker's throttle-time attribute. The exception is controller_mutation_rate: once the strict mutation quota is exhausted, operations above it are rejected with THROTTLING_QUOTA_EXCEEDED. The Java admin client retries them by default (CreateTopicsOptions.retryOnQuotaViolation is true).On your local 4.3 cluster, give the client-id loadtest a producer quota of 1 MB/s, describe it, then run the perf tool with that client id and watch the throughput settle.
Answer
bin/kafka-configs.sh --bootstrap-server localhost:9092 --alter \
--add-config 'producer_byte_rate=1048576' --entity-type clients --entity-name loadtest
bin/kafka-configs.sh --bootstrap-server localhost:9092 --describe \
--entity-type clients --entity-name loadtest
bin/kafka-producer-perf-test.sh --bootstrap-server localhost:9092 --topic perf \
--num-records 200000 --record-size 1000 --throughput -1 \
--command-property client.id=loadtest
Throughput should hover near 1 MB/s instead of whatever your laptop can push. --entity-default instead of --entity-name sets the default for all client-ids. (--bootstrap-server and --command-property are the 4.2+ spellings from KIP-1147. The older --producer-props still works but is deprecated.)
Replication throttles set by kafka-reassign-partitions.sh --throttle are a different mechanism: leader.replication.throttled.rate / follower.replication.throttled.rate plus per-topic lists of throttled replicas. Forget to run --verify after the reassignment and the throttle stays behind, quietly throttling normal replication. Set it too low, below max(BytesInPerSec), and the move never finishes.
Onboarding a new tenant onto a shared cluster, the way the multi-tenancy docs recommend:
- Give them a namespace with a hierarchical prefix, for example
acme.infosec., and turn offauto.create.topics.enable(but don't rely on that alone). - Grant prefixed ACLs:
kafka-acls.sh --add --allow-principal User:Alice --producer --resource-pattern-type prefixed --topic acme.infosec. - Set per-user quotas for bytes and
request_percentage, pluscontroller_mutation_rateif they create topics programmatically. - Set
retention.ms/retention.byteson their topics and alert onkafka.log:type=Log,name=Size. - Graph their
throttle-time. Throttling that never stops means they need a bigger contract, not a silent raise.
🔬 Go deeper: the delay formula and the ThrottledChannel queue
ClientQuotaManager records each request's bytes (or thread time) in a per-group Sensor with a rate Quota. When recording breaks the bound, the metrics library throws QuotaViolationException, and QuotaUtils.throttleTime computes
throttleTimeMs = (observed − bound) / bound × windowSize
using "the precise window used by the rate calculation". That is the time the client must stay idle for the average to fall back to the bound. ClientQuotaManager.throttle(...) then records the value in the throttle-time sensor and puts a ThrottledChannel in a delay queue. A ThrottledChannelReaper thread takes it out when the time is up and notifies the socket server that throttling is done, so the channel is unmuted. ControllerMutationQuotaManager specialises this: its quota is strict ("does not accept any mutations once the quota is exhausted") for request versions that support it, and permissive for older ones. You can replace the whole policy with client.quota.callback.class (a ClientQuotaCallback), for example to assign quotas per tenant group instead of per principal.
- Quotas:
producer_byte_rate,consumer_byte_rate,request_percentage,controller_mutation_rate, plus IP connection limits. Default: unlimited. - Entities: user, client-id, or (user, client-id). The most specific match wins, and all connections in the group share one quota.
- Enforcement is per broker, by delaying: the response carries
ThrottleTimeMsand the channel is muted. Mutation quotas reject withTHROTTLING_QUOTA_EXCEEDED. - Changes live in the metadata log and apply immediately, with no restart.
- Watch
throttle-time(broker) and*-throttle-time-avg(client).
Performance tuning playbook
measure, change one knob, measure againEvery Kafka performance "secret" you've read on a forum is one of four trades: latency for throughput (batch more), CPU for bytes (compress), safety for speed (acknowledge less), and memory and file handles for parallelism (more partitions). This chapter gives you the knobs for each trade, their real defaults, and a method for turning them without guessing.
In the archive, the clerks are fastest when patrons bring bundles of pages, not single sheets. They're slower when every bundle must be photocopied at two other branches before the patron leaves. And they're slowest of all when their desks are cluttered with ten thousand half-used ledger books. Keep those three images in mind; they explain almost every row below.
The method (before any knob)
- Reproduce with the perf tools:
kafka-producer-perf-test.shandkafka-consumer-perf-test.shgive you a baseline you can compare against. - Find the bottleneck, don't assume it. The broker's request metrics split
TotalTimeMsintoRequestQueueTimeMs,LocalTimeMs,RemoteTimeMs,ResponseQueueTimeMsandResponseSendTimeMs(chapter 15's pipeline). High queue time means the threads are busy. High local time means the disk or log append is slow. High remote time on produce means you're waiting for followers (acks=all). - Check thread idleness:
NetworkProcessorAvgIdlePercentandRequestHandlerAvgIdlePercentshould ideally stay above 0.3 according to the monitoring docs. - Change one knob, re-run, and keep a log. Two knobs at once teach you nothing.
linger.ms point out.Producer knobs
| Config | Default | What it does | Use it when |
|---|---|---|---|
linger.ms | 5 (was 0 before 4.0) | Upper bound on how long a not-yet-full batch waits for more records. A full batch is sent right away. | Raise it for throughput-heavy pipelines (how far is a judgement call; measure). The docs' example: 50 adds up to 50 ms of latency when there's no load. |
batch.size | 16384 | Per-partition batch buffer size, and the upper bound of a batch. 0 disables batching. | Raise it with linger.ms when records are small and throughput matters. Each batch allocates the full buffer, so memory use grows. |
compression.type | none | gzip, snappy, lz4, zstd. Compresses whole batches. | Often worth it for text/JSON (heuristic; measure with compression-rate-avg). Better batching means better compression. Levels: compression.{gzip,lz4,zstd}.level. |
acks | all | How many replicas must have the batch before the send succeeds. | Keep all with min.insync.replicas ≥ 2 for anything you'd hate to lose. 1 trades durability for lower latency. |
buffer.memory | 33554432 (32 MiB) | Total memory for unsent batches. When it's full, send() blocks up to max.block.ms (60000). | Raise it if bufferpool-wait-ratio is non-zero with many partitions per producer. |
max.request.size | 1048576 | Largest request the producer will build. It also caps a single record. | Raise it (with broker/topic max.message.bytes) only for genuinely large records. |
Consumer knobs
| Config | Default | What it does | Use it when |
|---|---|---|---|
fetch.min.bytes | 1 | The broker waits until this much data is available (or fetch.max.wait.ms passes) before answering. | Raise it for throughput and fewer fetch requests, at "some additional latency". |
fetch.max.wait.ms | 500 | Maximum time the broker holds a fetch in purgatory waiting for fetch.min.bytes. | Lower it only if you raised fetch.min.bytes and latency suffers. |
fetch.max.bytes | 52428800 (50 MiB) | Soft cap per fetch response. The first batch is always returned so the consumer can make progress. | Lower it for memory-constrained consumers. |
max.partition.fetch.bytes | 1048576 | Per-partition cap per fetch (also soft). | Relevant when a few partitions are very hot. |
max.poll.records | 500 | Records returned per poll(). It doesn't change fetching; records are cached and handed out incrementally. | Lower it when processing is slow, so each loop finishes inside max.poll.interval.ms (300000). |
Broker knobs
| Config | Default | What it does | Use it when |
|---|---|---|---|
num.network.threads | 3 | Threads that read requests from and write responses to sockets (per listener). | Raise it when NetworkProcessorAvgIdlePercent is low. |
num.io.threads | 8 | Request handler threads that process requests, which "may include disk I/O". | Raise it when RequestHandlerAvgIdlePercent is low and RequestQueueTimeMs grows. |
queued.max.requests | 500 | Request queue depth before network threads block. | Rarely. A full queue is a symptom; fix the slow stage behind it. |
num.replica.fetchers | 1 | Fetcher threads per source broker for replication. | Followers lag (MaxLag growing) while the leader's disk and network are fine. |
socket.send/receive.buffer.bytes | 102400 | TCP buffer sizes on the broker socket server. | High-latency links (cross-DC). The OS max socket buffer must allow it too. |
num.recovery.threads.per.data.dir | 2 (was 1 before 4.0) | Threads for log recovery on startup and flushing at shutdown. | Faster restarts after an unclean shutdown, "at the expense of extra IO cycles". |
Partitions: the parallelism you pay for
The partition count is the maximum parallelism of a consumer group, and each partition must fit entirely on one server. So more partitions means more parallel consumers and spreads the load across more brokers. But each partition's segments cost file descriptors and memory map areas. The OS docs recommend at least 100000 file descriptors for the broker as a starting point. Each segment uses 2 map areas (index and time index), and the docs warn that 50000 partitions on one broker means 100000 map areas, which "likely" crashes it with OutOfMemoryError (Map failed) at a typical vm.max_map_count of about 65535. Remember too that you can add partitions but never remove them, and adding them remaps keys.
OS and disks
Kafka leans on the page cache. The hardware docs give a back-of-the-envelope memory estimate: buffer about 30 seconds of writes, write_throughput × 30. Keep Kafka data on dedicated drives (not shared with application logs), mount with noatime, and prefer XFS. In the docs' comparison XFS gave "160ms vs. 250ms+" request local time against the best EXT4 configuration. Keep the default flush settings (no application fsync): durability comes from replication, and forcing fsync gives the OS less room to reorder writes and adds latency.
Run the producer perf test twice against a local topic: once with defaults, once with linger.ms=50 batch.size=262144 compression.type=lz4. Which metric should improve, and which should get worse at light load?
Answer
bin/kafka-producer-perf-test.sh --bootstrap-server localhost:9092 --topic perf \
--num-records 500000 --record-size 200 --throughput -1
bin/kafka-producer-perf-test.sh --bootstrap-server localhost:9092 --topic perf \
--num-records 500000 --record-size 200 --throughput -1 \
--command-property linger.ms=50 batch.size=262144 compression.type=lz4
Records/sec and MB/s should go up (fewer, fuller, compressed batches). With --throughput set to a low rate instead of -1, average latency grows by up to the linger time, because batches wait to fill. Add --print-metrics and compare batch-size-avg, compression-rate-avg and record-queue-time-avg. The exact numbers depend on your machine.
🔬 Go deeper: where the time goes inside the producer
The producer's sender metrics (SenderMetricsRegistry) are your tuning dashboard. record-queue-time-avg is how long batches sit in the RecordAccumulator (dominated by linger.ms at light load). batch-size-avg shows whether batches actually fill. compression-rate-avg shows what compression buys. request-latency-avg is the network plus broker round trip, and produce-throttle-time-avg exposes quotas (chapter 20). If record-queue-time-avg is large while batch-size-avg is small, you're lingering for nothing. If bufferpool-wait-ratio is non-zero, buffer.memory is the wall. On the broker, compare LocalTimeMs against RemoteTimeMs for request=Produce to tell a slow disk from a slow follower.
- Tuning is a set of trades: latency vs throughput (
linger.ms,batch.size,fetch.min.bytes), CPU vs bytes (compression), safety vs speed (acks). - Defaults:
linger.ms5,batch.size16384,compression.typenone,acksall,max.poll.records500,fetch.min.bytes1. - Diagnose with the request-time breakdown and thread idle percentages (ideally > 0.3) before adding threads.
- Partitions buy parallelism but cost file descriptors (≥100000 recommended) and 2 map areas per segment.
- Page cache, dedicated disks, XFS,
noatime, and no forced fsync.
Failure scenarios & troubleshooting
symptom → cause → fix, without panicAt 3 a.m. nobody reads the design docs. You get a symptom ("lag is climbing", "producers throw NotEnoughReplicasException") and you need to go from that symptom to a cause and a safe fix in minutes. This chapter is that map. It leans on everything you already know: ISR and high watermark (ch. 10), rebalances (ch. 11), idempotence (ch. 12), the request pipeline (ch. 15), leader epochs and ELR (ch. 16), KRaft (ch. 9).
Archive version: an inspector walks in, checks a few gauges on the wall (metrics), asks the front desk a few questions (CLI tools), and only then moves furniture. The worst incidents come from moving furniture first, like adding consumers, deleting data or lowering min.insync.replicas, before knowing which gauge is red.
The symptom table
| Symptom | Likely cause | Fix |
|---|---|---|
Consumer lag keeps growing (LAG column, records-lag-max) | Processing slower than the produce rate; too few consumers or partitions; frequent rebalances pausing work; a quota throttling the fetch. | Profile the handler first. Scale consumers up to the partition count; beyond that, add partitions (keys will remap). Check fetch-throttle-time-avg. |
| UnderReplicatedPartitions > 0 | A broker is down, or a follower can't keep up (disk, network, GC), so the ISR shrank. | Find the common broker in --under-replicated-partitions. Restore it, or fix its disk/network. Consider more num.replica.fetchers. |
Producers get NotEnoughReplicasException; UnderMinIsrPartitionCount > 0 | ISR size < min.insync.replicas while acks=all. The broker refuses the write on purpose. | Bring replicas back into the ISR. Lowering min.insync.replicas trades away the durability you asked for, so do it only as a conscious, temporary decision. |
| Rebalance storm (group keeps flipping state; throughput collapses) | Poll loop slower than max.poll.interval.ms (300 s); members missing heartbeats (session.timeout.ms 45 s); rolling deploys without static membership. | Lower max.poll.records or speed up processing; use group.instance.id; move to group.protocol=consumer (KIP-848, incremental and server-side). |
RecordTooLargeException / MESSAGE_TOO_LARGE; BytesRejectedPerSec rising | Record above producer max.request.size (1048576), or batch above broker message.max.bytes / topic max.message.bytes (1048588). | Raise the producer and topic limits consistently (followers still replicate oversized batches, because replica.fetch.max.bytes is a soft limit), or store big payloads elsewhere and send a reference. |
Disk full / OfflineLogDirectoryCount > 0; clients see KafkaStorageException | Retention too long for the volume; skewed partitions; a failed disk. | Check with kafka-log-dirs.sh --describe. Shorten retention or add capacity. Move replicas with kafka-reassign-partitions.sh (with a throttle). Replace the failed disk. |
| Controller / quorum lost: no metadata changes, topic creation hangs | Majority of controllers down or partitioned. The sum of ActiveControllerCount over the controllers ≠ 1. | kafka-metadata-quorum.sh describe --status and --replication. Restore a majority of voters before anything else. |
ISR flapping (IsrShrinksPerSec / IsrExpandsPerSec non-zero without failures) | Followers repeatedly fall more than replica.lag.time.max.ms (30000) behind: GC pauses, saturated disks or network. | Fix the resource. Don't just raise the lag time, because that hides slow replicas inside the ISR and slows acks=all. |
| Offset out of range (consumer jumps to start or end) | The committed offset was deleted by retention (the consumer was down longer than retention), so auto.offset.reset kicks in. | Decide the policy explicitly (earliest, latest, by_duration:…, or none to fail loudly). Recover with --reset-offsets --to-datetime. |
| OfflinePartitionsCount > 0 | Every replica eligible to lead is down. | Bring back a replica from the ISR or ELR (ch. 16). Unclean election (kafka-leader-election.sh --election-type unclean) is a last resort that can lose acknowledged data. |
NotEnoughReplicasAfterAppendException is the nasty sibling. The broker had already appended the batch when it discovered the ISR was too small. Its javadoc says it outright: "Producer retries will cause duplicates", unless the producer is idempotent (enable.idempotence defaults to true; ch. 12). One more reason not to turn idempotence off.
The dashboard: metrics worth an alert
| MBean | Healthy value (per docs) | What red means |
|---|---|---|
kafka.server:type=ReplicaManager,name=UnderReplicatedPartitions | 0 | Replication is behind somewhere. |
kafka.server:type=ReplicaManager,name=UnderMinIsrPartitionCount | 0 | acks=all writes to these partitions are failing. |
kafka.controller:type=KafkaController,name=OfflinePartitionsCount | 0 | Partitions with no leader: unavailable. |
kafka.controller:type=KafkaController,name=ActiveControllerCount | exactly one node has 1 | 0 everywhere means no active controller. |
kafka.server:type=ReplicaManager,name=IsrShrinksPerSec / IsrExpandsPerSec | 0 outside broker restarts | Flapping followers. |
kafka.controller:type=ControllerStats,name=UncleanLeaderElectionsPerSec | 0 | Possible data loss occurred. |
kafka.log:type=LogManager,name=OfflineLogDirectoryCount | 0 | A disk or log dir failed. |
kafka.network:type=SocketServer,name=NetworkProcessorAvgIdlePercent; kafka.server:type=KafkaRequestHandlerPool,name=RequestHandlerAvgIdlePercent | ideally > 0.3 | Threads are saturated; queues will grow. |
kafka.network:type=RequestMetrics,name=TotalTimeMs,request=Produce (and its parts) | stable | Break it down: queue / local / remote / response. |
consumer records-lag-max (published by the consumer, not the broker) | bounded | The consumer is falling behind. |
Your pager says "UnderMinIsrPartitionCount = 12". Write the three commands you'd run first on a 4.3 cluster, and what you're looking for in each.
Answer
bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --under-min-isr-partitions
bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --under-replicated-partitions
bin/kafka-metadata-quorum.sh --bootstrap-server localhost:9092 describe --status
(1) Which partitions, and which replica is missing from Isr? Usually one broker id repeats. (2) How wide the damage is: under-replicated but still ≥ min ISR is a warning, not an outage. (3) Whether the controller quorum is healthy (a leader, small lag), so leadership and ISR changes can actually be committed. Then check that broker's disk (kafka-log-dirs.sh --bootstrap-server localhost:9092 --describe --broker-list 2) and its logs.
A consumer group lost its place after a long outage and jumped to latest. Replay the last 6 hours safely:
- Stop all instances of the group.
--reset-offsetsrequires inactive members. - Dry run (the default mode):
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --reset-offsets --group billing --topic invoices --by-duration PT6H - Check the
NEW-OFFSETcolumn, and optionally save it with--export. - Re-run with
--execute, then start the consumers. Processing must be idempotent, because you will see some records again.
🔬 Go deeper: reading the error codes
Every failed response carries an Errors code, and the broker counts them in kafka.network:type=RequestMetrics,name=ErrorsPerSec,request=…,error=… (error=NONE counts successes). Useful ones: OFFSET_OUT_OF_RANGE (1), MESSAGE_TOO_LARGE (10), RECORD_LIST_TOO_LARGE (18, a batch larger than the segment size), NOT_ENOUGH_REPLICAS (19, rejected before append), NOT_ENOUGH_REPLICAS_AFTER_APPEND (20, written but under-replicated), THROTTLING_QUOTA_EXCEEDED (89). Graphing ErrorsPerSec by error tag often explains an incident faster than any log. For disk trouble, KafkaStorageException's javadoc describes the path: after logs are loaded, an IOException triggers LogDirFailureChannel.maybeAddOfflineLogDir(), the directory goes offline, and clients are told to refresh metadata and retry against the new leaders.
- Diagnose before acting: metric → CLI → cause → fix.
- First commands:
kafka-consumer-groups --describe,kafka-topics --describe --under-replicated-partitions / --under-min-isr-partitions,kafka-metadata-quorum describe --status. - Consumers beyond the partition count sit idle, so they can't fix lag.
NotEnoughReplicasis a safety feature.…AfterAppendduplicates on retry without idempotence.- Offset-out-of-range means retention ate your position; make
auto.offset.reseta deliberate choice.
Geo-replication with MirrorMaker 2
a copying office in another cityReplication inside a cluster (ch. 5, 10) keeps photocopies in other branches of the same archive, coordinated by one city council. Geo-replication is different: two independent archives in two cities, each with its own council, its own offsets and its own consumer groups. Between them sits a copying office that reads one archive and writes into the other. In Kafka that office is MirrorMaker 2 (MM2). The original MirrorMaker 1 was removed in 4.0.
People use it for disaster recovery, for feeding edge clusters into a central one, for separating production from testing, for cloud migrations, and for legal requirements about where data lives. MM2 replicates topic data and topic configurations, consumer group offsets and ACLs. It preserves partitioning and discovers new topics and partitions automatically.
Built from Connect parts
MM2 is not a separate engine. It's a set of Kafka Connect source connectors, which is why it inherits Connect's scaling (run more MM2 processes and they share the work) and Connect's metrics:
MirrorSourceConnector: "Replicate data, configuration, and ACLs between clusters." Consumes from the source, produces to the target, and writes offset syncs that pair upstream offsets with downstream ones.MirrorCheckpointConnector: "Replicate consumer group state between clusters. Emits checkpoint records." It translates each group's committed offsets into target offsets.MirrorHeartbeatConnector: "Emits heartbeats to Kafka." A tinyheartbeatstopic that proves the flow is alive and lets tools trace the replication topology.
Flows, aliases and remote topics
You name clusters with aliases and enable directional flows source->target. A flow is off until you set {source}->{target}.enabled = true. Topologies are just sets of flows: active/passive A->B, active/active A->B, B->A, aggregation A->K, B->K, fan-out K->A, K->B.
clusters = us-west, us-east
us-west.bootstrap.servers = broker1-west:9092
us-east.bootstrap.servers = broker3-east:9092
us-west->us-east.enabled = true
us-west->us-east.topics = orders.*
With the DefaultReplicationPolicy, a replicated ("remote") topic is renamed {source}.{topic}, so orders from us-west becomes us-west.orders in us-east. That prefix is not decoration. It stops two clusters from writing into the same topic-partition, and it's how active/active avoids infinite loops: MM2 knows us-west.orders came from us-west and won't send it back. IdentityReplicationPolicy keeps names unchanged, but its javadoc warns that MM2 then "is not able to prevent cycles", so use it only for one-way topologies.
orders at offsets 1000–1009 (older records already expired). Press ▶.offset.lag.max (default 100) and in other situations; checkpoints are emitted every emit.checkpoints.interval.seconds (default 60). The translation rule (sync's downstream offset, +1 if the group is past the sync) is the one in OffsetSyncStore.translateDownstream.Offsets don't survive the trip
This is the single most important idea in the chapter. The target cluster assigns its own offsets. If replication starts when the source partition begins at 1000, the remote partition begins at 0. Filtered or aborted records widen the gap further. A consumer group's committed offset "1007" means nothing in the other city.
So MM2 keeps a translation table. The source connector writes offset syncs (upstream ↔ downstream pairs) to mm2-offset-syncs.<target>.internal, which by default lives on the source cluster (offset-syncs.topic.location defaults to source). The checkpoint connector reads each group's committed offsets, translates them, and writes checkpoints to <source>.checkpoints.internal on the target. Applications can read them via RemoteClusterUtils.translateOffsets(...). Alternatively, set sync.group.offsets.enabled=true (default false) and MM2 writes the translated offsets into the target's __consumer_offsets, "as long as no active consumers in that group are connected to the target cluster".
Translation is deliberately conservative. MM2 only knows the sync points exactly. For a group that's past the nearest sync, it translates to "sync's downstream offset + 1". The code comment explains why: overestimating could skip records and lose data. The result is that after a failover you re-read some records, never skip them. Plan for at-least-once and idempotent processing.
<target>.exactly.once.source.support = enabled (existing clusters go through preparing first), dedicated.mode.enable.internal.rest = true, and read the source with isolation.level=read_committed so aborted transactions aren't copied. Consumer failover via translated offsets is still at-least-once, as above.Two MM2 processes that target the same cluster share configuration through that cluster. If process 1 says A->B.topics = foo and process 2 says A->B.topics = bar, the docs say one of them wins depending on who is the elected leader, and you get foo or bar, not both. Keep one shared config per target. Also note that newly created remote topics use MM2's replication.factor, whose default is 2 (the heartbeats, checkpoints and offset-syncs topics default to 3). Set it explicitly for production.
Active/passive DR with "consume from remote, produce to local":
- Run MM2 next to the target. Producers suffer more from long, unreliable links than consumers do:
bin/connect-mirror-maker.sh connect-mirror-maker.properties --clusters secondary. - Enable
primary->secondaryonly. Setreplication.factor=3andprimary.consumer.isolation.level = read_committed. - Decide how groups fail over:
sync.group.offsets.enabled=true(offsets pre-written to the passive side), or translate at failover time withRemoteClusterUtils. - Monitor
replication-latency-ms,record-age-ms(MirrorSourceConnector) andcheckpoint-latency-ms(MirrorCheckpointConnector) underkafka.connect.mirror. - On failover, point consumers at
primary.orderson the secondary cluster, with idempotent processing.
🔬 Go deeper: OffsetSyncStore's exponential memory
OffsetSyncStore can't keep every sync forever, so it keeps up to 64 per partition (SYNCS_PER_PARTITION = Long.SIZE), spaced "approximately exponential[ly]": dense near the live end of the topic, sparse further back (its invariants: syncs[0] is the latest sync, syncs[63] the earliest usable one). Translation picks the sync that most closely precedes the group's offset. It returns nothing until the store has read the syncs topic to the end once ("This prevents emitting stale offsets"). If the group is behind the oldest sync it returns -1 ("too far in the past to translate accurately"). The practical effect: lagging groups translate less precisely and re-read more after a failover. Other defaults worth knowing: topics are rescanned every refresh.topics.interval.seconds (600), topic configs sync every 600 s (sync.topic.configs.enabled defaults to true), heartbeats are emitted every second, and topics.exclude defaults to mm2.*\.internal, .*\.replica, __.* in the 4.3.1 source (DefaultTopicFilter). The geo-replication docs page still shows an older pattern.
- MM2 = Connect source connectors: Source (data, configs, ACLs), Checkpoint (group offsets), Heartbeat. MM1 is gone since 4.0.
- Flows are directional and off by default. Remote topics are named
{source}.{topic}, which prevents loops. - Offsets differ across clusters. Offset syncs plus checkpoints translate them conservatively, so failover re-reads rather than skips.
sync.group.offsets.enableddefaults to false; remote-topicreplication.factordefaults to 2.- Run MM2 near the target; one consistent config per target cluster.
Upgrades, feature versions & the 4.x timeline
new binaries first, new rules laterUpgrading a Kafka cluster changes two separate things, and most upgrade mistakes come from mixing them up. The first is the software version: the jars on each node. The second is the feature levels the whole cluster has agreed to use: metadata.version and a handful of feature flags, stored as records in the KRaft metadata log.
In the archive, the software is each branch's clerks and furniture. The feature level is the edition of the city rulebook that the council has formally adopted. You retrain the clerks branch by branch, and the old rulebook stays in force while you do. Only when every branch can handle the new edition does the council vote it in. After that vote, some new rules write records the old clerks can't read, so there's no going back.
The rolling upgrade, per the 4.3 docs
- Check the preconditions. 4.x is KRaft-only. Brokers must already run KRaft with software and metadata at least 3.3.x. KRaft clusters older than 3.3.x should go to 3.9.x first. ZooKeeper clusters must migrate to KRaft using a bridge release, and "the last bridge release is Kafka 3.9".
- Roll the binaries: stop one broker, update the code, restart it, and repeat. The cluster still speaks the old
metadata.version, so a mixed-version cluster is fine. - Verify behavior and performance at your leisure. This is your cheap rollback window: just reinstall the old binaries.
- Finalize:
bin/kafka-features.sh --bootstrap-server localhost:9092 upgrade --release-version 4.3
Finalizing is where the one-way doors are. Every MetadataVersion carries a boolean that says whether it changes the metadata format. IBP_4_3_IV0(30, "4.3", "IV0", true) does, so after finalizing 4.3 you cannot downgrade the metadata. IBP_4_2_IV1(29, "4.2", "IV1", false) doesn't, which is why the 4.2 docs say downgrade is supported. A downgrade is possible only if no version between current and target has metadata changes.
metadata.version=4.2-IV1. Press ▶.MetadataVersion and the *Version enums in server-common (4.3.1). The refusal text is quoted from FeatureControlManager. Controllers are left out; in a real cluster you roll them too. 5.0 is not released; its card lists only removals announced in the 4.x upgrade notes.Feature flags: one switch per protocol
Since KIP-584, metadata.version isn't the only versioned feature. Each big protocol change has its own flag, so it can be enabled, and in most cases downgraded, independently. --release-version X sets all features to their defaults for that release. The tool's own help says "3.9-IV0 will set metadata.version=21 and kraft.version=1". --feature name=level changes just one.
| Feature | Level → enables | Default from release | Notes |
|---|---|---|---|
metadata.version | 3.3-IV3 (min) … 4.3-IV0 = level 30 (latest production) | n/a | Gates metadata record formats; one-way when "metadata changes" = true. |
kraft.version | 1 → KIP-853 dynamic controller quorum | 3.9-IV0 | Static→dynamic upgrade of existing quorums is supported since 4.1; afterwards, replace controller.quorum.voters with controller.quorum.bootstrap.servers. |
group.version | 1 → KIP-848 consumer rebalance protocol | 4.0-IV0 | Once groups use it, cluster downgrade only to ≥ 3.4.1. |
transaction.version | 1 → flexible transactional state records; 2 → epoch bump per transaction (KIP-890) | 4.0-IV2 | Downgrades are safe; producers switch on their next transaction. |
eligible.leader.replicas.version | 1 → ELR (KIP-966) | 4.1-IV0 (depends on MV ≥ 4.0-IV1) | Enabling moves min.insync.replicas to cluster level; downgrade to 0 is safe. |
share.version | 1 → share groups (KIP-932) | 4.2-IV0 | In 4.1 (preview) you had to enable it explicitly. |
streams.version | 1 → Streams rebalance protocol (KIP-1071) | 4.2-IV1 | upgrade --feature streams.version=1 / downgrade --feature streams.version=0. |
On a local 4.3 cluster: (a) list the finalized features, (b) see what --release-version 4.1 would map to without touching anything, (c) rehearse an upgrade with no side effects.
Answer
bin/kafka-features.sh --bootstrap-server localhost:9092 describe
bin/kafka-features.sh --bootstrap-server localhost:9092 version-mapping --release-version 4.1
bin/kafka-features.sh --bootstrap-server localhost:9092 upgrade --release-version 4.3 --dry-run
describe prints each feature's SupportedMinVersion, SupportedMaxVersion and FinalizedVersionLevel. Use --bootstrap-controller localhost:9093 to ask the controllers directly, and --node-id (4.2+) to pick a node. feature-dependencies --feature eligible.leader.replicas.version=1 shows that ELR needs a sufficiently new metadata.version. For brand-new clusters, kafka-storage.sh format accepts --release-version and --feature too.
4.0 removed old protocol API versions (KIP-896). Brokers and Java clients (including Streams and Connect) must both be at least 2.1 before either side goes to 4.0, and non-Apache clients need checking separately. Clients and Streams need Java 11+, and brokers, Connect and tools need Java 17+. Upgrading the cluster can break an ancient client that nobody remembers owning.
Why can't you just "undo" 4.3's metadata? Because after finalizing, the controller may write records in a format that older code can't parse, and a downgrade would have to drop them. Ask for it anyway and FeatureControlManager answers "Refusing to perform the requested downgrade because it might delete metadata information." Even with downgrade --unsafe, the answer in 4.3.1 is "Unsafe metadata downgrade is not supported in this version." A code comment points to KAFKA-13896 for a future implementation. The practical rule: finalize only after you're sure you won't need the old binaries.
What 5.0 will take away
5.0 isn't out, but the 4.x notes already name what it removes. Clean these up now: group.coordinator.rebalance.protocols (in 5.0 all protocols are always on, and group.version/streams.version/share.version alone control them), the kafka-streams-scala library, the CLI options deprecated by KIP-1147 (--producer-props, --consumer.config, --property…, replaced by --command-property, --command-config, --formatter-property, --reader-property), --max-partition-memory-bytes in the console producer, MX4J support, the misspelled PARTITIONER_ADPATIVE_PARTITIONING_ENABLE_CONFIG constant, the tag-less app-info metric names, and PrincipalConnectorClientConfigOverridePolicy (the new allowlist policy becomes the default).
🔬 Go deeper: how a feature's default is chosen
Every feature level is an enum constant with a bootstrap metadata version, for example GV_1(1, MetadataVersion.IBP_4_0_IV0, Map.of()), and optional dependencies, like ELRV_1(1, IBP_4_1_IV0, Map.of("metadata.version", IBP_4_0_IV1.featureLevel())). Feature.defaultVersion(mv) walks the levels and picks the highest one whose bootstrap version is ≤ mv. That's what --release-version and kafka-storage format use, and what version-mapping prints. Feature.validateVersion rejects levels whose dependencies aren't met. The 4.3.1 source already defines IBP_4_4_IV0(31, …), but LATEST_PRODUCTION is IBP_4_3_IV0. Versions past that are for testing only, and MINIMUM_VERSION is IBP_3_3_IV3. The broker exposes the active level as kafka.server:type=MetadataLoader,name=CurrentMetadataVersion.
- Two steps: roll binaries (reversible), then
kafka-features.sh upgrade --release-version X(maybe not reversible). - 4.x needs KRaft and ≥ 3.3.x; ZooKeeper clusters migrate via the 3.9 bridge first.
- Feature flags:
group.version(KIP-848),transaction.version(KIP-890),eligible.leader.replicas.version(ELR),share.version,streams.version,kraft.version. - 4.3-IV0 has metadata changes: no metadata downgrade, and unsafe downgrade isn't supported.
- Start removing 5.0-deprecated configs, CLI flags and metric names today.
Mastery: building apps
You know how the archive works inside. Now build clerks that survive bad records, broken networks, rebalances and their own bugs — with the plain Kafka clients, with Spring for Apache Kafka 4.1.1, and with an honest look at when Kafka is not the right archive at all.
Error handling with the Kafka clients
not every error deserves a retry — and some deserve nothing elseEvery Kafka application eventually meets three kinds of trouble: the network or cluster hiccups (a leader moves, an ISR shrinks), the data is bad (a record your code can't parse or can't process), or your own process misbehaves (too slow, killed mid-batch, fenced by a newer instance). Each needs a different reaction, and the most common production bug is treating all three the same way — usually with a try { … } catch (Exception e) { retry(); }.
A clerk of our city ledger archive is copying entries into ledger book 3 when the branch reports "the book's keeper just changed — try again". A minute later another entry is returned with "this entry is larger than the page allows". Should the clerk treat both the same way? Which one will never succeed no matter how many times it is retried?
The producer: retriable vs non-retriable
The Java client encodes the first split directly in its exception hierarchy. Every exception that extends org.apache.kafka.common.errors.RetriableException is, in the client's own words, "a transient exception that if retried may succeed". NotEnoughReplicasException, TimeoutException and the metadata errors (InvalidMetadataException and friends, such as NotLeaderOrFollowerException) live there. Everything else — RecordTooLargeException, InvalidTopicException, AuthorizationException, UnknownServerException — is non-retriable: the Callback javadoc calls them "fatal, the message will never be sent".
Here is the part many people miss: the producer already retries retriable errors for you. retries defaults to Integer.MAX_VALUE, and the real budget is time, not attempts: delivery.timeout.ms (default 120 000 ms = 2 minutes) is "an upper bound on the time to report success or failure after a call to send() returns". It covers the time the record waits in the accumulator, the wait for the broker's acknowledgement and all retries. Between attempts the client backs off exponentially from retry.backoff.ms (100 ms) up to retry.backoff.max.ms (1000 ms). Each single request is bounded by request.timeout.ms (30 000 ms), and the docs say delivery.timeout.ms should be ≥ request.timeout.ms + linger.ms.
So when your callback receives an exception, the client has already done what it could. A TimeoutException in your callback is technically "retriable", but it usually means the whole two-minute budget is gone. The other case is that send() already waited max.block.ms for metadata or buffer space, and doSend() reports that timeout through the callback too. Re-sending it blindly in the callback is how you get unbounded memory growth and records reordered behind newer ones. The right reaction is almost always: record the failure somewhere durable (a local outbox, a log, a metric + alert) and let a human or a controlled process decide.
| Config | Default (4.3.1) | What it does | Use it when |
|---|---|---|---|
delivery.timeout.ms | 120000 | Total time budget per record from send() returning to success/failure, including retries | The one knob to tune how long to keep trying; raise it to ride out longer leader elections, lower it for latency-sensitive paths that have their own fallback |
retries | 2147483647 | Max retry attempts for transient errors | Leave unset — the docs say to "prefer to leave this config unset and instead use delivery.timeout.ms". 0 disables idempotence (or throws if idempotence is explicitly on) |
request.timeout.ms | 30000 | How long the client waits for one request's response before retrying or failing | Rarely changed; keep it well below delivery.timeout.ms |
max.block.ms | 60000 | How long send() may block waiting for metadata and buffer space before failing | Lower it if a blocking send() would stall a request thread |
retry.backoff.ms / retry.backoff.max.ms | 100 / 1000 | Exponential back-off between retries | Rarely changed |
enable.idempotence | true | Broker de-duplicates retried batches; ordering preserved for up to 5 in-flight requests | Keep it on; it is what makes automatic retries safe (Part II, idempotent producer) |
Some errors are thrown from send() itself, not passed to the callback: a SerializationException ("if the key or value are not valid objects given the configured serializers"), an InterruptException, and IllegalStateException after close(). If you only look at the callback, those bypass your error handling. Wrap the call, not just the result. Note also that send() can block for up to max.block.ms (metadata fetch plus buffer allocation). That timeout arrives as a TimeoutException in the callback/future, after your thread has already waited.
Since Kafka 4.1, calling producer.flush() inside a callback throws a KafkaException: "KafkaProducer.flush() invocation inside a callback is not permitted because it may lead to deadlock." Callbacks run on the producer's I/O thread; the javadoc asks you to keep them fast and hand expensive work to your own Executor.
// Kafka clients 4.3.1 — a producer callback that classifies instead of blindly retrying
producer.send(record, (metadata, e) -> {
if (e == null) return; // acknowledged
if (e instanceof RetriableException) {
// the client already retried until delivery.timeout.ms ran out
outbox.park(record, e); // durable, replayable later
} else if (e instanceof RecordTooLargeException) {
deadLetters.record(record, e); // will never fit: don't retry
} else {
fatal.set(e); // auth, unknown server error…: stop, alert
}
});
The transactional and idempotent producer adds one more category. ProducerFencedException (a newer instance with the same transactional.id took over — Part III) extends ApplicationRecoverableException, whose javadoc says the error "is fatal to the producer, and the application needs to restart the producer after handling the error". The send() javadoc lists the transactional errors that abortTransaction() cannot fix — ProducerFencedException, OutOfOrderSequenceException, UnsupportedVersionException, AuthorizationException — and says "the only option left is to call close()". Any other failed transactional send surfaces at commitTransaction(); you abort and carry on.
The consumer: poison pills, lost commits, wake-ups
A poison pill is a record your consumer can't deserialize — someone wrote JSON into an Avro topic, or a producer shipped a new schema early. The consumer deserializes inside poll(), so the failure surfaces as a RecordDeserializationException thrown from poll(). Its message ends with the key sentence: "If needed, please seek past the record to continue consumption." The position does not move past the bad record by itself, so a loop that just logs and polls again hits the same record forever. Since Kafka 3.8 (KIP-1036) the exception carries everything you need to quarantine it: topicPartition(), offset(), origin() (KEY or VALUE), keyBuffer(), valueBuffer(), headers(), timestamp().
// Kafka clients 4.3.1 — quarantine a poison pill and move on
while (running) {
ConsumerRecords<String, Order> records;
try {
records = consumer.poll(Duration.ofMillis(500));
} catch (RecordDeserializationException e) {
dlqProducer.send(new ProducerRecord<ByteBuffer, ByteBuffer>(
"orders-dlq", null, e.keyBuffer(), e.valueBuffer(), e.headers()));
consumer.seek(e.topicPartition(), e.offset() + 1); // step over it
continue;
}
for (ConsumerRecord<String, Order> r : records) process(r); // must be idempotent
consumer.commitSync();
}
The second classic is CommitFailedException. Its javadoc explains it exactly: the commit failed "with an unrecoverable error. This can happen when a group rebalance completes before the commit could be successfully applied" — the partitions may already belong to another member. The default message even names the usual cause: the time between poll() calls exceeded max.poll.interval.ms (default 300 000 ms), so fix it "either by increasing max.poll.interval.ms or by reducing the maximum size of batches returned in poll() with max.poll.records" (default 500). Do not retry the commit: the records you processed will be re-delivered to the new owner. That is why consumers must be idempotent.
RebalanceInProgressException from commitSync() is milder: the javadoc suggests completing the rebalance with poll() and reconsidering the commit afterwards, with the caveat that your assignment may have changed.
Finally, WakeupException is not an error at all — it's the only thread-safe way to interrupt a consumer. KafkaConsumer "is NOT thread-safe", except for wakeup(), which makes the blocked call throw WakeupException. The javadoc pattern: set a closed flag, call consumer.wakeup() from the shutdown hook, catch WakeupException in the poll loop, rethrow it only if you weren't closing, and close() in finally. Thread interrupts also work, but the javadoc discourages them "since they may cause a clean shutdown of the consumer to be aborted".
RetriableException?delivery.timeout.ms) left?finallyDead-letter queues and idempotent consumers
Kafka's plain consumer has no built-in dead-letter queue (DLQ): a consumer group tracks one offset per partition, not a per-record fate. A DLQ in a consumer-group app is a pattern you build — a separate topic you produce the failed record to, with headers describing why, before you commit past it. Kafka Connect has one built in for sink connectors (errors.deadletterqueue.topic.name with errors.tolerance=all), and since 4.2 Kafka Streams has one too (errors.dead.letter.queue.topic.name, KIP-1034). Spring for Apache Kafka gives you one for listeners (next chapter). Share groups (Part III) take a different route: they count delivery attempts per record, and a rejected record gets no further delivery attempts.
A good DLQ record is replayable: the original bytes (not a toString()), the original topic/partition/offset, the exception class and message, a timestamp, and the consumer group. Someone will fix the bug and want to push those records back through — design for that day.
Because the consumer gives you at-least-once delivery (the KafkaConsumer javadoc: "each record will likely be delivered one time but in failure cases could be duplicated"), processing must tolerate duplicates. Common designs: a natural idempotent write (upsert by business key), a processed-ids table updated in the same database transaction as the effect, or storing the consumed offset alongside the result and seeking to it on assignment (the javadoc's "storing offsets outside Kafka" pattern). For Kafka-to-Kafka pipelines, transactions (Part III) make the read-process-write cycle exactly-once.
That dialogue is the central trade-off of this Part. Inside a partition, order is only preserved if you process strictly in sequence. Any retry that lets later records go first (a retry topic, a DLQ you replay later, a share group redelivery) breaks per-key order. Blocking retries keep order but stall every key in the partition and risk max.poll.interval.ms. Decide per topic which one you can afford.
retries=0 so I can handle failures myself?retries=0 disables idempotence (the config check logs "Idempotence will be disabled because retries is set to 0"), and your own resend creates a new record that the broker can't de-duplicate. Tune delivery.timeout.ms instead.enable.auto.commit=true at-least-once?poll() before the next poll() or close(). Hand records to another thread and the auto-commit can run ahead of what you've processed — the javadoc says this "results in missing records".CommitFailedException and just commit again?Make a poison pill on a local 4.3 cluster and watch a consumer choke on it:
bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic ints --partitions 1
bin/kafka-console-producer.sh --bootstrap-server localhost:9092 --topic ints
> hello
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic ints --from-beginning \
--formatter-property value.deserializer=org.apache.kafka.common.serialization.IntegerDeserializer
What happens? Which flag of kafka-console-consumer.sh keeps it running, and why is that flag the console-tool version of a DLQ-less "skip"?
Answer
"hello" is 5 bytes, and IntegerDeserializer throws SerializationException: Size of data received by IntegerDeserializer is not 4, so the tool stops. Add --skip-message-on-error ("If there is an error when processing a message, skip it instead of halt") and it logs the error and continues. Skipping without recording the record anywhere is data loss by design. Acceptable for a console tool, not for an application: there you would quarantine the bytes first. (The console consumer reads raw bytes and deserializes in its formatter, so the error surfaces there. In your own consumer with a value deserializer configured, the same bytes cause a RecordDeserializationException from poll().)
| Symptom | Likely cause | Fix |
|---|---|---|
| Consumer logs the same deserialization error forever; lag grows on one partition | Poison pill; the loop catches and re-polls without seeking | seek(tp, offset+1) after quarantining the bytes; or an error-handling deserializer |
CommitFailedException, duplicates downstream | Processing a batch took longer than max.poll.interval.ms; member evicted | Lower max.poll.records, speed up processing, or raise max.poll.interval.ms; make processing idempotent |
Callbacks report TimeoutException after ~2 minutes | delivery.timeout.ms exhausted (under-min-ISR partition, unreachable leader) | Fix the cluster side (Part IV troubleshooting); park records durably; alert |
| Throughput collapses after adding logging to the callback | Slow work on the producer I/O thread | Hand work to your own executor; keep callbacks trivial |
| Shutdown hangs or loses the last commit | Thread interrupt instead of wakeup(); no close() in finally | Use the javadoc wakeup() pattern |
🔬 Go deeper: where the client decides "retriable"
Broker errors travel as numeric error codes in each response. The client maps each code to an exception class through org.apache.kafka.common.protocol.Errors, and whether that class extends RetriableException decides the producer's path. In the producer's Sender, a batch that fails with a retriable error goes back into the RecordAccumulator if its delivery deadline hasn't passed; otherwise the batch completes exceptionally and every record's callback fires with that exception. Metadata-type errors extend InvalidMetadataException, which also triggers a metadata refresh before the retry — that's how a leader move becomes invisible to you.
The 4.x clients also have intermediate classes in the hierarchy: RefreshRetriableException ("requiring a refresh … before retrying"), ApplicationRecoverableException (restart the producer) as abstract bases, plus the concrete TransactionAbortableException (abort the transaction and continue). Classify on these base classes, not on long lists of leaf exceptions, and your handler keeps working when new leaves are added.
On the consumer side, deserialization happens in CompletedFetch (ShareCompletedFetch for share consumers): newRecordDeserializationException(…) builds the exception with the raw key/value ByteBuffers and headers, and the fetch keeps the bad record as the next one to return, so the same failure repeats until you seek.
- Producer:
RetriableExceptions are retried automatically untildelivery.timeout.ms(120 s); leaveretriesat its default and tune the timeout. - An exception in your callback means the client is done trying. Park or dead-letter the record instead of re-sending it blindly.
ProducerFencedException,OutOfOrderSequenceException,UnsupportedVersionExceptionandAuthorizationExceptionare fatal for a transactional producer:close()it. Don'tflush()inside callbacks (throws since 4.1).- Poison pill →
RecordDeserializationExceptionfrompoll(); quarantinekeyBuffer()/valueBuffer(), thenseek(offset + 1). CommitFailedException= you were rebalanced out (usually slow processing). Don't retry the commit; make processing idempotent.wakeup()+WakeupExceptionis the thread-safe shutdown path.- Retries that let later records overtake break per-key order. Blocking retries keep order but stall the partition.
Spring for Apache Kafka best practices
the framework gives you the patterns — you still pick themSpring for Apache Kafka wraps the clients you just learned in a listener container: it runs the poll loop, calls your @KafkaListener method, commits offsets, catches exceptions, retries, dead-letters and shuts down cleanly. The patterns from the last chapter are all there, already built. The catch is that its defaults were chosen for "works out of the box", not for "correct for your topic". This chapter is pinned to Spring for Apache Kafka 4.1.1 (2026-08-20). That release builds against kafka-clients 4.2.1 and Spring Framework 7.0.9 (from its gradle/libs.versions.toml), and its quick tour lists Java 17 as the minimum.
Your listener throws on a record because the payment gateway is down. With no error handler configured at all, how many times will Spring try that record before giving up — and what happens to it then?
The container: threads, partitions, commits
A ConcurrentKafkaListenerContainerFactory builds a ConcurrentMessageListenerContainer per @KafkaListener. With concurrency n it creates n KafkaMessageListenerContainers, each with its own KafkaConsumer and thread — consumers are not thread-safe, so one consumer per thread. Kafka's group protocol then spreads the partitions. In the archive: n clerks from the same team, each holding whole ledger books. More clerks than books means idle clerks. The docs warn about a subtler version: with three topics of five partitions each and concurrency=15, you "see only five active consumers" under the classic RangeAssignor default. Size concurrency to your partition count (summed over instances) and check the assignor.
Spring sets enable.auto.commit to false unless you set it (since 2.3), and commits for you according to the container's AckMode. The default is BATCH.
| AckMode | When the container commits | Use it when |
|---|---|---|
BATCH (default) | After all records returned by one poll() have been processed | General purpose; fewer commits |
RECORD | After the listener returns for each record | Slow, expensive records where re-processing a whole batch after a crash hurts; suggested for retry topics |
TIME / COUNT / COUNT_TIME | After the poll's records are processed, if ackTime elapsed and/or ackCount records were received | High-volume topics where commit frequency matters |
MANUAL | Listener calls Acknowledgment.acknowledge(); then BATCH semantics | Hand-off to async work that completes later |
MANUAL_IMMEDIATE | Immediately when acknowledge() is called | You need the commit to happen right at that point |
New in 4.1: @KafkaListener(ackMode = "MANUAL") overrides the factory's mode per listener, so you no longer need a second factory bean just for that. With transactions, offsets are sent to the transaction instead, and semantics equal RECORD or BATCH depending on the listener type.
Blocking retries: DefaultErrorHandler
When the listener throws, the container calls a CommonErrorHandler, by default the DefaultErrorHandler. It remembers the failed record, seeks the partition back so the record (and everything after it) is redelivered by the next poll(), and waits according to a Spring BackOff. The default is FixedBackOff(0, 9): ten deliveries with no delay, then the record is logged at ERROR and skipped. That answers the brain-power question. Without a recoverer, the default ends by skipping and logging, which is quiet data loss.
Two built-in guard rails matter:
- Fatal exceptions skip retries.
DeserializationException,MessageConversionException,ConversionException,MethodArgumentResolutionException,NoSuchMethodExceptionandClassCastExceptiongo straight to the recoverer on the first failure. Add your own withaddNotRetryableExceptions(…), for example validation errors. - Only
RuntimeExceptions are handled. The docs warn that anything extendingError"bypass[es] the error handler entirely, causing the consumer to terminate immediately", and your health check may still say UP.
Blocking retries keep order: nothing behind the failing record in that partition is processed until it succeeds or is recovered. The price is a stalled partition and the poll-interval clock. The container sleeps between attempts, but the consumer must still poll() within max.poll.interval.ms (300 s by default) or it is kicked out of the group. For long back-offs, the docs point to ContainerPausingBackOffHandler, which "pauses the listener container until the back off time passes", for delays "longer than the max.poll.interval.ms".
Dead letters: DeadLetterPublishingRecoverer
Give the error handler a recoverer and exhausted records get somewhere to go. DeadLetterPublishingRecoverer publishes the failed record with a KafkaTemplate. By default it goes to <originalTopic>-dlt and the same partition, so the DLT "must have at least as many partitions as the original topic". (The suffix became -dlt in 3.3. Older apps used .DLT and must opt in to keep it.) Each dead letter carries headers such as KafkaHeaders.DLT_EXCEPTION_FQCN, DLT_EXCEPTION_MESSAGE, DLT_EXCEPTION_STACKTRACE, DLT_ORIGINAL_TOPIC, DLT_ORIGINAL_PARTITION, DLT_ORIGINAL_OFFSET and DLT_ORIGINAL_CONSUMER_GROUP, which is everything the last chapter said a replayable DLQ record needs.
// Spring for Apache Kafka 4.1.1 (Spring Framework 7)
import org.springframework.kafka.support.ExponentialBackOffWithMaxRetries;
import org.springframework.kafka.listener.*;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
@Configuration
class KafkaErrorConfig {
@Bean
DeadLetterPublishingRecoverer recoverer(KafkaOperations<?, ?> template) {
return new DeadLetterPublishingRecoverer(template); // -> orders-dlt, same partition
}
@Bean
DefaultErrorHandler errorHandler(DeadLetterPublishingRecoverer recoverer) {
ExponentialBackOffWithMaxRetries backOff = new ExponentialBackOffWithMaxRetries(3);
backOff.setInitialInterval(1_000L);
backOff.setMultiplier(2.0);
backOff.setMaxInterval(10_000L); // 1 s, 2 s, 4 s, then DLT
DefaultErrorHandler handler = new DefaultErrorHandler(recoverer, backOff);
handler.addNotRetryableExceptions(IllegalArgumentException.class); // validation: no retry
return handler;
}
@Bean
ConcurrentKafkaListenerContainerFactory<String, Order> kafkaListenerContainerFactory(
ConsumerFactory<String, Order> consumerFactory, DefaultErrorHandler errorHandler) {
var factory = new ConcurrentKafkaListenerContainerFactory<String, Order>();
factory.setConsumerFactory(consumerFactory);
factory.setConcurrency(3); // <= partitions per instance
factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.RECORD);
factory.setCommonErrorHandler(errorHandler);
return factory;
}
}
Poison pills: ErrorHandlingDeserializer
Remember that deserialization happens inside poll(), before Spring sees anything, so "Spring has no way to handle the problem". The fix is to wrap your real deserializer in ErrorHandlingDeserializer. If the delegate fails, it returns null and puts a DeserializationException (with the raw bytes) in a header. The container sees the header, skips your listener and calls the error handler. Because DeserializationException is on the fatal list, the record goes straight to the recoverer. Paired with DeadLetterPublishingRecoverer, the DLT record gets the original bytes back as its value. Configure the delegate with ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS (spring.deserializer.value.delegate.class). In 4.x, the Jackson 3 JacksonJsonDeserializer replaces the deprecated Jackson 2 JsonDeserializer.
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class);
props.put(ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS, JacksonJsonDeserializer.class);
props.put(JacksonJsonDeserializer.VALUE_DEFAULT_TYPE, "com.example.Order");
props.put(JacksonJsonDeserializer.TRUSTED_PACKAGES, "com.example");
Non-blocking retries: @RetryableTopic and @DltHandler
The alternative to stalling a partition is to move the failing record aside. With @RetryableTopic on a @KafkaListener, a failure forwards the record to a retry topic with a back-off timestamp. The retry topic's consumer pauses that partition until the record is due, then processes it again. After the last attempt, the record goes to the DLT, handled by a @DltHandler method (or a default consumer "which only logs"). The framework creates the topics and containers for you.
@RetryableTopic(attempts = "4",
backOff = @BackOff(delay = 1000, multiplier = 2.0, maxDelay = 10000))
@KafkaListener(topics = "payments", groupId = "payments-svc")
void onPayment(Payment p) { gateway.charge(p); } // may throw
@DltHandler
void onDeadPayment(Payment p, @Header(KafkaHeaders.RECEIVED_TOPIC) String topic) {
alerts.raise(p, topic);
}
// creates payments-retry-1000, payments-retry-2000, payments-retry-4000, payments-dlt
Defaults to know: attempts = "3" (the number of attempts made before the message goes to the DLT), a fixed back-off of 1000 ms, retry suffix -retry, DLT suffix -dlt, numPartitions = "1" and replicationFactor = "-1" (broker default) for auto-created topics, and SameIntervalTopicReuseStrategy.SINGLE_TOPIC. So a fixed back-off uses one …-retry topic. (4.1 changed RetryTopicConfigurationBuilder's default to SINGLE_TOPIC too, to match the annotation.) In 4.0 the @Backoff from Spring Retry became Spring Kafka's own @BackOff, because Spring Retry was dropped in favour of Spring Framework 7's core retry support.
Two hard limits from the docs: non-blocking retries are not supported with batch listeners, and they cannot combine with container transactions. And the big caveat, in the docs' own capitals: "IMPORTANT: By using this strategy you lose Kafka's ordering guarantees for that topic." Record 18 overtakes the retried record 17. That is exactly what the figure's toggle shows.
FixedBackOff(1000L, 2L); non-blocking: @RetryableTopic defaults — 3 attempts, fixed 1 s, single -retry topic). Timing is compressed and illustrative; real retry topics pause the partition until the record is due.That combination is real: extend RetryTopicConfigurationSupport and override configureBlockingRetries(…) to retry chosen exceptions in place first (the docs' example: a database hiccup that "would likely trigger errors on the next records as well"), with non-blocking retries after that. There's a global list of fatal exceptions for retry topics too; those skip retries and go straight to the DLT.
Transactions
Give the DefaultKafkaProducerFactory a transactionIdPrefix and it keeps a cache of transactional producers (transactional.id = prefix + n). The prefix "must be unique per instance". With Spring Boot, setting spring.kafka.producer.transaction-id-prefix auto-configures a KafkaTransactionManager and wires it into the listener container. The container then starts a transaction before calling your listener, your KafkaTemplate sends join it, and on success it sends the consumed offsets with sendOffsetsToTransaction() before committing. That's the consume-transform-produce pattern from Part III, done for you. On an exception it rolls back and repositions the consumer. Repeated failures go to the DefaultAfterRollbackProcessor, the transactional twin of DefaultErrorHandler. Spring Kafka 3.0+ only supports EOSMode.V2 (KIP-447 fencing). The docs are careful to scope exactly-once: the read → process → write sequence completes once; "the read and process have at least once semantics". Side effects outside Kafka (an HTTP call, an email) can still happen twice.
For a database write plus a Kafka send, annotate the method @Transactional with your DataSourceTransactionManager. The KafkaTemplate synchronizes with it, and the DB commits first, then Kafka. That is two transactions, not one atomic one. If the second commit fails, the docs say the exception reaches the caller and you "should take remedial action … to compensate". For outside-world side effects, pair transactions with idempotent consumers.
Sending: KafkaTemplate returns a CompletableFuture
Every send(…) returns CompletableFuture<SendResult<K, V>>. Attach whenComplete((result, ex) -> …) for non-blocking handling. The Throwable can be cast to KafkaProducerException, whose getFailedProducerRecord() gives you the record. If you must block, the docs recommend get() "with a timeout". By default the template has a LoggingProducerListener that only logs errors, so a send you never check fails silently apart from a log line. The producer-side rules from the last chapter still apply underneath: keep completion handlers short, and move slow work to your own executor (e.g. whenCompleteAsync(…, executor)).
Share consumers (Kafka queues) in Spring
Spring Kafka 4.0 added early-access share consumers. The docs say that since 4.1, "backed by Apache Kafka 4.2, share consumers are promoted to full production support". You use a DefaultShareConsumerFactory, a ShareKafkaListenerContainerFactory and a plain @KafkaListener(containerFactory = "shareKafkaListenerContainerFactory"). Acknowledgement follows ContainerProperties.ShareAckMode (new in 4.1, replacing a boolean):
EXPLICIT(default): the container sends ACCEPT when your listener returns. On an exception, aShareConsumerRecordRecovererdecides ACCEPT, RELEASE or REJECT, and the default isREJECTING.MANUAL: your listener takes aShareAcknowledgmentand must call exactly one ofacknowledge(),release(),reject(). Unacknowledged records block the next poll.renew()(KIP-1222) extends the lock for slow work.IMPLICIT: maps toshare.acknowledgement.mode=implicit; every acquired record is accepted "regardless of processing outcome".
Since 4.1, undeserializable records are caught at poll level and REJECTed, so a poison pill can't kill the thread. Current limits: no batch listeners and no message converters for share consumers. The broker's delivery-count limit (the share.delivery.count.limit group config, broker default 5 via group.share.delivery.count.limit) is your built-in "poison pill" cutoff, and there is no ordering promise to lose.
Observability and testing
If Micrometer is on the classpath with a single MeterRegistry, the listener container and KafkaTemplate record timers automatically: spring.kafka.listener with tags name, result (success/failure) and exception, and spring.kafka.template for sends. Set observationEnabled to true on ContainerProperties and KafkaTemplate to switch to Micrometer Observation (see the Micrometer Tracing reference for tracing). MicrometerProducerListener/MicrometerConsumerListener bind the clients' own metrics (KafkaClientMetrics). One 4.1.1 behaviour change: KafkaAdmin now remembers a failed cluster-id lookup for clusterIdRetryInterval (default 5 minutes). Previously, an observation-enabled container could block on every record while the cluster id was unavailable.
For tests, @EmbeddedKafka starts an in-JVM broker. Since 4.0 that's only EmbeddedKafkaKraftBroker, KRaft only; the ZooKeeper variant and the JUnit 4 EmbeddedKafkaRule are gone. The docs recommend a single broker across tests with a different topic per test, via Spring's context caching. For tests closer to production (real broker image, real networking), the Testcontainers Kafka module's org.testcontainers.kafka.KafkaContainer runs the apache/kafka or apache/kafka-native images, and getBootstrapServers() gives you the address to point your factories at.
Best-practices checklist for a Spring Kafka 4.1 service:
- Pick an ordering stance per topic. Per-key state (balances, order status) → blocking
DefaultErrorHandler+ DLT. Independent events →@RetryableTopic. Truly queue-like work → share consumers. - Always configure a recoverer. The default ends with "log and skip". Use
DeadLetterPublishingRecovererand create the-dlttopic with ≥ the source's partition count. - Wrap deserializers in
ErrorHandlingDeserializerso poison pills become dead letters, not crash loops. - Classify exceptions:
addNotRetryableExceptions(…)for validation/business errors. Retry only what can heal. - Bound blocking back-off well below
max.poll.interval.ms, or useContainerPausingBackOffHandler. - Keep
concurrency≤ partitions, and watch the assignor for multi-topic listeners. - Make listeners idempotent. Every path here is at-least-once, except Kafka-to-Kafka writes in a container transaction.
- Handle the
CompletableFutureof everyKafkaTemplate.sendthat matters, or use transactions. - Unique
transactionIdPrefixper instance when using transactions. Don't combine them with retry topics. - Monitor the DLT (size, age),
spring.kafka.listenerfailure timers and consumer lag. A DLT nobody reads is a slower way of losing the data. - Test with
@EmbeddedKafkafor speed, and Testcontainers for real-broker behaviour.
| Anti-pattern | What goes wrong | Do instead |
|---|---|---|
No recoverer on DefaultErrorHandler | After 10 fast attempts the record is logged and skipped. Silent loss. | DeadLetterPublishingRecoverer + DLT alerting |
FixedBackOff.UNLIMITED_ATTEMPTS for everything | One poison record stalls the partition forever | Bounded attempts; unlimited only for known-transient exceptions via setBackOffFunction |
| Long blocking back-off (minutes) | Exceeds max.poll.interval.ms; rebalance; duplicates | ContainerPausingBackOffHandler or retry topics |
@RetryableTopic on per-key state updates | Later records overtake the retried one: lost ordering | Blocking retries, or park the whole key |
| Catching and swallowing exceptions in the listener | Container commits; error handler never sees it | Let it throw (or throw a classified exception) |
Throwing an Error subclass for business failures | Bypasses the error handler; consumer terminates | Extend RuntimeException |
| DLT with fewer partitions than the source | Default resolver targets the same partition number, which doesn't exist | Match partition counts or supply a destination resolver |
concurrency > partitions | Idle threads, false sense of scale | Add partitions (plan it) or use share consumers |
Ignoring the KafkaTemplate future | Failed sends only show up in the logs | whenComplete handling or transactions |
Same transactionIdPrefix on every replica | Instances fence each other (ProducerFencedException) | Unique prefix per instance |
You deployed the payments listener above. After a gateway outage, you want to inspect what landed in the DLT, including the headers Spring added. Which command shows them on a local 4.3 cluster, and which header tells you the original offset?
Answer
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic payments-dlt --from-beginning \
--formatter-property print.headers=true --formatter-property print.offset=true
print.headers and print.offset are DefaultMessageFormatter properties, passed with --formatter-property (4.2 deprecated the old --property flag in its favour). The original position is in the kafka_dlt-original-offset header, whose constant is KafkaHeaders.DLT_ORIGINAL_OFFSET. Next to it are …-original-topic, …-original-partition and the kafka_dlt-exception-* headers.
| Symptom | Likely cause | Fix |
|---|---|---|
| Listener stops consuming, app reports healthy | An Error (not RuntimeException) escaped the listener | Throw runtime exceptions; add a container-stopped event alert |
| Same record retried ~10 times fast, then vanishes | Default FixedBackOff(0, 9), default logging recoverer | Configure back-off and DeadLetterPublishingRecoverer |
| DLT publish fails: partition doesn't exist | DLT has fewer partitions than the source | Recreate DLT with enough partitions or custom resolver |
Retries never happen for ClassCastException | It's on the default fatal list | Intended; fix the type mapping, or reclassify deliberately |
Out-of-order updates after enabling @RetryableTopic | Non-blocking retries give up ordering | Blocking retries for that topic |
🔬 Go deeper: what the container actually does on an exception
In KafkaMessageListenerContainer's consumer loop, a listener exception is wrapped in ListenerExecutionFailedException and passed to CommonErrorHandler.handleRemaining(…) for record listeners. The DefaultErrorHandler (a FailedRecordProcessor) uses a FailedRecordTracker (a per-thread map of topic-partition → failed record and its offset) to count attempts and pick the BackOff, then either recovers the record or calls SeekUtils to seek each affected partition back to its first unprocessed offset. The next poll() re-fetches them. SeekUtils.DEFAULT_MAX_FAILURES is 10, hence DEFAULT_BACK_OFF = new FixedBackOff(0, DEFAULT_MAX_FAILURES - 1). Since 2.9, seekAfterError=false keeps the unprocessed records in memory and redelivers them after a paused poll() instead of seeking. resetStateOnExceptionChange (true by default for record listeners) restarts the back-off when the exception type changes. In 4.1, FailedBatchProcessor gained setBackOffFunction and setResetStateOnExceptionChange for batch listeners, defaulting to false there for compatibility.
For batch listeners, throw BatchListenerFailedException with the failing index. The handler commits the records before it, retries from the failing one and, once retries are exhausted, recovers only that record. Any other exception from a batch listener falls back to retrying the complete batch.
- Container = poll loop + commits + error handling;
concurrency= consumers/threads, useful only up to the partition count. - Default
AckModeisBATCH; 4.1 lets@KafkaListener(ackMode=…)override it per listener. DefaultErrorHandlerdefault = 10 attempts, no delay, then log and skip. Always add aDeadLetterPublishingRecoverer(<topic>-dlt, same partition).ErrorHandlingDeserializerturns poison pills into dead letters; deserialization errors are fatal (no retries).- Blocking retries keep order but stall the partition.
@RetryableTopickeeps flowing but loses ordering, and doesn't work with batch listeners or container transactions. - Transactions: unique
transactionIdPrefixper instance, EOS V2 only. Exactly-once covers read-process-write to Kafka, not other side effects. - Share consumers are production-ready in 4.1 (on Kafka 4.2+) with
ShareAckModeEXPLICIT/MANUAL/IMPLICIT. - Test with
@EmbeddedKafka(KRaft only) and Testcontainers'KafkaContainer.
Kafka vs the alternatives
a ledger archive is not a post office — sometimes you need a post officeAfter twenty-six chapters it would be easy to believe every messaging problem is a Kafka problem. It isn't. Our city ledger archive is superb at one thing: keeping an ordered, replayable, replicated record that many teams can read independently at their own pace. Some jobs look more like a post office: hand each parcel to exactly one courier, route it by address, and forget it once delivered. Others want the archive's model with a different building underneath. This chapter compares Kafka 4.3 with the main alternatives, fairly and with dates.
Every vendor statement below comes from that vendor's own documentation, fetched on 2026-09-25: RabbitMQ 4.3 docs, Apache Pulsar docs ("next") with 4.2.4 the newest stable line and 4.0 the LTS, Redpanda 26.2 docs, and the AWS Kinesis Data Streams and Google Cloud Pub/Sub docs. These products move fast. Re-check anything you're about to bet a platform on. Performance claims are the vendors' own marketing and are not repeated here as fact.
The one question that sorts everything: log or queue?
A log keeps records after they are read. Consumers track their own position, many independent readers can read the same data, and you can rewind (replay). Order is kept per partition. A queue hands each message to one consumer, tracks per-message acknowledgement, and deletes the message once it's acknowledged. Kafka consumer groups are log semantics. RabbitMQ classic and quorum queues are queue semantics. The interesting systems now offer both: Kafka added share groups (queue-like consumption over a log), RabbitMQ added streams (a log inside a broker built around queues), and Pulsar exposes both through subscription types.
RabbitMQ: queues, quorum queues, streams
RabbitMQ's model starts with routing. Publishers send to exchanges, which route to queues by binding rules. A queue "is an ordered collection of messages", FIFO, but priorities, multiple consumers and requeues after negative acknowledgements can change the order. After a consumer acknowledges, the message is removed. Quorum queues are "a modern queue type which implements a durable, replicated queue based on the Raft consensus algorithm" and the docs call them "the default choice when needing a replicated, highly available queue". They have per-message poison handling built in: since RabbitMQ 4.0 the delivery limit defaults to 20, after which the message is dropped or dead-lettered.
RabbitMQ streams are RabbitMQ's answer to the log: "an append-only log of messages that can be repeatedly read until they expire", with "non-destructive consumer semantics". They are always replicated and persistent, retention is set with x-max-length-bytes / x-max-age, consumers attach at an offset, timestamp or first/last, single active consumer gives ordered exclusive consumption, and super streams partition one logical stream across several streams. The docs' use cases are large fan-outs, replay/time-travel, throughput and large backlogs.
Apache Pulsar: brokers without disks
Pulsar separates serving from storage. Brokers are stateless ("The Pulsar message broker is a stateless component"), message data lives in Apache BookKeeper bookies as append-only ledgers, and a metadata store coordinates the cluster (Oxia recommended, ZooKeeper, or RocksDB). The Kafka analogy is a branch clerk who keeps no books at all: every page is written to a separate vault service. That makes brokers easy to scale out and replace, at the cost of running more distinct components. On top it has built-in multi-tenancy (tenants/namespaces), geo-replication, and four subscription types: Exclusive, Failover, Shared (round-robin, queue-like) and Key_Shared (messages with the same key go to one consumer). It also has negative acknowledgement, a retry letter topic and a dead letter topic built in. By default acknowledged messages are deleted; retention policies keep them for replay.
Redpanda: the Kafka API on a different engine
Redpanda is not a different messaging model. It's a different implementation of Kafka's model. Its docs say it is "compatible with the Kafka API", is "Built on C++", is "packaged as a single binary: it doesn't rely on any external systems", uses a thread-per-core model via the Seastar library, runs "Raft consensus … for data replication" where "every topic partition forms a Raft group", and offers tiered storage. Your Kafka clients and most tools connect unchanged. What you're comparing is operations, cost, performance and licensing, not semantics. Check which newer Kafka protocol features you depend on before assuming parity. Redpanda's Kafka-compatibility page says it "does not implement the server-side portion of KIP-890", so Kafka 4.x clients fall back to the original transaction protocol (no Transactions V2). KIP-848 consumer groups and share groups (KIP-932) are not mentioned in its compatibility docs.
Managed cloud streams: Kinesis and Pub/Sub
Amazon Kinesis Data Streams is a log, like Kafka. A stream is a set of shards (≈ partitions), records are routed by a partition key hashed with MD5, and each shard supports writes of up to 1,000 records/s or 1 MB/s and reads of up to 2 MB/s. Retention defaults to 24 hours and can be raised to 8,760 hours (365 days) at extra cost. The Kinesis Client Library keeps consumer state in DynamoDB tables. Google Cloud Pub/Sub is closer to a queue: at-least-once delivery by default, an optional exactly-once delivery feature, and ordering disabled by default. You enable it per subscription at creation and publish related messages with the same ordering key, limited to 1 MBps of publishing per key. Both remove the operations work. You give up protocol portability (Kafka clients don't speak them) and the Kafka ecosystem (Connect, Streams). Managed Kafka services are the middle ground: the Kafka API and semantics without running brokers. Compare their Kafka version and enabled features against what you need.
…and the option inside Kafka: share groups
Before adding a second broker technology for "we need a work queue", remember Part III. Since Kafka 4.2, share groups are production-ready. Consumers cooperatively consume the same partitions, there can be more consumers than partitions, records are acknowledged individually (accept / release / reject / renew), and delivery attempts are counted, with a broker default limit of 5 (group.share.delivery.count.limit) and a 30 s acquisition lock by default. You keep one platform, one security model and replay of the underlying topic. You give up ordering: the Kafka docs say to use share groups "in cases where records are processed one at a time, rather than as part of an ordered stream". What they don't give you (as of 4.3) is RabbitMQ-style routing or a broker-managed dead-letter topic. A rejected record simply isn't delivered again, so capture it yourself if you need it.
| Dimension (as of Sept 2026) | Kafka 4.3 consumer groups | Kafka 4.3 share groups | RabbitMQ 4.3 quorum queues | RabbitMQ 4.3 streams | Pulsar 4.x | Redpanda 26.2 |
|---|---|---|---|---|---|---|
| Storage model | Partitioned replicated log on broker disks (+ tiered storage) | Same log; per-record in-flight state tracked by the broker (SharePartition), persisted via the share coordinator in __share_group_state | Raft-replicated queue; message removed on ack | Replicated append-only log | Stateless brokers + BookKeeper ledgers | Raft-replicated partitioned log; tiered storage |
| Replay | Yes (retention, seek) | Underlying topic, yes | No (destructive) | Yes (offset/timestamp) | With retention policy | Yes (Kafka API) |
| Ordering | Per partition | Not an ordered stream | FIFO, but redelivery / multiple consumers can reorder | Per stream; single active consumer | Per key/partition; Key_Shared per key | Per partition |
| Consumers > partitions | No (extras idle) | Yes | Yes (competing consumers) | No — work is split by super-stream partitions (SAC: one active consumer each) | Shared / Key_Shared | No (Kafka groups) |
| Per-message retry / poison limit | App-level (Spring, custom) | Delivery count (default limit 5) | Delivery limit (default 20) + dead-lettering | App-level | Nack, retry letter topic, DLT | App-level |
| Routing | By topic/key | By topic | Exchanges + bindings | Exchanges + bindings | Topics, namespaces, tenants | By topic/key |
| Client protocol | Kafka | Kafka (share APIs) | RabbitMQ (AMQP) clients | Dedicated stream protocol (recommended) or AMQP 0-9-1 | Pulsar clients | Kafka API |
You have a thumbnails topic with 3 partitions and bursts that need 12 workers. Try the in-Kafka queue option on a local 4.3 cluster (share groups are enabled by default on new 4.2+ clusters; on an upgraded cluster first run bin/kafka-features.sh --bootstrap-server localhost:9092 upgrade --feature share.version=1). On a single-broker dev cluster, also set share.coordinator.state.topic.replication.factor=1 and share.coordinator.state.topic.min.isr=1 in the broker config before first use, because __share_group_state defaults to 3 replicas:
bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic thumbnails --partitions 3
bin/kafka-console-share-consumer.sh --bootstrap-server localhost:9092 --topic thumbnails --group thumbs
# start the same command in 3 more terminals, then produce 20 records
How many of the four share consumers receive records? What would happen with kafka-console-consumer.sh --group thumbs2 in four terminals?
Answer
With the share group, records are spread across all four consumers even though there are only 3 partitions — "the number of consumers in a share group can exceed the number of partitions". With a classic consumer group, at most 3 consumers get partitions and the fourth is idle. The share group version gives up per-partition ordering in exchange.
🔬 Go deeper: why the architectures lead to these trade-offs
Where state lives decides what's cheap. A Kafka consumer group stores one committed offset per partition in __consumer_offsets, so a million-record backlog costs the same bookkeeping as ten records. That's why fan-out and replay are nearly free and per-record acknowledgement isn't offered. Share groups add per-record state (acquired/acknowledged/archived and delivery counts) for a bounded window. The broker caps in-flight acquired records per share-partition (group.share.partition.max.record.locks) and persists share-partition state through the share coordinator in __share_group_state. That bound is how Kafka offers queue semantics without per-message state for the whole log.
A quorum queue keeps per-message state by design, so a message leaves once it's acknowledged. RabbitMQ streams and Kafka keep the log and move per-reader state to the consumer (offset tracking). Pulsar tracks acknowledgement per subscription (individual or cumulative), which is how one topic can serve both queue-like Shared and stream-like Exclusive/Failover subscriptions. Redpanda replaces the per-partition ISR replication you learned in Part II with a Raft group per partition. Clients see the same Kafka API, but the replication and failure behaviour underneath differs, so re-validate your durability assumptions (acks, min ISR semantics) against its docs.
- Decide log vs queue first: replay and fan-out → log; per-message ack, routing, "forget after delivery" → queue.
- RabbitMQ 4.3: exchanges route; quorum queues are the replicated default (delivery limit 20); streams add a replayable log.
- Pulsar: stateless brokers + BookKeeper; Exclusive/Failover/Shared/Key_Shared subscriptions; built-in retry letter and DLT topics.
- Redpanda: Kafka API, different engine (C++, Raft per partition, single binary). Verify newer-feature parity before relying on it (e.g. no server-side KIP-890 / Transactions V2).
- Kinesis (shards, 24 h default retention up to 365 d) and Pub/Sub (ordering keys off by default) trade Kafka portability for zero ops.
- Share groups (Kafka 4.2+) are the in-Kafka queue: more consumers than partitions, per-record ack, delivery limit; no ordering.
- All comparisons are dated Sept 2026. Re-check vendor docs before deciding.
Exam + cheat sheet
check what stuckOne or more questions per chapter. Questions tagged (L2)–(L4) belong to the deeper levels; the wrong answers are the misconceptions the chapters took apart. Click an answer to see why.
Cheat sheet
| Parameter / command | Default / what it does | Use when |
|---|---|---|
| linger.ms | 5 (since 4.0) — max wait to fill a batch | Raise for throughput; lower only for very latency-sensitive, low-volume producers |
| batch.size | 16384 bytes — max per-partition batch | Raise for high volume or with compression |
| acks | all — leader waits for the full ISR | Keep all; 0/1 only when losing data is acceptable |
| auto.offset.reset | latest — where a group without a committed offset starts | earliest to read existing data; none to fail loudly |
| enable.auto.commit | true, every auto.commit.interval.ms = 5000 | Turn off for manual commit after processing |
| min.insync.replicas | 1 — min ISR size for acks=all writes and for records to become committed/visible | Set 2 with RF 3 |
| default.replication.factor | 1 — RF for topics created without one | Set 3 in production (or pass --replication-factor 3) |
| retention.ms / retention.bytes | 604800000 (7 d) / -1 (per partition, unlimited) | Match how far back consumers must re-read; cap disk |
| cleanup.policy | delete — or compact (latest value per key) | compact for current-state topics |
| kafka-consumer-groups.sh --describe --group G | CURRENT-OFFSET, LOG-END-OFFSET, LAG per partition | Checking if a group keeps up |
| segment.bytes | 1073741824 (1 GiB): max segment file size | Lower it for compacted or short-retention topics so data becomes deletable sooner |
| segment.ms | 604800000 (7 d): force a roll after this age | Low-volume topics where retention/compaction must act on time |
| index.interval.bytes | 4096: bytes between sparse offset-index entries | Almost never changed |
| compression.type (producer / topic) | none / producer: batch codec; topic "producer" keeps the producer's codec | Enable zstd/lz4 on producers; leave the topic at producer to avoid recompression |
| kafka-dump-log.sh --files f.log [--cluster-metadata-decoder] | Print batch headers (offsets, producerId, sequence, codec, crc) or decoded metadata records | Inspect segments, idempotence state, KRaft metadata |
| broker.session.timeout.ms | 9000: broker lease without heartbeats (heartbeat every 2000 ms) | Tune failover speed vs false fencing |
| kafka-metadata-quorum.sh describe --status | --replication | Leader id, epoch, high watermark, voters/observers, follower lag | Checking controller quorum health |
| replica.lag.time.max.ms | 30000: follower not caught up for this long → removed from the ISR | Rarely; fix slow brokers instead of raising it |
| group.protocol | classic: consumer rebalance protocol; "consumer" = KIP-848 | New apps on 4.x brokers: set consumer |
| max.poll.interval.ms / session.timeout.ms | 300000 / 45000: progress timeout / liveness timeout | Slow processing / faster crash detection |
| enable.idempotence | true (needs acks=all, retries > 0, max.in.flight ≤ 5) | Always; set explicitly so conflicting configs fail loudly |
| transactional.id | null; enables transactions + fencing (implies idempotence) | atomic multi-partition writes, consume–transform–produce |
| transaction.timeout.ms | 60000; coordinator aborts older open txns (≤ broker transaction.max.timeout.ms, 15 min) | lower it so hung producers stop blocking read_committed readers |
| isolation.level | read_uncommitted; read_committed reads only up to the LSO | downstream of transactional producers |
| kafka-transactions.sh list | describe | find-hanging | abort | inspect and repair transactions | read_committed consumers stuck at the LSO |
| cleanup.policy=compact[,delete] | delete; compact keeps latest value per key | changelog / state / "current value" topics |
| delete.retention.ms | 86400000 (24 h) tombstone lifetime | raise if full state replays take longer |
| num.network.threads / num.io.threads / queued.max.requests | 3 (per listener) / 8 / 500 | tune only after reading the idle-percent gauges and the TotalTimeMs breakdown |
| unclean.leader.election.enable | false; true lets an out-of-sync replica lead (data loss) | emergency, per topic, when availability beats completeness |
| remote.storage.enable + local.retention.ms | false / -2 (= full retention); local deletion only after upload | long retention on cheap object storage, non-compacted topics |
| share.record.lock.duration.ms / share.delivery.count.limit (group) | 30000 ms / 5 | tune per share group: slow jobs, poison-record tolerance |
| processing.guarantee=exactly_once_v2 | at_least_once default; EOS drops commit.interval.ms to 100 | Streams results must not double-count |
| num.standby.replicas / streams.num.standby.replicas | 0; warm shadow copies of task state | large state stores, fast failover |
| kafka-configs.sh --alter --add-config 'producer_byte_rate=…' --entity-type users|clients | Set a client quota (also consumer_byte_rate, request_percentage, controller_mutation_rate); default unlimited | Multi-tenant clusters; noisy neighbours |
| quota.window.num / quota.window.size.seconds | 11 samples / 1 s | Rarely changed; short windows mean short throttles |
| kafka-topics.sh --describe --under-replicated-partitions | --under-min-isr-partitions | List partitions with a shrunken ISR | First step for URP / NotEnoughReplicas alerts |
| kafka-consumer-groups.sh --reset-offsets … --by-duration PT6H --execute | Move a stopped group's offsets (dry run without --execute) | Replaying after offset-out-of-range or a bad deploy |
| {source}->{target}.enabled (MM2) | false | Turn on a MirrorMaker 2 replication flow |
| sync.group.offsets.enabled (MM2) | false | Pre-write translated group offsets for DR failover |
| kafka-features.sh upgrade --release-version 4.3 | Finalize the upgrade: sets metadata.version and all feature defaults | After all nodes run 4.3 and behave well (4.3-IV0 cannot be downgraded) |
| kafka-features.sh describe | version-mapping | --dry-run | Show finalized levels / what a release maps to / rehearse | Before and after every upgrade |
| delivery.timeout.ms | 120000 — total per-record budget incl. retries | Tune how long the producer keeps trying; leave retries unset |
| retries | 2147483647 — attempts for transient errors | Don't set to 0: disables idempotence |
| max.poll.interval.ms / max.poll.records | 300000 / 500 | Seeing CommitFailedException / rebalances from slow processing |
| consumer.seek(tp, offset + 1) | step over a poison pill after RecordDeserializationException | After quarantining keyBuffer()/valueBuffer() to a DLQ |
| consumer.wakeup() | the only thread-safe KafkaConsumer call; poll throws WakeupException | Clean shutdown from another thread |
| DefaultErrorHandler(recoverer, backOff) | default FixedBackOff(0, 9) + log-and-skip | Always pass a DeadLetterPublishingRecoverer and a bounded BackOff |
| DeadLetterPublishingRecoverer | <topic>-dlt, same partition, kafka_dlt-* headers | Replayable dead letters; DLT needs ≥ source partitions |
| ErrorHandlingDeserializer | wraps delegate; failure → header, record to error handler | Any consumer whose payloads could be malformed |
| @RetryableTopic / @DltHandler | attempts 3, fixed 1000 ms, -retry / -dlt suffixes | Independent events where ordering doesn't matter |
| ContainerProperties.AckMode | BATCH (default), RECORD, TIME, COUNT, COUNT_TIME, MANUAL, MANUAL_IMMEDIATE | RECORD for slow records/retry topics; per listener via @KafkaListener(ackMode=…) since 4.1 |
| ContainerProperties.ShareAckMode | EXPLICIT (default), MANUAL, IMPLICIT | Share consumers in Spring Kafka 4.1 |
| group.share.delivery.count.limit | 5 — broker default max delivery attempts per record | In-Kafka queue with a poison-message cutoff |
Back to the question from the start: how can an append-only log that does not force every write to disk be fast, durable and exactly-once?
Fast because appends are sequential writes into segment files, reads are served from the OS page cache,
data moves in compressed batches from producer to disk to consumer without being re-encoded, and plain-text reads
use zero-copy transfer from file to socket. Durable not because of fsync, but because of replication:
a write counts as committed only when every in-sync replica has it (the high watermark), acks=all
plus min.insync.replicas put a floor under that set, and leader epochs let a returning broker cut
away exactly the records that never got committed. Exactly-once because idempotent producers stamp every
batch with a producer ID, epoch and sequence number so retries cannot duplicate, and transactions write commit or
abort markers that read_committed consumers respect — with the consumer's offsets committed inside
the same transaction.
Next step: run a three-broker KRaft cluster locally, break it on purpose with the exercises from each chapter, and watch the metrics from the troubleshooting chapter while you do.