Skip to content

Distributed systems reasoning

advancedCommon atAnthropicGoogleAmazonMetaStripeRead firstHash mapsLoad balancing and consistent hashingBinary treesPractise in the Sandbox →

Sharding splits data so each machine holds a part; replication copies each part so a crash loses nothing. With N copies, writes acknowledged by W and reads that ask R, R + W > N forces every read to overlap the latest write. There is no shared clock, so order comes from logical clocks or ids with the time in the high bits, and replicas find their differences cheaply with Merkle trees: O(d log n) hash comparisons for d differing ranges instead of n.

Picture a library too big for one building. You split the books across branches by some rule (A–G here, H–M there): that’s sharding, also called partitioning. Then you keep copies of each branch’s books in two other branches, so a fire doesn’t lose anything: that’s replication.

Both create new problems that a single machine never has:

  • Which branch has the book? You need a rule that maps a key to a shard, and that rule must survive branches opening and closing.
  • Are the copies the same? Copies drift: one branch got the new edition, another hasn’t yet. Someone asking the wrong branch reads an old copy.
  • What happened first? Each branch has its own wall clock, and they disagree by milliseconds or more. “Latest” is not as simple as comparing timestamps.

Distributed-systems reasoning is mostly asking, for every operation: how many machines must agree, what if one is slow or dead, and what if two clocks disagree?

Five writes go out over time; reads happen in between. Each replica applies a write after its own network delay. Start with N=3, W=1, R=1 and run the clock: watch reads come back with an old version. Then raise W or R until R + W > N and the stale reads disappear. Click a replica name to crash it and see which operations are now refused.

Replicas, quorums and stale reads
run the clock
writes
1
2
3
4
5
reads
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
t
0
5
10
15
20
25
30
35
40
R + W2 ≤ N = 3
newest written–
newest acked–
stale reads0
refused ops0
has newest write / fresh readreplica lagging behindstale or refusedreplicas this read asked
Writes go out at t = 2, 9, 16, 23, 30; reads happen in between. Press Run, or step with →. Click a replica name to crash it.
t = 0

What to notice:

  • With W=1 the client hears “OK” as soon as the fastest replica has the write. The others are still behind (amber). A read that happens to ask only lagging replicas returns old data.
  • With W=2, R=2 on 3 replicas, any two replicas a read asks must include at least one of the two that acknowledged. The read can still see a slow replica, but it takes the highest version, so it’s never stale.
  • Crashing one replica under W=3 refuses every write. That’s the trade: bigger quorums mean fresher reads and lower availability.
The problem says… What to think about
“data is split across many shards / nodes” The partitioning rule, hot spots, and which queries must ask every shard.
“each shard can only answer simple questions” Every message costs; count round trips and design the protocol around them.
“replicas”, “eventually consistent”, “N copies” Quorums (R, W, N), what “acknowledged” means, and stale-read windows.
“unique ids without coordination”, “roughly time-ordered” Pack time in the high bits, a machine id in the middle, a counter at the bottom.
“find what differs between two copies cheaply” Hash trees (Merkle trees): compare top-down, descend only where hashes differ.
“which event happened first?” across machines Don’t trust wall clocks; use logical clocks or a single sequencer.

1. Partitioning: pick the key, then the rule

Section titled “1. Partitioning: pick the key, then the rule”

There are two basic rules for mapping a key to a shard:

Rule Good at Bad at
Range (keys a–g on shard 0…) Range scans (users ada–dee), ordered iteration Hot spots when writes cluster (timestamps, auto-increment ids)
Hash (hash(key) picks the shard) Spreading load evenly Range queries, which must ask every shard (scatter-gather)

Rebalancing matters as much as the rule. hash(key) % N reshuffles most keys when N changes; consistent hashing or a fixed number of many small partitions (say 256 partitions on 8 nodes, moved whole) keeps moves small.

Try the three rules on the same traffic. Every new write is stamped “today”, which is exactly how real time-series workloads behave.

Partitioning: where do writes and queries land?
interactive
Shard 0 holds days 0–7, … shard 3 the most recent days.
Write load from 400 new events (all happen today):
shard 0
0
shard 1
0
shard 2
0
shard 3
400
one user, all days
4 shards (scatter-gather)
users ada–dee (a range)
4 shards (scatter-gather)
everyone, last 3 days
1 shard
Every new write is "today", so one shard takes all of them while three sit idle: a hot spot. Range-by-time is great for "last 3 days" queries and terrible for write spread.

Hashing doesn’t fix one hot key: all writes for max still hash to one shard. The standard fix is salting, appending a small suffix (max#1…max#4) so writes spread; the cost is that reads for that key must ask all four sub-keys and merge.

  1. Every key lives on N replicas (often 3).
  2. A write is sent to all N and counted as successful once W of them confirm.
  3. A read asks R replicas and keeps the value with the highest version.
  4. If R + W > N, the read set and the write set must share at least one replica, so the read sees the latest acknowledged write. If not, it may miss it.

The overlap rule is just counting (pigeonhole), and you can check it by brute force over every possible write set and read set:

quorums_overlap.py
from itertools import combinations
def quorums_overlap(n: int, w: int, r: int) -> bool:
"""Brute-force check: does EVERY write set of size w share a replica with EVERY read set of size r?"""
replicas = range(n)
for write_set in combinations(replicas, w): # 1. every way a write can land
for read_set in combinations(replicas, r): # 2. every way a read can be served
if not set(write_set) & set(read_set): # 3. disjoint: this read can miss the write
return False
return True
def read_latest(versions: list[int], read_set: list[int]) -> int:
"""A quorum read asks r replicas and keeps the highest version it sees."""
return max(versions[i] for i in read_set) # 4. the newest copy wins
if __name__ == "__main__":
# The rule of thumb r + w > n matches the brute force for every small configuration.
for n in range(1, 7):
for w in range(1, n + 1):
for r in range(1, n + 1):
assert quorums_overlap(n, w, r) == (r + w > n), (n, w, r)
# n=3, w=2: version 7 reached replicas 0 and 1; replica 2 still has version 6.
versions = [7, 7, 6]
assert all(read_latest(versions, list(s)) == 7 for s in combinations(range(3), 2)) # r=2: always fresh
assert read_latest(versions, [2]) == 6 # r=1: can be stale
print("ok")

Common settings: N=3, W=2, R=2 (balanced); W=N, R=1 (fast reads, any dead node blocks writes); W=1, R=1 (fast and available, eventually consistent). Leader-based systems (one primary per shard, followers copy its log) get the same choice through synchronous vs asynchronous followers.

Wall clocks on different machines drift and get corrected by NTP, sometimes backwards. Two rules follow:

  • Measure durations with a monotonic clock (time.monotonic(), performance.now(), Stopwatch), never with wall-clock subtraction.
  • Don’t decide “which write wins” by comparing timestamps from different machines unless you accept that a skewed clock can silently drop a write (last-write-wins).

Logical clocks order events without real time. A Lamport clock is one counter per node: bump it on every event, and on receive set it to max(local, received) + 1. If a caused b, then L(a) < L(b). A vector clock keeps one counter per node and can also tell you when two events are concurrent, meaning neither saw the other, which is a genuine conflict to resolve:

  1. A local event bumps only your own entry.
  2. On receive, take the element-wise max with the sender’s clock, then bump your own entry.
  3. a happened before b exactly when every entry of a ≤ the same entry of b, and they’re not equal.
  4. If neither is ≤ the other, the events are concurrent.
vector_clock.py
from typing import Literal
Clock = dict[str, int] # node id -> number of events that node has seen from itself
Order = Literal["before", "after", "equal", "concurrent"]
def tick(clock: Clock, node: str) -> Clock:
"""1. A local event (or a send) bumps only this node's own entry."""
return {**clock, node: clock.get(node, 0) + 1}
def merge(local: Clock, received: Clock, node: str) -> Clock:
"""2. On receive: take the element-wise max, then count the receive as a local event."""
merged = {k: max(local.get(k, 0), received.get(k, 0)) for k in local.keys() | received.keys()}
return tick(merged, node)
def compare(a: Clock, b: Clock) -> Order:
"""3. a happened before b iff a <= b in every entry and a != b. Neither way round: concurrent."""
keys = a.keys() | b.keys()
a_le_b = all(a.get(k, 0) <= b.get(k, 0) for k in keys)
b_le_a = all(b.get(k, 0) <= a.get(k, 0) for k in keys)
if a_le_b and b_le_a:
return "equal"
if a_le_b:
return "before"
if b_le_a:
return "after"
return "concurrent" # 4. a real conflict: someone (app or user) must reconcile
if __name__ == "__main__":
a1 = tick({}, "A") # A writes {A:1}
b1 = merge({}, a1, "B") # B receives it {A:1, B:1}
a2 = tick(a1, "A") # A writes again, without hearing from B {A:2}
b2 = tick(b1, "B") # B writes {A:1, B:2}
assert compare(a1, b1) == "before"
assert compare(b1, a1) == "after"
assert compare(a2, b2) == "concurrent" # neither saw the other's latest write
a3 = merge(a2, b2, "A") # A hears B: now A has seen everything
assert compare(b2, a3) == "before" and compare(a2, a3) == "before"
print("ok")

Auto-increment ids need one machine to hand them out. Fleet-wide ids are usually built so any machine can generate them alone:

Scheme Layout Sortable by time? Notes
UUIDv4 122 random bits No Collisions practically impossible; random inserts scatter across a B-tree index.
UUIDv7 (RFC 9562) 48-bit Unix ms + random Yes, to the ms Same 128 bits, index-friendly.
ULID 48-bit ms + 80 random, base32 text Yes Popular text format before UUIDv7.
Snowflake (Twitter, 2010) 1 sign + 41 time + 10 machine + 12 sequence, in 64 bits Roughly Fits a long; up to 4096 ids per ms per machine; 41 bits of ms ≈ 69.7 years.

Drag the bit split and watch the trade: more worker bits mean more machines but fewer ids per millisecond, or a shorter lifetime. The lower half shows what clock skew does to “sorted by id”.

Anatomy of a time-ordered 64-bit id
drag the split
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
1
1
1
0
1
0
1
1
0
1
1
1
1
0
0
1
1
0
1
0
0
0
1
0
1
0
1
0
0
0
0
1
0
0
1
0
1
0
0
0
0
0
0
0
0
0
1
0
1
bit 63 (top-left) … bit 0 (bottom-right)
timestamp: 41 bitsworker: 10 bitssequence: 12 bitssign bit (kept 0)
id517815304081413
hex0x0001d6f345425005
workers1,024
ids / ms / worker4,096
lifetime69.7 years
Sorted by id = sorted by time? Four events happen in order e1 → e4 on two workers.
e1 · w1
true 1000 · stamped 1000
e2 · w2
true 1002 · stamped 1002
e3 · w1
true 1003 · stamped 1003
e4 · w2
true 1005 · stamped 1005
Sorting by id gives the true order. The timestamp sits in the high bits, so it dominates the comparison; worker and sequence only break ties within one millisecond.

Because the timestamp sits in the top bits, comparing ids compares times first. That’s why these ids work well as primary keys: new rows append at the end of the index. But they’re only as ordered as the clocks that stamped them, and a generator must decide what to do when its clock jumps backwards or its sequence for one millisecond runs out.

Replicas that miss writes must eventually repair. Comparing every key costs O(n) data over the network. A Merkle tree hashes each key range into a leaf, and each parent hashes its children. Two replicas compare root hashes; only where hashes differ do they descend. Amazon’s Dynamo and Apache Cassandra’s repair both use this.

Click keys to change replica B, then step through the comparison:

Merkle tree anti-entropy: find the diff, skip the rest
step through
change replica B's data:
replica A
7ee4k0=appledf7ak1=bread37ca20fak2=cocoacf77k3=datesf1c39bb4cefbk4=eggs6196k5=flour2e7494e9k6=grape4408k7=honey2c53e2c4b9e5
replica B (capitalised = changed)
7ee4k0=appledf7ak1=bread37ca20fak2=cocoacf77k3=datesf1c39bb4cefbk4=eggsa383k5=FLOUR4f3494e9k6=grape4408k7=honey2c53b6af886a
hash comparisons0
leaves (naive compare)8
ranges to repair–
hashes differ: descendhashes match: skip subtreediffering range foundnever looked at
Both replicas built the same tree shape over the same key ranges. Sync starts by exchanging just the root hashes.
1 / 9
Thing Cost Why
Quorum write / read latency of the W-th / R-th fastest replica You wait for the slowest member of the quorum, not the slowest of all.
Range query on hash partitioning ask all shards Neighbouring keys are scattered on purpose.
hash % N rebalance ~(N−1)/N of keys move Almost every key’s remainder changes.
Consistent-hash rebalance ~1/N of keys move Only the arc next to the new node changes owner.
Merkle sync, d differing leaves of n O(d log n) hash comparisons Each difference costs one root-to-leaf path; matching subtrees are skipped whole.
Merkle sync, everything differs O(n) No subtree can be skipped; no worse than comparing everything.
Vector clock O(number of nodes) per event and per message One counter per participant; grows with cluster size.

A useful rule when shards are remote: count messages, not CPU. A round trip to another machine costs around a millisecond in one data centre, millions of times more than a local operation, so the number of messages (and how many happen in parallel) usually decides the design.

  • Leader-follower replication. One leader per shard orders all writes; followers replay its log. Simple, and reads from followers can be stale (replication lag).
  • Multi-leader / leaderless. Writes accepted in several places; needs conflict resolution: last-write-wins, vector clocks, or CRDTs (data types whose merges always converge).
  • Consensus (Raft, Paxos). A majority quorum agrees on a single ordered log; this is how you get one true order, at the cost of a round trip to a majority per write.
  • Read-your-writes / monotonic reads. Weaker guarantees you can provide by routing a user to the same replica, or by tracking the version they last saw.
  • Two-level keys. Partition by one key, sort by another inside the partition (Cassandra’s partition key + clustering key): one user’s events by time on one shard.
  • Gossip. Nodes periodically swap state with a random peer; information spreads to everyone in O(log n) rounds.
  • Hot partitions. Timestamps or auto-increment ids as a range key send every write to the newest shard.
  • Treating “acknowledged” as “everywhere”. With W < N, some replicas don’t have the write yet.
  • Trusting wall clocks for order. NTP can step a clock backwards; last-write-wins then discards newer data.
  • Forgetting partial failure. A request can succeed on the server and time out on the client. Retries need idempotency.
  • Scatter-gather tail latency. A query that fans out to 100 shards is as slow as the slowest of the 100.
  • Ask: How big is the data and how many machines? Read-heavy or write-heavy? Is stale data acceptable, and for how long? What happens on a network partition: refuse, or serve possibly stale data?
  • Say out loud what each message costs and how many you send. “A write waits for 2 of 3 replicas, so its latency is the second-fastest replica’s, and a read fans out to 2.”
  • Name the trade-off you’re choosing: consistency vs availability during a partition (CAP), and latency vs consistency the rest of the time (PACELC).
  • Expect follow-ups: “A node dies mid-write, what does the client see?”, “Clocks go backwards by 2 seconds”, “Add a shard: how much data moves?”, “Two replicas disagree; how do you find out where, cheaply?”
Check yourself
pick one
N = 5 replicas. You want reads to always see the latest acknowledged write, and writes to keep working with 2 replicas down. Which setting works?
Check yourself
pick one
You shard an event log by range on the event's timestamp across 8 nodes. What goes wrong first?
Check yourself
pick one
Two replicas each hold 1,024 key ranges in a binary Merkle tree. Exactly one range differs. About how many hash comparisons does a top-down sync need?

These are classic exercises, not gym questions:

  • Simulate a Lamport clock across three processes exchanging messages and check that causally related events get increasing timestamps.
  • Given N, list every (R, W) pair that keeps reads fresh while tolerating f failed nodes for writes, then for reads.
  • Implement a G-Counter CRDT (one counter per node, merge by element-wise max) and show that merges in any order give the same total.
  • Design a URL shortener’s id scheme for 1,000 machines and 50,000 ids per second each: how many bits for each field, and how long until overflow?
  • Compute how many keys move when you go from 4 to 5 shards under hash % N versus a 256-slot fixed-partition table.