open atlas
↑ Back to track
System Design Case Studies SDC · 01 · 04

Design a distributed message queue

Design a Kafka-like log: topics split into partitions for parallelism, replicated via an ISR for durability, consumer offsets for replay, and the delivery-semantics trade — at-most-once, at-least-once, exactly-once — that every async system must pick.

SDC Senior ◷ 32 min
Level
FoundationsJuniorMiddleSenior

A payments team wired their services together with a traditional message broker that deleted each message once a consumer acknowledged it. It worked until the day a downstream fraud-scoring service shipped a bug, silently mis-scored an hour of transactions, and the team realised the original messages were gone — consumed, acked, deleted. There was no way to replay the hour through the fixed code, because the queue had treated messages as mail to be delivered once and discarded. The rebuild changed one assumption: messages are not mail, they are a durable, append-only log that consumers read at their own position and that the broker keeps for days regardless of who has read it. That single change — from “delete on delivery” to “retain and let consumers track their own offset” — is the difference between a classic queue and the Kafka-shaped log, and it makes replay, multiple independent consumers, and enormous throughput all fall out of the same structure.

Requirements

The hook’s fraud team didn’t need a faster queue — they needed one with a different contract. When you read the functional requirements below, notice which one the classic broker violated and which requirement makes replay and multiple consumers structurally free rather than bolted on.

Functional

  • Produce: append a message to a named topic.
  • Consume: read messages from a topic, in order, starting from a chosen position.
  • Multiple independent consumers: several different systems read the same topic without interfering (the fraud scorer, the ledger, the analytics pipeline all read the payment stream).
  • Replay: a consumer can rewind and re-read past messages (the hook’s missing feature).
  • Retention: messages are kept for a configured window (time or size), not deleted on delivery.

Non-functional

  • High throughput. Millions of messages/sec — this is a firehose, not a mailbox.
  • Durability. An acknowledged message must survive broker crashes; losing a payment event is unacceptable.
  • Horizontal scale. Add brokers to add capacity; no single broker is a ceiling.
  • Ordering guarantee — at least within a partition.
  • Tunable delivery semantics. Different consumers tolerate different failure modes (a metrics pipeline can drop a message; a ledger cannot).

The animating idea is that the queue is a log, not a mailbox: append-only, retained, and read by position. Everything else is how to make that log fast, durable, and parallel.

Estimation

  • Target 1 million messages/sec at peak, average message 1 KB~1 GB/sec of write throughput. This is the headline number and it rules out any single-broker design.
  • Retention of 7 days: 1 GB/s × 86,400 s × 7~600 TB of log on disk (before replication). With replication factor 3, ~1.8 PB. The log lives on disk, not RAM — so sequential disk I/O performance is the whole ballgame.
  • Per-partition throughput caps at maybe ~10 MB/sec for ordered, replicated writes, so 1 GB/sec needs on the order of ~100+ partitions spread across brokers — partition count is how you buy throughput.
  • Consumers: if reads are mostly sequential from the tail of the log, the OS page cache serves recent messages from RAM, so a healthy cluster does almost no random disk reads — the access pattern is what makes the numbers work.

The key insight from the numbers: sequential append to disk plus partitioning is what turns a “slow” disk into a 1 GB/sec firehose, because sequential disk throughput rivals memory bandwidth while random I/O would be ~1000× slower.

High-level design

A topic is split into partitions; each partition is an ordered, append-only log living on a leader broker and replicated to follower brokers. Producers append to partitions (keyed or round-robin). Consumers in a consumer group divide the partitions among themselves and track an offset per partition.

Deep dive

Partitions: the unit of parallelism and ordering

A topic is divided into partitions, and the partition is the atom of the whole system. Three things follow from it:

  • Parallelism. Different partitions live on different brokers and are written/read independently, so throughput scales with partition count — this is how you get from one broker’s limit to 1 GB/sec.
  • Ordering is per-partition, not per-topic. Messages within a partition are strictly ordered by offset; across partitions there is no global order. So if you need ordered processing of a user’s events, you must route all of that user’s messages to the same partition — done by hashing a partition key (e.g. user_id). No key → round-robin → maximum spread but no ordering guarantee.
  • Consumer parallelism is capped by partitions. Within a consumer group, each partition is consumed by exactly one consumer, so you can have at most as many active consumers as partitions. Partition count is therefore both your throughput knob and your max consumer parallelism — chosen up front and awkward to change later.

Taken together, the partition is a single knob that buys you throughput, ordering, and consumer parallelism simultaneously — but only if you choose the partition key right. A wrong key (or none) and you lose ordering where you need it and create a hot partition that the rest of the cluster cannot help with.

Why this works

Why is ordering only guaranteed within a partition, and why does that force the partition-key decision? Because partitions are independent logs on different machines with no shared clock or coordinator — imposing a total order across them would require global coordination on every message, which is exactly the bottleneck partitioning exists to avoid. So the system offers the strongest order it can give cheaply: a strict per-partition offset order. The consequence lands on the producer: to keep a related sequence ordered (a single account’s debits and credits, a single device’s telemetry), you hash a stable key so all those messages land in one partition and are read in order by one consumer. Pick the wrong key — or none — and related events scatter across partitions and can be processed out of order, which for a ledger is a correctness bug, not a performance one. The key choice is where ordering semantics actually live.

Replication and the ISR

Each partition has one leader (takes all reads and writes) and several followers that replicate the leader’s log. Durability hinges on the in-sync replica (ISR) set: the followers that are currently caught up with the leader. A write is considered committed only once all ISR members have it, and acks controls how strict the producer is:

  • acks=0 — fire-and-forget; fastest, can lose data.
  • acks=1 — leader-only ack; loses data if the leader dies before followers replicate.
  • acks=all — every ISR member must have the message before it’s acknowledged; survives leader failure with no loss, as long as the ISR isn’t empty.

When the leader dies, a new leader is elected from the ISR, so it already has every committed message — no committed write is lost. The tunable min.insync.replicas sets how many replicas must be in-sync for an acks=all write to succeed; set it to 2 (with replication factor 3) and you survive one broker failure while still rejecting writes if durability would be compromised.

Edge cases

What happens if every follower falls behind and the ISR shrinks to just the leader, then the leader dies? You face the unclean-leader-election dilemma: either refuse to elect any leader (the partition goes offline — unavailable but never serving lost data), or allow an out-of-sync follower to become leader (the partition comes back available but silently drops every message the dead leader had that the new leader lacks). This is the CAP trade made concrete at the partition level: unclean.leader.election.enable=false chooses consistency/durability (stay down rather than lose data), true chooses availability (come back, accept data loss). A payments log sets it false; a metrics firehose might set it true. There is no third option — when the only in-sync copy is gone, you cannot have both a live partition and a complete log.

Offsets, consumer groups, and delivery semantics

Because the broker retains the log and never deletes on delivery, each consumer simply tracks an offset — the position of the next message to read — and commits it periodically. This is the engine behind three features:

  • Replay: reset the offset backwards and re-read (the hook’s fix — replay the fixed code over old messages).
  • Multiple consumer groups: each group has its own offsets, so the fraud scorer, ledger, and analytics each read the full stream independently at their own pace.
  • Failure recovery: a crashed consumer resumes from its last committed offset.

But when you commit the offset relative to when you process the message defines the delivery semantics — the single most important correctness decision in any async system:

  • At-most-once: commit the offset before processing. If the consumer crashes mid-process, the message is skipped on restart — possible loss, no duplicates. (Acceptable for metrics.)
  • At-least-once: commit the offset after processing. If the consumer crashes after processing but before committing, the message is re-read and reprocessed — possible duplicates, no loss. (The common default.)
  • Exactly-once: no duplicates and no loss. Genuinely hard — it requires either idempotent processing (the consumer dedupes by message ID so reprocessing is harmless) or a transaction that atomically commits the offset and the side effect together. Kafka offers transactional producers/consumers for this, but most systems achieve “effectively once” with at-least-once delivery plus an idempotent consumer.
Pick the best fit

A payment-event consumer reads messages from a Kafka partition and calls a downstream ledger service on each. The consumer crashes mid-batch. Which delivery semantic and offset-commit strategy should you choose?

Bottlenecks & tradeoffs

  • Partition count is a one-way-ish decision. Too few and you cap throughput and consumer parallelism; too many and you pay coordination, file-handle, and rebalance overhead. Increasing partitions later breaks key-based ordering (a key may hash to a new partition), so you size generously up front.
  • Hot partition / skewed key. A bad partition key (e.g. keying by a country where one country dominates) sends most traffic to one partition and one consumer — the partitioned log’s version of a hot shard, and the whole cluster can’t help.
  • At-least-once means consumers must be idempotent. The common default delivers duplicates on retry, so every consumer that has side effects (charge a card, send an email) must dedupe by message ID — a requirement teams forget until they double-charge someone.
  • Rebalance storms. When a consumer joins or leaves a group, partitions are reassigned and consumption pauses; frequent membership churn (flapping consumers, long GC pauses) causes repeated rebalances that stall the group — so consumer liveness tuning is operationally important.
  • Retention vs cost. Long retention enables replay but costs petabytes; the window is a direct money-vs-recoverability trade, and “infinite retention for replay” is a real bill.
Recall before you leave
  1. 01
    Why is the queue a log rather than a mailbox, and what does that unlock?
  2. 02
    What does a partition give you, and why is ordering only per-partition?
  3. 03
    How do the ISR and offset-commit timing each determine a guarantee?
Recap

A distributed message queue of the Kafka shape rests on one reframing from the hook: the queue is a log, not a mailbox — append-only, retained for a window, and read by a consumer-tracked offset rather than deleted on delivery. That unlocks replay, multiple independent consumer groups, and crash recovery from the same structure. The estimate (1 GB/sec, ~600 TB over 7 days) forces the architecture: sequential disk append plus partitioning turns slow disk into a firehose, and partitions are the atom — they give parallelism (throughput scales with count), per-partition ordering (which is why a stable partition key decides where ordering semantics live), and the cap on consumer parallelism. Durability comes from replication and the ISR: with acks=all a write commits only when all in-sync replicas have it and a new leader is elected from the ISR, so no committed write is lost — and the unclean-leader-election dilemma is CAP made concrete (stay offline, or come back lossy). The hardest decision is delivery semantics, set by when you commit the offset: at-most-once (loss), at-least-once (duplicates), or exactly-once (idempotent consumer or atomic transaction) — and the pragmatic answer almost everywhere is at-least-once plus an idempotent consumer. The traps are the partition count you can’t easily change, the hot partition a bad key creates, rebalance storms, and the petabyte bill that long retention buys. Now when you see a message-queue design question — or an incident report where a consumer double-charged a customer — you know which lever to reach for first: what is the delivery semantic, and is the consumer idempotent?

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.