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.
code/sd7-scalable-infra/ — pure Python / sqlite3, runs with no setup.1 · Sharding — when one DB isn't enough essential
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
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).
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
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
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.
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?