Lesson 37 · Logs, offsets & consumer groups

The Log as an Abstraction

A queue forgets a message the moment someone reads it. That single property is the source of nearly every operational complaint people have about queues: you cannot replay, you cannot add a second reader cheaply, and one slow consumer drags everyone else down with it. The log deletes nothing, and hands the bookkeeping to the reader.

The win in this lesson: you will be able to choose a partition key knowing exactly which ordering guarantee you are buying — and what parallelism ceiling you have just set for every consumer group that will ever read the topic.

1. The queue that eats its own evidence

Start with what a classical broker has to do. It owns the truth about who has read what, so it must keep per-message state, forever, until that message is resolved:

"Most messaging systems keep metadata about what messages have been consumed on the broker." […] "as a message is handed out to a consumer, the broker either records that fact locally immediately or it may wait for acknowledgement from the consumer."

Apache Kafka documentation, Design — Consumer Position

Both halves of that choice are bad, and the docs are unusually candid about it. Record on hand-out and a crashed consumer loses the message. Wait for an acknowledgement and you get the other failure:

"if the consumer processes the message but fails before it can send an acknowledgement then the message will be consumed twice" […] "the broker must keep multiple states about every single message (first to lock it so it is not given out a second time, and then to mark it as permanently consumed so that it can be removed)"

Apache Kafka documentation, Design — Consumer Position

Note what that per-message lock implies. A record being processed slowly is a record the broker is holding open — memory, a timer, a redelivery decision. And the honest closing line: "Tricky problems must be dealt with, like what to do with messages that are sent but never acknowledged."

The two costs, stated plainly

You cannot replay, because the broker deletes what it believes is consumed — the evidence is gone by the time you discover the bug. And one consumer's pace is everyone's problem, because the broker is the single place where who has read what lives, so a slow reader means locks held, memory grown and redelivery timers firing.

2. The log

Invert it. Do not model a topic as a mailbox that drains. Model it as a file you only ever append to, and never truncate on read.

"The log allows serial appends which always go to the last file." […] "Each message is uniquely identified by a 64-bit integer offset giving the byte position of the start of this message in the stream of all messages ever sent to that topic on that partition."

Apache Kafka documentation, Implementation — Log

Two consequences fall straight out. Records survive being read — "events are not deleted after consumption. Instead, you define for how long Kafka should retain your events through a per-topic configuration setting, after which old events will be discarded." And every record has a stable, monotonic name within its partition: the offset. A name you can write down, store, and come back to.

topic: orders three partitions. Each is a separate, totally ordered log. partition 0 k:b2 k:f9 k:a1 k:c7 k:a1 k:b2 k:a1 9636 9637 9638 9639 9640 9641 9642 partition 1 k:d4 k:e0 k:d4 k:e0 4101 4102 4103 4104 partition 2 k:g8 k:h3 k:g8 k:h3 k:g8 k:h3 5899 5900 5901 5902 5903 5904 dashed = past the retention window appends only ever land here every k:a1 record is in partition 0, in the order it was written
The offset is not a queue position — it is a permanent address. Offset 9640 in partition 0 means the same record tomorrow as it does now, to every reader, which is the whole reason a reader can hold onto it. The dashed cells at the left are the only thing that ever removes data: the clock, not a consumer.

3. The offset is the consumer's state, not the broker's

Here is the move the whole abstraction rests on. Because records are addressable and immutable, the broker does not need to remember anything per message. It only needs each reader to say where it is:

"Our topic is divided into a set of totally ordered partitions, each of which is consumed by exactly one consumer within each subscribing consumer group at any given time. This means that the position of a consumer in each partition is just a single integer, the offset of the next message to consume. This makes the state about what has been consumed very small, just one number for each partition." […] "This makes the equivalent of message acknowledgements very cheap."

Apache Kafka documentation, Design — Consumer Position

Per-message locks, delivery counters and redelivery timers all collapse into one integer per partition per group. Reading is a pull: "The consumer specifies its offset in the log with each request and receives back a chunk of log beginning from that position."

Replay is not a feature — it is the absence of deletion

Nobody had to build rewind. It falls out of the fact that the record is still there and still has the same name:

"A consumer can deliberately rewind back to an old offset and re-consume data. This violates the common contract of a queue, but turns out to be an essential feature for many consumers."

Apache Kafka documentation, Design — Consumer Position

And a slow reader is only slow for itself

This is the property people underrate. Two groups reading the same partition share nothing except the bytes. The docs frame it as a pull-versus-push argument, and the pull side wins for exactly this reason: "a push-based system has difficulty dealing with diverse consumers as the broker controls the rate at which data is transferred", whereas "A pull-based system has the nicer property that the consumer simply falls behind and catches up when it can."

producer partition 0 log group: alerts group: reports append k:a1 → offset 9642 log end offset is now 9643 fetch from offset 9641 records 9641, 9642 commit offset 9643 — lag 0 same partition, same second, a different reader the lag window: this group is 3 741 records behind the log end fetch from offset 5902 records 5902 … 6401 — still on disk, still addressable commit 6402 — lag 3 241 nothing here touched the alerts group: no lock, no redelivery, no shared cursor broker state for both groups: two integers, 9643 and 6402
Draw the same picture for a broker-tracked queue and the shaded band becomes shared: the lagging reader's un-acknowledged records are locks the broker is holding, and the fast reader is behind them. Here the band is private. That is the entire difference, and it is why "add a second consumer" is a one-line change on a log and a capacity conversation on a queue.

What you get back for free, in practice, is a lag number per group per partition — the gap between the log end and where that group has committed. It is the single most useful metric a log gives you, and it is arithmetic, not instrumentation:

GROUP    TOPIC   PARTITION  CURRENT-OFFSET  LOG-END-OFFSET   LAG
alerts   orders          0            9643            9643      0
alerts   orders          1            4105            4105      0
reports  orders          0            6402            9643   3241
reports  orders          1            4105            4105      0

(illustrative shape, not measured output — the point is that LAG is a subtraction)

4. Ordering is a promise about one partition — nothing wider

A topic is not one log. It is a set of logs: "a topic is spread over a number of “buckets” located on different Kafka brokers", and "When a new event is published to a topic, it is actually appended to one of the topic’s partitions."

Which one? The key decides, and that is the whole sentence you need to memorise:

"Events with the same event key (e.g., a customer or vehicle ID) are written to the same partition, and Kafka guarantees that any consumer of a given topic-partition will always read that partition’s events in exactly the same order as they were written."

Apache Kafka documentation, Introduction — Main Concepts and Terminology

Read it for what it does not say. There is no promise about two records with different keys, no promise across partitions, no global order for the topic. The producer side says the same thing from the other end: "We expose the interface for semantic partitioning by allowing the user to specify a key to partition by and using this to hash to a partition", and "For example if the key chosen was a user id then all data for a given user would be sent to the same partition."

The one idea worth remembering

The partition key is the ordering guarantee. Choosing customer_id does not merely distribute load — it decides, permanently, that you get order per customer and nothing else. If your invariant is a cancellation must never be processed before its order, then the key must be whatever the two events share. If it is not, no amount of consumer-side cleverness gets the ordering back.

Key you chooseOrdering you actually buyWhat it costs you
null (spread freely) None you can rely on Perfectly even load; every cross-record invariant is now your problem
customer_id Total order per customer One busy customer is one busy partition — see below
order_id Total order per order Fine grain, good spread, and no ordering between two orders of the same customer
region (coarse) Total order per region A handful of partitions carry everything; the ceiling in §5 collapses

The docs are explicit that this is a design choice made for the reader's benefit, not just a load-balancing trick: "This style of partitioning is explicitly designed to allow locality-sensitive processing in consumers." A consumer of one partition may safely keep per-key state in memory, because no other consumer in its group is touching those keys.

5. Consumer groups, and the ceiling you just set

A group is how you get parallelism without giving up the order you just bought. The assignment rule is the quote from §3, and it is doing more work than it looks: "each of which is consumed by exactly one consumer within each subscribing consumer group at any given time."

Exactly one. Not at least one. So a partition is the unit of parallelism, and the number of partitions is a hard ceiling on the number of usefully-employed consumers in any one group. Kafka 4.x makes the contrast explicit when it introduces share groups as the alternative for workloads that want queue semantics back — the first two differences listed are "The consumers in a share group cooperatively consume records, and partitions may be assigned to multiple consumers" and "The number of consumers in a share group can exceed the number of partitions in a topic."

4 partitions group: alerts — 6 consumers group: reports — 2 consumers P0 P1 P2 P3 consumer 1 consumer 2 consumer 3 consumer 4 consumer 5 — idle consumer 6 — idle consumer A — P0, P1 consumer B — P2, P3 adding a 5th process does not add throughput. Adding a partition does.
Two groups, one topic, no interference — the reports group re-reads everything the alerts group has already seen, and neither is aware of the other. Inside a group, though, the ceiling bites: consumers 5 and 6 are paid-for processes doing nothing, and no autoscaler can fix that.

So the partition count is a capacity decision made at topic-creation time, and it is the same decision as the ordering one. More partitions means a higher ceiling and weaker ordering; fewer means stronger ordering and a lower ceiling. There is no setting that gives you both.

6. Residual risk

Hot partitions — the same bet, in new clothes

Lesson 02's warning applies here without a single word changed. A partition key is a distribution bet, and human-generated keys follow a power law. Key by customer_id and your largest customer's traffic lands, by design, on one partition — which means one broker's disk, and exactly one consumer in each group. The parallelism ceiling is not partitions in practice; it is partitions, weighted by how evenly the key spreads.

Worse than in Lesson 02, the symptom is quiet. A hot database partition shows up as tail latency. A hot log partition shows up as lag on one partition while the topic-level average looks fine — so alert on max-partition lag, never mean lag.

Rebalances

Membership changes trigger reassignment, and the classic protocol paid for that with a synchronised stop. Kafka's newer protocol is described precisely in those terms:

"It improves the scalability of consumer groups while simplifying consumers. It also decreases rebalance times, thanks to its fully incremental design, which no longer relies on a global synchronization barrier."

Apache Kafka documentation, Consumer Rebalance Protocol

The operational reading: a rolling deploy of a consumer group is a sequence of membership changes, and anything your consumer keeps in memory per partition has to be rebuilt when that partition moves. Large local state plus frequent restarts is the combination that hurts.

Retention — replay has an expiry date

Replay is only free inside the window you paid for. Past it, the record is gone and the offset you stored is meaningless: "when the client attempts to consume a non-existent offset it is given an OutOfRangeException". A consumer that has been down longer than the retention period does not resume — it jumps, silently, to whatever its reset policy says, and skips everything in between.

Three numbers to write down before you create a topic

The key — which decides your ordering guarantee and your hotspot. The partition count — which is your parallelism ceiling for every group, forever. The retention — which is how long just replay it remains a true sentence. Every incident in this lesson is one of those three chosen without noticing it was being chosen.

7. Check yourself

8. Back to your world

You do not need a streaming platform to have made these decisions. Any time you put work on a queue keyed by tenant, fan out events to more than one downstream, or find yourself writing we should be able to re-run yesterday's events, you are choosing between a mailbox and a log — usually without saying so.

Three questions to carry out: which two records in this stream must never be reordered, and do they share a key? How many consumers could I usefully run, and did I set that number by accident? And how long is replay actually true for — a day, a week, or until the next deploy?

Ask me things. Good directions to push: "what happens to my ordering when I increase the partition count mid-flight?" · "where is the committed offset actually stored, and what happens if that write fails?" · "I want per-record acknowledgement AND replay. Grill me on why I cannot have both." · "how does log compaction change what replay means?" · "show me the same sequence diagram for a broker-tracked queue."