Sharding, hashing & queues
The infrastructure of scale: sharding to grow writes, a consistent-hashing ring you build and measure, message queues with retry, and choosing SQL vs NoSQL — climbing beginner→expert in Python.
Replication (SD6) scales reads, but a single database still caps writes and total size. This chapter covers the four tools that break that ceiling: sharding (split data across DBs), consistent hashing (shard without chaos when servers change), message queues (decouple services), and choosing SQL vs NoSQL. Each is built or demonstrated in runnable Python, climbing from the basic idea to production nuance.
The words you'll hear (in plain terms):
| Term | What it actually means |
|---|---|
| sharding | splitting data across multiple databases by a key. |
| shard key | the column that decides which shard a row lives on (e.g. user_id). |
| consistent hashing | a way to map keys to servers so adding/removing one moves few keys. |
| message queue | a buffer that lets service A hand work to B without waiting. |
| SQL vs NoSQL | relational+ACID vs. scale+flexible-schema — a fit choice, not a rivalry. |
What you need before starting:
- Replication + CAP from SD6.
- Basic Python; the ring + queue demos are plain Python.
- Comfort with hashing (DSA D3) helps.
New to the topic? Read this box, then take the chapters in order — each section is tagged essential → expert so you always know the depth you're at.
Learning objectives
- Explain sharding and choose a good shard key.
- Implement consistent hashing and measure how few keys move on change.
- Use a queue to decouple producer/consumer; compare Kafka vs RabbitMQ.
- Choose SQL vs NoSQL from the access pattern.
30-second recap
- HLD: architecture & caching: The standard web shape: load balancer → stateless services → cache → database, with caching as the biggest performance lever.
- Scalability & CAP: Scale up (bigger machine) or out (more machines); replicas trade consistency for availability, and CAP says you can't have both during a partition.
code/sd7-scalable-infra/ — pure Python / sqlite3, runs with no setup.1 · Sharding — when one DB isn't enough essential
Here's the wall you eventually hit. You added a read replica when the dashboards got slow (that's SD6), and it worked beautifully — reads flew. Then the writes started backing up. Every replica in the world is a copy of the same single primary, and every insert, update, and delete still funnels through that one machine. One night the write queue grows faster than the disk can flush it, and inserts start timing out. You can't add another replica to fix this, because more copies don't give you more places to write. That is the moment you need sharding.
The everyday analogy is a single overflowing filing cabinet. Buying a photocopier and making duplicate cabinets (replication) lets more people read the files at once — but there's still one cabinet everyone must file into, and it's full. Sharding is the other move: you buy three cabinets and agree a rule — "surnames A–H go here, I–P there, Q–Z in the third." Now three people can file at the same time, and you have three times the drawer space. The rule that decides which cabinet a file belongs in is the shard key, and picking a good one is the whole art: choose "surname" and a run of Smiths overloads one cabinet; choose something that spreads evenly and every cabinet fills at the same rate.
The mistake almost everyone makes first is confusing sharding with replication — treating "add more servers" as one undifferentiated idea. They solve opposite problems. Replication answers "too many readers" by copying the same data everywhere; sharding answers "too many writes, too much data" by splitting different data across machines. A replica holds all the rows; a shard holds only its slice. Get this backwards in an interview and the whole design falls apart — and in production you'll scale the wrong axis and wonder why the write bottleneck never budges. The second mistake, which we're about to watch happen, is reaching for the obvious shard rule (hash(key) % N) without realising it quietly bakes the number of servers into every key's address — so the day you add a machine, almost every key needs to move.
Replication copies data (scales reads); sharding splits it (scales writes + storage). Each shard holds a slice, chosen by a shard key. The naive approach: hash(key) % N.
This picture explains sharding in three steps: instead of keeping everything in one database that eventually gets too big, you split the data across several smaller databases (called shards). Read it left to right.
- The left box (All data) is the starting problem: one database holding everything. It works until the data is too big for one machine to store or keep up with.
- The middle box (Shard by key) is the decision: you pick a shard key — one field like
user_id— and a rule that turns that key into a shard number. Every row goes to exactly one shard based on its key. - The right box (Shard 0 / 1 / 2) is the result: several independent slices. Each shard is a normal database holding only its share of the rows, so together they store far more and handle far more writes than one ever could.
- The arrows are the flow of a design decision, not a network call: you go from 'one big pile' → 'choose how to split' → 'many small piles'.
In short: Sharding scales writes and storage (each shard takes a fraction of the load); replication from SD6 scaled reads (copies of the same data). The hard part is picking the shard key — the next code lab shows the naive way and why it breaks.
modulo_shard.pydef shard_of(key, n):
return hash(key) % n
keys = ["alice", "bob", "carol", "dave", "eve"]
before = {k: shard_of(k, 3) for k in keys}
print("with 3 shards:", before)
after = {k: shard_of(k, 4) for k in keys} # add ONE shard...
moved = sum(before[k] != after[k] for k in keys)
print(f"added a shard -> {moved}/{len(keys)} keys moved") # almost all! disaster
with 3 shards: {'alice': 0, 'bob': 2, 'carol': 2, 'dave': 0, 'eve': 1}
added a shard -> 4/5 keys moved
This is the simplest possible way to decide which shard a key belongs to: hash the key to a number, then take the remainder when dividing by the number of shards (% n). It works — until you add or remove a shard, which this lab deliberately shows going wrong.
shard_of(key, n)turns any key into a shard number.hash(key)scrambles the key into a big integer, and% n(the modulo or remainder operator) squeezes that integer into the range0 … n-1— a valid shard number fornshards.beforeis a dictionary built with a comprehension: for each name inkeys, it records which shard that name lands on with 3 shards.afterrecomputes the same thing but with 4 shards — we added one shard. Because% 3and% 4give completely different remainders, most keys now point at a different shard.movedcounts how many keys changed shard.before[k] != after[k]isTrue(which counts as 1) whenever a key moved, andsum(...)adds up those 1s.
What the output means: With 3 shards each name has a shard number; after adding one shard, 4/5 keys moved. In a real system 'moved' means physically copying that data to another server and invalidating caches — a near-total reshuffle just to add one machine.
Try this: Change the last argument from 4 back to 3 and re-run — 0 keys move because nothing changed. Then try % 5: still almost everything moves. That instability is the whole reason consistent hashing exists.
% N fails in productionChange N (add/remove a server) and almost every key remaps — a cache stampede and a massive data migration. Consistent hashing (next) fixes exactly this.2 · Consistent hashing — shard without chaos intermediate
Picture the pain the modulo scheme just showed you, but at 3am and at real scale. You have a 100-node cache serving live traffic, one node dies, and your code does hash(key) % N to find things. The instant N drops from 100 to 99, the remainder changes for almost every key — so nearly every lookup now points at the wrong node, finds nothing, and falls through to the database. All of them. At once. The database, sized to handle the trickle of cache misses, gets the entire read load in one wave and falls over too. One dead cache node just took down your database. That cascade has a name — a cache stampede — and it's exactly what consistent hashing was invented to prevent.
Here's the mental shift that fixes it. Instead of a formula that depends on how many servers exist, imagine a giant clock face — a ring of numbers that wraps around from the top back to zero. You hash each server to a spot on that clock, and you hash each key to a spot too. To find where a key lives, you start at the key's position and walk clockwise until you bump into a server; that server owns the key. The beauty is what happens when a server vanishes: only the keys sitting in the arc just before it need a new home (they walk clockwise to the next server), and every other key on the ring is completely undisturbed. Add a server and it's the mirror image — it slots into one arc and steals only the keys in that slice, roughly 1/N of them, instead of shuffling the whole deck.
The subtle trap is assuming a plain ring automatically balances load. It doesn't — if you drop three servers onto random clock positions, one might land right after a huge empty arc and get swamped while another sits nearly idle. The fix is virtual nodes (vnodes): instead of placing each physical server at one point, you place it at many points around the ring (100 in the lab below), under names like s1:0, s1:1, and so on. Now each server's territory is chopped into 100 small arcs scattered evenly around the circle, so the law of averages gives everyone a fair share — and when a node dies, its keys get redistributed across all the survivors rather than dumped entirely on its single clockwise neighbour. This is not academic: it's the literal backbone of Amazon DynamoDB, Apache Cassandra, and most distributed caches. Watch the numbers in the lab — adding a server moves ~1/4 of keys, not ~all of them.
Consistent hashing places servers and keys on a ring; a key belongs to the next server clockwise. Add/remove a server and only the keys near it move — the backbone of distributed caches and databases (Cassandra, DynamoDB).
s4 onto the ring steals only the keys in its arc (~1/N) — everything else stays exactly where it was.
Read it as a clock face: both servers and keys sit at hashed positions on the circle, and to place a key you start at its dot and walk clockwise until you hit a server.
- The left ring has three servers (
s1,s2,s3) as blue boxes on the circle. The two amber dots are keys; follow the blue arrow clockwise and each key is owned by the next server it reaches. - The right ring shows what happens when you add
s4: the thick green arc is the only region that changes hands — just the keys that now fall closest to the new server, roughly1/Nof them. - The payoff is everything outside that green arc: those keys never move. Compare that to modulo sharding, where changing N reshuffled almost every key at once.
In short: A key lives on the first server clockwise, so adding a server disturbs only one arc (~1/N of keys) instead of the whole ring — and virtual nodes chop each server into many arcs so the load stays even.
consistent_hash.pyimport hashlib, bisect
class HashRing:
def __init__(self, nodes=(), vnodes=100):
self.vnodes = vnodes; self._ring = {}; self._sorted = []
for n in nodes: self.add(n)
def _h(self, key): return int(hashlib.md5(key.encode()).hexdigest(), 16)
def add(self, node):
for i in range(self.vnodes):
h = self._h(f"{node}:{i}"); self._ring[h] = node; bisect.insort(self._sorted, h)
def remove(self, node):
for i in range(self.vnodes):
h = self._h(f"{node}:{i}"); del self._ring[h]; self._sorted.remove(h)
def get(self, key):
h = self._h(key); i = bisect.bisect(self._sorted, h) % len(self._sorted)
return self._ring[self._sorted[i]]
ring = HashRing(["s1", "s2", "s3"])
keys = [f"user{i}" for i in range(1000)]
before = {k: ring.get(k) for k in keys}
ring.add("s4") # scale out
moved = sum(before[k] != ring.get(k) for k in keys)
print(f"added s4 -> {moved}/1000 keys moved (~1/4, not all)")
added s4 -> 251/1000 keys moved (~1/4, not all)
This builds a consistent-hashing ring — the fix for the modulo problem above. Imagine a clock face numbered all the way around. Both servers and keys get a position on that circle; a key is stored on the first server you meet going clockwise. Adding a server only steals the keys in one arc, so almost everything stays put.
_h(key)is the hash function: it runs the key throughmd5and turns the result into a huge integer — that integer is the key's position on the ring.add(node)places a server on the ringvnodestimes (here 100), under names likes1:0,s1:1, … These extra copies are virtual nodes; scattering each server across many points spreads the load evenly.bisect.insortkeeps the list of positions sorted so we can search it fast.get(key)is the lookup: hash the key to a position, thenbisect.bisectfinds the next position clockwise in the sorted list. The% len(...)wraps around from the top of the ring back to the start (that's the 'circle' part).- The test builds a ring of 3 servers, records where 1000 keys land, then
ring.add("s4")adds a fourth server and counts how many keys moved.
What the output means: About 251/1000 keys moved — roughly 1/4, i.e. only the keys that now fall closest to the new server s4. Compare that to the ~100% reshuffle with % N: same goal, dramatically less data movement.
Try this: Lower vnodes from 100 to 1 and re-run a few times — the 'moved' count and the balance across servers get much noisier. That shows why virtual nodes matter: more points per server means smoother, more even load.
vnodes), so load spreads evenly and removing a node redistributes its keys across all others. Adding the Nth node moves only ~1/N of keys — vs nearly 100% for modulo.3 · Message queues — decouple & absorb bursts advanced
Think about the last time you uploaded a photo and the app said "we'll email you when it's ready." It didn't freeze your screen for thirty seconds while it resized the image, ran it through a model, and generated thumbnails. It took your file, said "got it," and let you get on with your life — the actual heavy work happened later, somewhere else. That "got it, we'll handle it" moment is a message queue doing its job. The alternative — the app calling the image service directly and making you wait for it — is how you build something that feels slow when it works and collapses entirely when the image service has a bad minute.
The analogy that makes it click is a restaurant. Waiters (producers) don't cook — they take orders, clip the tickets to a rail in the kitchen window, and immediately go serve the next table. Cooks (consumers) pull tickets off the rail at their own pace. That humble ticket rail is a queue, and it buys the restaurant three things it couldn't get if waiters had to stand at the stove until each dish was done. First, decoupling: waiters and cooks work independently — neither waits on the other. Second, absorbing bursts: when a coach party arrives and forty orders hit at once, the tickets simply stack up on the rail instead of the dining room grinding to a halt. Third, retries and safety: if a cook drops a plate, the ticket is still there — the order isn't lost, it just gets remade.
The mistake to unlearn is thinking a queue is "just a list you push to." The value isn't the buffer itself — it's the durability and the delivery guarantees wrapped around it. A real queue (Kafka, RabbitMQ, SQS) persists messages to disk so a crash doesn't vaporise the backlog, tracks which messages have been acknowledged so a consumer dying mid-job doesn't silently drop work, and re-delivers anything that wasn't confirmed done. That last property is why queues give you at-least-once delivery — and its flip side, the trap juniors fall into: because a message can be delivered more than once (a consumer processed it but crashed before acknowledging), your consumers must be idempotent — handling the same message twice must be safe. "Charge the customer" as a queue handler, written naively, will double-bill someone. Build the retry in from the start, and route the message that fails every retry to a dead-letter queue so one poison message can't jam the line forever — you'll build exactly that in the practice ladder. The lab below is this whole shape in ~20 lines of standard-library Python.
Instead of A calling B directly (and failing if B is down/slow), A puts a message on a queue and B consumes it when ready. This decouples producer from consumer, absorbs spikes, and enables retries.
This shows what a message queue does: it sits between the part of your system that creates work and the part that does the work, so neither has to wait for the other. Read it left to right.
- The left box (Producer) is any code that emits work — for example 'resize this uploaded image' or 'send this email'. It drops a message describing the job and immediately moves on; it does not wait for the job to finish.
- The middle box (Queue) is the buffer (Kafka or RabbitMQ in production). It stores messages durably — safely, in order — until someone is ready to handle them. If work suddenly spikes, messages simply pile up here instead of crashing anything.
- The right box (Consumer) is the worker that pulls messages off the queue and processes them at its own pace. You can run several consumers to go faster.
- The arrows are messages flowing one way: producer → queue → consumer. This separation is called decoupling — the producer keeps working even if the consumer is slow or temporarily down.
In short: A queue buys you three things at once: decoupling (the two sides don't call each other directly), absorbing bursts (spikes queue up instead of overloading), and retries (a failed job can go back on the queue). The next lab builds this in plain Python.
queue_demo.pyimport queue, threading
work = queue.Queue() # stands in for Kafka/RabbitMQ
processed, failed = [], []
def producer():
for i in range(6): work.put(f"job-{i}")
work.put(None) # sentinel = done
def consumer():
while True:
job = work.get()
if job is None: break
try:
if job == "job-3": raise RuntimeError("transient") # simulate a failure
processed.append(job)
except Exception:
work.put(job.replace("job", "retry")) # re-queue for retry
failed.append(job)
finally:
work.task_done()
t = threading.Thread(target=consumer); t.start()
producer(); t.join()
print("processed:", processed)
print("retried:", failed)
processed: ['job-0', 'job-1', 'job-2', 'job-4', 'job-5', 'retry-3']
retried: ['job-3']
This is a working message queue in ~20 lines of plain Python — no Kafka needed to learn the shape. A producer thread drops jobs onto a queue; a consumer thread pulls them off and processes them, retrying the one that fails. It's the diagram above, made runnable.
work = queue.Queue()creates a thread-safe queue — Python's built-in stand-in for Kafka/RabbitMQ.processedandfailedare lists that record what happened, so we can print the result at the end.producer()puts six jobs (job-0 … job-5) on the queue, then putsNone. ThatNoneis a sentinel — an agreed 'no more work' signal that tells the consumer when to stop.consumer()loops forever callingwork.get()to take the next job. If it sees theNonesentinel itbreaks out and stops.- The
try/exceptis the retry logic:job-3deliberately raises an error to simulate a transient failure. On failure we re-queue it asretry-3(work.put(...)) and note it infailed; on success we add it toprocessed.work.task_done()in thefinallytells the queue that item is handled either way. - At the bottom the consumer runs on its own
threading.Threadso it works at the same time as the producer;t.join()waits for it to finish before printing.
What the output means: processed lists jobs 0,1,2,4,5 plus retry-3 — the failed job succeeded on its second attempt. retried shows job-3 failed once. That's the queue's superpower: a temporary failure doesn't lose the work.
Try this: Add a second failing job (e.g. also raise on job-5) and watch both get retried. This retry-on-a-queue pattern is exactly how real systems survive flaky downstream services.
| Kafka | RabbitMQ | |
|---|---|---|
| Model | durable, replayable log | smart broker with routing |
| A message is | read by many consumer groups | usually consumed once |
| Best for | event streams, analytics, replay | task queues, RPC-style work |
4 · Expert — SQL vs NoSQL, decided by data expert
Somewhere right now a team is rewriting a perfectly good PostgreSQL app onto a NoSQL database because a conference talk said SQL "doesn't scale." Six months later they're hand-rolling joins in application code, discovering their "eventually consistent" store let a customer's balance go negative, and quietly missing the transactions they threw away. This is the single most expensive mistake in this chapter — not a bug, but a whole architecture chosen for the wrong reason. SQL versus NoSQL is not a fight where one side wins; it's a fit question, and the answer is written in your data, not in the hype cycle.
The honest way to see it: these are two different tools that gave up different things to be good at different jobs. A SQL database is like a meticulous accountant — it enforces relationships between tables, lets you ask complex questions across them with joins, and wraps changes in ACID transactions so "move $100 from A to B" either fully happens or fully doesn't, never half. That rigour is priceless for money, orders, and anything where a wrong answer is a real-world disaster. A NoSQL store is more like a vast, simple set of labelled lockers — hand it a key, it hands back a blob, blindingly fast, and you can add lockers (shards) forever. It got that horizontal scale and schema flexibility by giving up the accountant's guarantees: cross-locker joins and multi-key transactions largely aren't on the menu. Neither is "better." They're bets on what your workload needs most.
The mistake, spelled out so you never make it: choosing by fashion or by a single scary word ("scale") instead of by your actual access pattern — the concrete way your app reads and writes. Ask what you truly need: Do rows relate to each other and get queried together (a payments ledger, an inventory system)? Lean SQL. Are you firehosing simple, independent records at enormous volume and only ever looking them up by one key (event logs, session stores, feature flags)? That's the NoSQL sweet spot. And know the default: start with SQL until a specific pattern forces you off it — it scales far further than its reputation suggests (read replicas from SD6, plus the sharding from §1), and losing joins and transactions is a cost you pay forever. Most real systems land on "SQL for the core data that must be correct, plus a key-value cache for the hot lookups." The lab makes the NoSQL shape concrete: a tiny key-value store sharded across nodes by the very ring you built in §2 — fast, horizontally scalable, and pointedly unable to do a cross-shard join, which is precisely the trade you're agreeing to.
Not a rivalry — a fit question decided by access pattern. SQL gives relations, joins, and ACID; NoSQL trades some of those for scale and flexible schemas. Let's make the trade-off concrete with a tiny key-value store (the NoSQL shape) sharded by the ring above.
Read it as two panels side by side — the same job (store data) solved with two opposite trade-offs. The left is SQL, the right is NoSQL.
- The left panel (SQL) is one logical database that keeps two green powers: joins across tables and ACID transactions. That's what makes money, orders, and ledgers correct — and it scales up (bigger box, plus replicas and sharding).
- The right panel (NoSQL) is a row of independent lockers (shards): hand it a key, it hands back a blob, blindingly fast, and you add lockers to grow. The red band is the price — no joins or transactions across those shards.
- The payoff is the bottom line of each panel: SQL scales up and stays correct; NoSQL scales out and stays fast. Neither is better — you choose by what your data actually needs.
In short: SQL trades scale for joins + ACID; NoSQL trades joins + ACID for horizontal scale. The lab below builds the NoSQL shape — a key-value store sharded by the ring, deliberately unable to join across shards.
sharded_kv.pyimport hashlib, bisect
class HashRing:
def __init__(self, nodes=(), vnodes=50):
self.vnodes=vnodes; self._ring={}; self._sorted=[]
for n in nodes: self.add(n)
def _h(self,k): return int(hashlib.md5(k.encode()).hexdigest(),16)
def add(self,node):
for i in range(self.vnodes):
h=self._h(f"{node}:{i}"); self._ring[h]=node; bisect.insort(self._sorted,h)
def get(self,key):
h=self._h(key); i=bisect.bisect(self._sorted,h)%len(self._sorted)
return self._ring[self._sorted[i]]
class ShardedKV:
"""Fast key-lookup store, horizontally sharded — the NoSQL sweet spot.
No joins/transactions across shards; that's the trade for scale."""
def __init__(self, nodes):
self.ring = HashRing(nodes)
self.shards = {n: {} for n in nodes}
def put(self, key, value): self.shards[self.ring.get(key)][key] = value
def get(self, key): return self.shards[self.ring.get(key)].get(key)
def where(self, key): return self.ring.get(key)
kv = ShardedKV(["db0", "db1", "db2"])
for i in range(6): kv.put(f"user:{i}", {"name": f"u{i}"})
print("user:3 ->", kv.get("user:3"), "on", kv.where("user:3"))
print("distribution:", {n: len(s) for n, s in kv.shards.items()})
user:3 -> {'name': 'u3'} on db1
distribution: {'db0': 2, 'db1': 2, 'db2': 2}
This ties everything together: a tiny key-value store (the shape most NoSQL databases take) that spreads its data across shards using the consistent-hashing ring from earlier. It's a working model of how systems like Cassandra and DynamoDB place data.
- The
HashRingclass here is the same ring as before, trimmed down — it maps any key to one of the shard names viaget(key). ShardedKVholds two things: aring(which decides where a key lives) andshards, a dictionary of dictionaries — one small store per node. The docstring names the trade-off: fast key lookups and easy scaling, but no joins or transactions across shards.put(key, value)asks the ring which shard the key belongs to, then stores the value there.get(key)asks the same question and reads it back — so a key always writes and reads on the same shard.where(key)just reports which shard that is.- The demo writes six users, then looks up
user:3and prints how many keys landed on each shard.
What the output means: user:3 is found on db1, and the distribution is {db0: 2, db1: 2, db2: 2} — an even split, thanks to the ring's virtual nodes. That even spread is exactly what you want: no single shard is a hotspot.
Try this: Add "db3" to the node list and re-run — the distribution rebalances across four shards. This is the NoSQL bet: give up cross-shard joins, and in return scale horizontally just by adding nodes.
| Need | Lean | Why |
|---|---|---|
| complex queries, joins, transactions | SQL | relational integrity + ACID |
| massive scale, simple key lookups | NoSQL KV/doc | horizontal scale, flexible schema |
| high write throughput, wide rows | NoSQL (Cassandra) | tunable consistency, sharded by design |
| strong consistency + relations | SQL / NewSQL | correctness guarantees |
Exercise SD7.1 — Rebalance + route
Context: A sharded infrastructure task ties the ring, the queue, and the routing together — and asks you to reason about what a naive modulo scheme would have broken when a shard dies.
Your task: Extend the consistent-hashing ring to report the exact percentage of keys that move when you remove a node, combine the message queue with a sharded KV store so each job writes to the shard its key maps to, and explain in a paragraph what breaks if % N had been used instead when a shard dies.
Requirements:
- Report the fraction of keys that relocate on node removal (should be about 1/N)
- Route each queued job to the shard its key maps to on the ring
- Have the sharded KV store persist each job's write on the correct shard
- Explain why
% Nwould remap almost all keys when a shard dies - Contrast that mass migration with the ring's localized ~1/N movement
💡 Hint: Removal is the mirror of the add case — count keys whose node changed after taking one out; the paragraph should center on the cascade of remapping that % N forces when N changes.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Sharding splits data across multiple databases so no single box holds it all. The starting point is hashing a key to pick a shard, and the goal is an even spread so no shard becomes a hotspot.
Your task: Route keys to one of N shards by hashing, and show that over many keys the distribution across shards is roughly even.
Requirements:
- Compute a shard as
hash(key) % n_shards - Route a large number of keys and tally how many land on each shard
- Show the counts are roughly balanced across the shards
- Explain that even distribution avoids a hotspot shard
- Note the catch: changing
nreshuffles almost everything, which motivates consistent hashing
💡 Hint: A Counter over the shard of each key demonstrates the balance; the modulo is fine here but is exactly what the next rung shows breaking on resize.
Show solution
Modulo sharding is the starting point:
def shard_of(key, n_shards):
return hash(key) % n_shards
from collections import Counter
counts = Counter(shard_of(f"user{i}", 4) for i in range(10000))
print(dict(sorted(counts.items())))
# roughly even, e.g. {0: 2504, 1: 2470, 2: 2512, 3: 2514}
Even distribution is the goal so no single shard is a hotspot. The catch: % n reshuffles almost everything when n changes — which motivates consistent hashing.
Context: Modulo sharding's fatal flaw appears on resize: because the divisor changes, almost every key remaps. Quantifying that churn is what makes the case for consistent hashing concrete.
Your task: Show the cost of % n when you add a shard by counting how many keys move when going from 4 shards to 5.
Requirements:
- Assign each key a shard under
n = 4and again undern = 5 - Count the keys whose shard assignment changed between the two
- Report the fraction that moved (expect roughly 80%)
- Explain that this churn is a massive data migration for adding one shard
- Motivate consistent hashing as the technique that makes the churn ~1/n instead
💡 Hint: Compare where(k, 4) against where(k, 5) for every key; the near-total reshuffle is the whole reason the plain modulo approach doesn't scale.
Show solution
Measuring the churn makes the problem concrete:
keys = [f"user{i}" for i in range(10000)]
def where(key, n): return hash(key) % n
moved = sum(1 for k in keys if where(k, 4) != where(k, 5))
print(f"{moved}/{len(keys)} keys move = {moved/len(keys):.0%}")
# ~8000/10000 = ~80% move <-- almost everything reshuffles
Adding one shard remaps ~80% of keys — a massive data migration. Consistent hashing exists precisely to make this ~1/n instead.
Context: Consistent hashing solves the resize problem: adding or removing a node moves only about 1/n of the keys instead of nearly all of them. A ring of hashed points with virtual nodes is the standard construction.
Your task: Build a consistent-hashing ring with virtual nodes and show that adding a node moves far fewer keys than modulo sharding did.
Requirements:
- Hash each node into multiple virtual points placed around a sorted ring
- Map a key to the first node clockwise from the key's hash
- Record every key's node, add a new node, and re-map
- Count the keys that moved and show it is near the ideal 1/(n+1), not ~80%
- Explain that virtual nodes keep the load balanced across real nodes
💡 Hint: Keep the ring's hashed points sorted and use bisect to find the next node clockwise; virtual nodes (many points per real node) are what smooth out the distribution.
Show solution
A sorted ring of hashed points; a key goes to the next node clockwise:
import hashlib, bisect
class HashRing:
def __init__(self, nodes, vnodes=100):
self.vnodes = vnodes
self.ring = {} # hash -> node
self.sorted = []
for n in nodes: self.add(n)
def _h(self, s):
return int(hashlib.md5(s.encode()).hexdigest(), 16)
def add(self, node):
for v in range(self.vnodes):
h = self._h(f"{node}#{v}")
self.ring[h] = node
bisect.insort(self.sorted, h)
def get(self, key):
h = self._h(key)
i = bisect.bisect(self.sorted, h) % len(self.sorted)
return self.ring[self.sorted[i]]
keys = [f"user{i}" for i in range(10000)]
r1 = HashRing(["a", "b", "c", "d"])
before = {k: r1.get(k) for k in keys}
r1.add("e")
moved = sum(1 for k in keys if before[k] != r1.get(k))
print(f"{moved}/{len(keys)} keys move = {moved/len(keys):.0%}")
# ~1900/10000 = ~19% (near the ideal 1/5), vs ~80% for modulo
Adding a fifth node moves only ~1/5 of keys — the whole point of consistent hashing. Virtual nodes keep the load balanced.
Context: Message queues decouple producers from consumers and absorb bursts. Reliable async processing needs at-least-once delivery: retry failures a bounded number of times, then route the poison message to a dead-letter queue.
Your task: Implement an in-memory queue with at-least-once delivery that retries a failing message up to N times and then routes it to a dead-letter queue.
Requirements:
- Track each message with an attempt count
- On a handler failure, requeue the message if attempts remain
- Route a message to the dead-letter queue once it exhausts its retries
- Keep processing other messages rather than blocking on the failure
- Show a poison message landing in the DLQ with its attempt count after the good messages succeed
💡 Hint: Increment attempts on each dequeue and branch on whether they've hit the max; the DLQ is what stops one bad message from blocking the queue forever.
Show solution
Retry + DLQ is the backbone of reliable async processing:
from collections import deque
class Queue:
def __init__(self, max_retries=3):
self.q = deque(); self.dlq = []; self.max_retries = max_retries
def publish(self, msg): self.q.append({"msg": msg, "attempts": 0})
def consume(self, handler):
while self.q:
item = self.q.popleft()
item["attempts"] += 1
try:
handler(item["msg"])
except Exception:
if item["attempts"] < self.max_retries:
self.q.append(item) # retry later
else:
self.dlq.append(item) # give up -> DLQ
q = Queue(max_retries=2)
for m in ["ok1", "BAD", "ok2"]: q.publish(m)
def handler(m):
if m == "BAD": raise ValueError("boom")
q.consume(handler)
print("dlq:", [i["msg"] for i in q.dlq], "attempts:", q.dlq[0]["attempts"])
# dlq: ['BAD'] attempts: 2
The poison message lands in the DLQ after exhausting retries instead of blocking the queue forever — the pattern that keeps consumers healthy.
Context: Sizing a consumer pool is queueing arithmetic. Given the arrival rate and per-message service time you can compute how many consumers keep up, and how quickly a burst backlog drains.
Your task: Given an arrival rate and a per-message service time, compute the number of consumers needed to keep up at a target utilization, and how long a given backlog takes to drain.
Requirements:
- Compute each consumer's throughput as
1 / service_time - Size consumers so arrival rate stays under a target utilization of pool capacity
- Round the consumer count up to a whole number
- Compute drain time as backlog divided by the net drain rate (capacity minus arrivals)
- Recognize that if capacity does not exceed arrivals the backlog grows without bound
💡 Hint: Keep utilization below the target (e.g. 70%) so there's slack for variance; if capacity - arrival_rps ≤ 0 the drain time is infinite — the signal to add consumers.
Show solution
Offline queueing math to right-size workers:
import math
def consumers_needed(arrival_rps, service_time_s, target_util=0.7):
# each consumer handles 1/service_time msgs/s; keep utilization under target
per_consumer = 1 / service_time_s
return math.ceil(arrival_rps / (per_consumer * target_util))
def drain_time_s(backlog, arrival_rps, consumers, service_time_s):
capacity = consumers / service_time_s # msgs/s the pool can process
net = capacity - arrival_rps # drain rate
return float("inf") if net <= 0 else backlog / net
n = consumers_needed(arrival_rps=500, service_time_s=0.05)
print("consumers:", n) # 36
print("drain 10k backlog:",
round(drain_time_s(10000, 500, n, 0.05), 1), "s") # ~44.9 s
36 consumers keep up with 500 rps at 70% utilization and drain a 10k backlog in ~45 s. If net ≤ 0 the backlog grows forever — the signal to add consumers.
Context: Choosing SQL versus NoSQL should follow the data's shape and access patterns, not fashion. Strong transactions and rich relations point to SQL; massive write scale, simple key access, and schema churn point to NoSQL.
Your task: Encode a decision function that weighs a workload's needs and returns SQL or NoSQL with the reasons behind the verdict.
Requirements:
- Score in favour of SQL for multi-row transactions and complex joins
- Score in favour of NoSQL for flexible schema, massive write scale, and simple key access
- Return a defensible verdict from the accumulated score
- Return the list of reasons that drove the verdict
- Show a payments ledger scoring toward SQL and an event firehose scoring toward NoSQL
💡 Hint: Accumulate a signed score with a reason string per factor, then read the sign for the verdict; the reasons are what you defend in a design review.
Show solution
Let the data's requirements pick the store, not fashion:
def choose_store(needs):
reasons = []
score_sql = 0
if needs.get("multi_row_transactions"): score_sql += 2; reasons.append("ACID txns -> SQL")
if needs.get("complex_joins"): score_sql += 2; reasons.append("relational joins -> SQL")
if needs.get("flexible_schema"): score_sql -= 1; reasons.append("schema churn -> NoSQL")
if needs.get("massive_write_scale"): score_sql -= 2; reasons.append("write scale -> NoSQL")
if needs.get("simple_key_access"): score_sql -= 1; reasons.append("key lookups -> NoSQL")
verdict = "SQL" if score_sql >= 0 else "NoSQL"
return verdict, reasons
print(choose_store({"multi_row_transactions": True, "complex_joins": True}))
# ('SQL', ['ACID txns -> SQL', 'relational joins -> SQL'])
print(choose_store({"massive_write_scale": True, "simple_key_access": True,
"flexible_schema": True}))
# ('NoSQL', ['schema churn -> NoSQL', 'write scale -> NoSQL', 'key lookups -> NoSQL'])
A payments ledger scores toward SQL (transactions, joins); an event firehose scores toward NoSQL (write scale, key access) — the verdict follows the data, with reasons you can defend in review.
✓ Checkpoint — you can move on when you can…
- Explain sharding and why modulo remapping fails.
- Implement consistent hashing and measure keys moved on change.
- Use a queue with retry; compare Kafka vs RabbitMQ.
- Choose SQL vs NoSQL from the access pattern; shard a KV store.
Knowledge check check yourself
The lesson distinguishes sharding from replication and shows why naive hash(key) % N sharding fails. What does sharding scale (versus replication), and what goes wrong with modulo sharding when you add or remove a shard?
Show answer
hash(key) % N, changing N (adding/removing a server) changes the remainder for almost every key, so nearly all keys remap — causing a cache stampede and a massive data migration just to add one machine.In the consistent-hashing ring, adding a 4th server moved only about 251/1000 keys (~1/4) instead of nearly all of them. How does the ring decide where a key lives, and what role do virtual nodes (vnodes) play?