AI EngineeringZero to ProductionHome·About·Contact
System Design · Chapter SD7

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.

⏱️ ~3 hours🧪 4 labs🎯 Beginner→Expert
🌱 Start here — from zero Scalable infrastructure, from scratch — the pieces that take a system from "one big database" to "handles the whole internet."

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):

TermWhat it actually means
shardingsplitting data across multiple databases by a key.
shard keythe column that decides which shard a row lives on (e.g. user_id).
consistent hashinga way to map keys to servers so adding/removing one moves few keys.
message queuea buffer that lets service A hand work to B without waiting.
SQL vs NoSQLrelational+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 essentialexpert 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.
▶ Runnable companionEvery code block here is also saved under 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.

All data too big for one Shard by key the key choice Shard 0 / 1 / 2 independent slices
🗺️ How to read this diagram

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.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Step 1 · Naive modulo sharding (and its flaw)
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
▶ How this works

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.

  1. 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 range 0 … n-1 — a valid shard number for n shards.
  2. before is a dictionary built with a comprehension: for each name in keys, it records which shard that name lands on with 3 shards.
  3. after recomputes the same thing but with 4 shards — we added one shard. Because % 3 and % 4 give completely different remainders, most keys now point at a different shard.
  4. moved counts how many keys changed shard. before[k] != after[k] is True (which counts as 1) whenever a key moved, and sum(...) 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.

Why % 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).

Step 2 · A consistent-hashing ring
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)
▶ How this works

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.

  1. _h(key) is the hash function: it runs the key through md5 and turns the result into a huge integer — that integer is the key's position on the ring.
  2. add(node) places a server on the ring vnodes times (here 100), under names like s1:0, s1:1, … These extra copies are virtual nodes; scattering each server across many points spreads the load evenly. bisect.insort keeps the list of positions sorted so we can search it fast.
  3. get(key) is the lookup: hash the key to a position, then bisect.bisect finds 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).
  4. 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.

Virtual nodes = even loadEach physical node is placed at many ring points (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.

Producer emits work Queue (Kafka/RabbitMQ) buffers durably Consumer(s) process at own pace
🗺️ How to read this diagram

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.

Step 3 · Producer/consumer with retry (runs standalone)
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']
▶ How this works

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.

  1. work = queue.Queue() creates a thread-safe queue — Python's built-in stand-in for Kafka/RabbitMQ. processed and failed are lists that record what happened, so we can print the result at the end.
  2. producer() puts six jobs (job-0 … job-5) on the queue, then puts None. That None is a sentinel — an agreed 'no more work' signal that tells the consumer when to stop.
  3. consumer() loops forever calling work.get() to take the next job. If it sees the None sentinel it breaks out and stops.
  4. The try/except is the retry logic: job-3 deliberately raises an error to simulate a transient failure. On failure we re-queue it as retry-3 (work.put(...)) and note it in failed; on success we add it to processed. work.task_done() in the finally tells the queue that item is handled either way.
  5. At the bottom the consumer runs on its own threading.Thread so 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.

KafkaRabbitMQ
Modeldurable, replayable logsmart broker with routing
A message isread by many consumer groupsusually consumed once
Best forevent streams, analytics, replaytask 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.

Step 4 · A sharded KV store (NoSQL shape) on the ring (runs standalone)
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}
▶ How this works

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.

  1. The HashRing class here is the same ring as before, trimmed down — it maps any key to one of the shard names via get(key).
  2. ShardedKV holds two things: a ring (which decides where a key lives) and shards, 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.
  3. 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.
  4. The demo writes six users, then looks up user:3 and 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.

NeedLeanWhy
complex queries, joins, transactionsSQLrelational integrity + ACID
massive scale, simple key lookupsNoSQL KV/dochorizontal scale, flexible schema
high write throughput, wide rowsNoSQL (Cassandra)tunable consistency, sharded by design
strong consistency + relationsSQL / NewSQLcorrectness guarantees
Default to SQL until a pattern forces NoSQLSQL scales further than people think (replicas + sharding), and losing joins/transactions is costly. Choose NoSQL for a concrete access pattern or scale need — not by default. Most systems are happiest with SQL for core data + a KV cache for hot lookups.

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 % N would 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.

Exercise 1 · Hash sharding: route a keyBeginner

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 n reshuffles 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.

Exercise 2 · Why modulo sharding hurts on resizeIntermediate

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 = 4 and again under n = 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.

Exercise 3 · Build a consistent-hashing ringAdvanced

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.

Exercise 4 · Message queue with retry & DLQExpert

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.

Exercise 5 · Queue throughput & backlog mathProfessional

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.

Exercise 6 · Choose SQL vs NoSQL by the dataIndustry scenario

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

✓ Knowledge check

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
Sharding splits data across databases to scale writes and total storage, whereas replication copies the same data to scale reads. With 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.
✓ Knowledge check

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?

Show answer
Servers and keys are each hashed to positions on a ring, and a key belongs to the first server encountered going clockwise, so adding a server only steals the keys in one arc (~1/N). Virtual nodes place each physical server at many ring points, spreading load evenly and, on removal, redistributing that node's keys across all remaining servers.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in