Session 3, second half · Week 2 · Tue 10 Nov 2026

Data at Scale: Sharding, Replication, Caching.

Last week we picked a database. Tonight, the harder one: what do you do when one machine can't cope any more? Every answer gives you room, and hands you a new bill.

Ehsan Gazar
From Senior to Staff · session 3 of 8
Where we're going · 90 minutes

The run sheet.

0–10When one machine endstry the easy fixes first
10–35Shardingsplit the data across machines
35–55Replicationkeep copies of the data
55–75Cachingstop re-asking the same thing
75–90What breaks + exercisehot shards, lag, stampedes
By the end of tonight

You'll be able to…

1Pick a shard key, and guess where it gets lopsided.
2Pick a copy setup, and say what could go a little stale.
3Put a cache in the right place and keep it honest.
4Name what breaks first: hot keys, skew, lag, stampedes.
The one big idea

Every fix removes a limit and adds a catch.

More machines, copies, caches: each one takes a bottleneck away, and hands you back a new way to break and a new "this might be slightly out of date." The senior skill isn't the trick. It's saying what you bought and what you now owe.

💡 In plain English

When one fridge isn't enough you add more fridges (sharding), keep a spare in case one dies (replication), and put a snack drawer on the counter for stuff you grab all the time (caching). Each one helps. Each one is a new thing to keep tidy.

Bigger box first
boring, and usually the right call
default
Shard reluctantly
it's a one-way door; the key is forever
careful
Copy on purpose
to handle more reads, or to survive a death
pick one
First moves · 0–10 min

First ask: is it reads or writes that hurt?

When the database creaks, don't jump to the scary fix. The cheapest fixes come first, and the answer depends on one question: are too many reads the problem, or too many writes?

flowchart TB
  P(["one DB can't keep up"]) --> Q{"reads or writes?"}
  Q -- "too many reads" --> R1["add read replicas"]
  R1 --> R2["add a cache"]
  Q -- "too many writes" --> W1["bigger box first"]
  W1 --> W2["then shard
last resort · one-way door"] classDef g fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef w fill:#FEE4E2,stroke:#1F2937,color:#0E1726; classDef a fill:#ffffff,stroke:#1F2937,color:#0E1726; class R1,R2,W1 g; class W2 w; class P,Q a;
💡 In plain English

Replicas and caches help when lots of people are reading. Only sharding helps when too many writes hit at once. People often shard when they really had a reading problem, and a cache would have fixed it in an afternoon.

Sharding · 10–35 min

Sharding: cut one big table into pieces on different machines.

When a table is too big for one machine, you split the rows across several. Each machine holds a slice. A simple rule, the shard key, decides which row goes where.

flowchart TB
  R(["users table
too big for one machine"]) R --> S{"split by user_id"} S -- "id ends 0–3" --> A["Machine A"] S -- "id ends 4–6" --> B["Machine B"] S -- "id ends 7–9" --> C["Machine C"] classDef big fill:#FEF3C7,stroke:#1F2937,color:#0E1726; classDef ask fill:#ffffff,stroke:#1F2937,color:#0E1726; classDef sh fill:#D6F5E3,stroke:#1F2937,color:#0E1726; class R big; class S ask; class A,B,C sh;
💡 In plain English

One library got too big for one building. So you split the books across three branches, with a sign on the door: "A–H here, I–P there, Q–Z over there." Sharding is that sign, for rows.

Sharding · the one choice that matters

The shard key is a one-way door. Choose it for even load.

The shard key is the column you split on. It decides two things, more or less forever: does the load spread evenly, and can a normal query be answered by one machine instead of all of them?

Spreads evenly?
lots of different values, none dominating → no machine on fire
In your usual query?
if the key is in your WHERE, one machine answers. If not, all of them do.
Never changes?
you can't move a row by editing its key, so pick something stable

Good keys: user_id, tenant_id. Bad keys: anything with few values (status, country) or that piles up in one place (a date).

Sharding · three ways to split

Same key, three ways to choose a machine.

flowchart TB
  K(["key: user_id = 42"])
  K --> R["RANGE
ids 0–100 live together → Shard A"] K --> H["HASH
scramble 42 → Shard C"] K --> D["DIRECTORY
look it up in a table → Shard B"] classDef a fill:#FEF3C7,stroke:#1F2937,color:#0E1726; classDef b fill:#DCEBFE,stroke:#1F2937,color:#0E1726; classDef c fill:#E6E8FF,stroke:#1F2937,color:#0E1726; class R a; class H b; class D c;
💡 In plain English

Range = shelved A–Z. Easy to grab "all of March", but the new-stuff shelf is always mobbed. Hash = a coat-check number. Perfectly even, but you can't browse "everything from March". Directory = a front desk that knows where each thing is. Flexible, but you ask the desk first.

Sharding · what breaks first

Skew: one machine does all the work, the rest nap.

Sharding promises even load. Real data is lumpy. If one value is far busier than the rest, its machine gets crushed while its neighbours sit idle. You added machines and fixed nothing.

flowchart LR
  T(["traffic"]) --> A["Shard A
1 celebrity user"] T --> B["Shard B"] T --> C["Shard C"] A --> X(["overloaded"]) classDef hot fill:#FEE4E2,stroke:#1F2937,color:#0E1726; classDef cool fill:#D6F5E3,stroke:#1F2937,color:#0E1726; class A,X hot; class B,C cool;
Why it happens
one user with 50M followers, or 90% of traffic is "today"
Fix: better key
spread it more evenly, or split the hot value
Fix: isolate it
give the giant its own machine
Sharding · adding machines later

Adding a machine shouldn't move all your data.

The naive way (id % number-of-machines) has a nasty surprise: add one machine and almost every row has to move. The fix is a "ring" (consistent hashing): add a machine and only a small slice moves.

flowchart LR
  subgraph NAIVE["id mod N · add a machine"]
    direction TB
    K1(["most keys move"]) --> BAD["huge, risky migration"]
  end
  subgraph RING["ring · add a machine"]
    direction TB
    K2(["one small slice moves"]) --> OK["quick and safe"]
  end
  classDef bad fill:#FEE4E2,stroke:#1F2937,color:#0E1726;
  classDef good fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
  class BAD bad;
  class OK good;
        
💡 In plain English

Picture a clock face with keys dotted around it. Each machine owns the keys in its arc. Add a machine and it takes over just one arc; everyone else keeps theirs. That's why Cassandra and DynamoDB use a ring instead of plain % N.

Replication · 35–55 min

Replication: keep copies of the same data.

Sharding puts different rows on different machines. Replication puts the same rows on several machines. You do it for two reasons, and you should know which one you're buying.

flowchart LR
  W(["all writes"]) --> L["Leader
the real one"] L -- "copies changes" --> F1["Follower 1"] L -- "copies changes" --> F2["Follower 2"] F1 --> RD(["reads spread out"]) F2 --> RD classDef lead fill:#FEF3C7,stroke:#1F2937,color:#0E1726; classDef fol fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef io fill:#ffffff,stroke:#1F2937,color:#0E1726; class L lead; class F1,F2 fol; class W,RD io;
💡 In plain English

Two reasons to photocopy your data. Stay up: if one machine dies, a copy takes over. Read more: spread millions of reads across the copies instead of hammering one. Same trick, two goals.

Replication · the main trade-off

Wait for the copy, or don't?

A write lands on the leader. Does it wait for a copy to confirm before telling the user "done"? That one yes/no is the whole trade-off.

flowchart LR
  subgraph SYNC["Synchronous: safe, slower"]
    direction LR
    A1(["write"]) --> L1["leader"] --> Wt["wait for copy"] --> D1(["reply done"])
  end
  subgraph ASYNC["Asynchronous: fast, can lose a little"]
    direction LR
    A2(["write"]) --> L2["leader"] --> D2(["reply now"])
    L2 -. "copy later" .-> F2b["follower"]
  end
  classDef wait fill:#FEF3C7,stroke:#1F2937,color:#0E1726;
  classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
  class Wt wait;
  class D1,D2 ok;
        
💡 In plain English

Sync = wait until a copy has it, so nothing is lost if the leader dies a second later, but every write is slower. Async = reply instantly and copy after, fast, but a crash can lose the last few seconds. Most teams keep one sync copy and the rest async.

Replication · who is allowed to write

One writer, or many?

flowchart LR
  subgraph LED["Leader-based: one writer"]
    direction TB
    CW1(["writes"]) --> LE["leader"]
    LE --> Fa["copy"]
    LE --> Fb["copy"]
  end
  subgraph LESS["Leaderless: any machine writes"]
    direction TB
    CW2(["writes"]) --> Na["node"]
    CW2 --> Nb["node"]
    CW2 --> Nc["node"]
  end
  classDef lead fill:#FEF3C7,stroke:#1F2937,color:#0E1726;
  classDef node fill:#DCEBFE,stroke:#1F2937,color:#0E1726;
  class LE lead;
  class Na,Nb,Nc node;
        
💡 In plain English

Leader = one cashier takes all the money. Tidy, but if the cashier leaves, the queue stops until someone steps up. Leaderless (Dynamo, Cassandra) = many tills. You're never blocked, but two tills might sell the last item and you sort it out after.

Replication · the catch to name

Lag: the copy is a little behind.

A copy is always a touch behind the leader. Usually fine. Sometimes it causes a confusing bug: a user does a thing, then reads from a behind copy and can't see the thing they just did.

flowchart LR
  U(["user posts a comment"]) --> L["leader
has it"] L -. "lag: not copied yet" .-> F["follower
missing it"] U2(["same user reloads"]) --> F F --> Q(["my comment
is missing"]) classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef warn fill:#FEE4E2,stroke:#1F2937,color:#0E1726; class L ok; class F,Q warn;
💡 In plain English

The fix is small: for a user's own recent actions, read from the leader, not a copy. Say it out loud: "I read from copies, except your own recent writes, those come from the leader." That sentence sounds like someone who has shipped this.

Caching · 55–75 min

Caching: stop asking the same question twice.

A cache is a small, fast copy of an answer you already worked out, kept close to whoever needs it. A request tries each layer in turn, and the earlier it gets an answer, the cheaper it is.

flowchart LR
  U(["request"]) --> B["browser
on the device"] B -- "miss" --> CDN["CDN / edge
near the user"] CDN -- "miss" --> APP["app cache
Redis"] APP -- "miss" --> DB["database
slow · the truth"] classDef fast fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef slow fill:#FEE4E2,stroke:#1F2937,color:#0E1726; class B,CDN,APP fast; class DB slow;
💡 In plain English

It's the snack drawer on your counter. You don't walk to the shop (the database) every time you want a biscuit. You keep a few close by. The whole game: how many trips can I skip, and how do I stop the biscuits going stale?

Caching · the everyday pattern

Cache-aside: check the drawer, restock if empty.

This is the one you'll use almost always. On a read, look in the cache. Hit? Return it fast. Miss? Go to the database, then put the answer in the cache so next time is fast.

flowchart LR
  R(["read"]) --> C{"in cache?"}
  C -- "hit" --> F1(["return fast"])
  C -- "miss" --> DB["read database"]
  DB --> S["store in cache"]
  S --> F2(["return"])
  classDef g fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
  classDef w fill:#FEF3C7,stroke:#1F2937,color:#0E1726;
  classDef a fill:#ffffff,stroke:#1F2937,color:#0E1726;
  class F1,F2 g;
  class DB,S w;
  class C a;
        
Write-through
update cache + DB together · always fresh, more work
Write-behind
cache now, DB later · fastest, but a crash can lose it
Caching · the genuinely hard part

When does the cached answer become a lie?

A cache is a copy, and the real data changes underneath it. Now the copy is wrong. You only have two ways to deal with it, and most cache bugs come from getting them tangled.

TTL (by time)
"trust it for 60 seconds, then refetch." Simple. Wrong for up to a minute.
Explicit (on change)
when the data changes, delete the cached copy. Fresh, but you must catch every place that writes.
The classic bug
one forgotten write path → the cache stays wrong forever
The honest move
don't promise "fresh", promise a number: "up to 60s stale, fine because…"

"There are only two hard things in computing: cache invalidation and naming things." It's a joke because it's true.

What breaks · 75–90 min

The bills come due. Name them before they bite.

Each thing tonight has one signature way it fails. Being able to list them on the spot is exactly what makes you sound like you've run this for real.

1
Hot shard
one machine gets all the traffic
2
Replication lag
a copy is behind; users miss their own writes
3
Failover gap
leader dies; brief moment with no writer
4
Thundering herd
a hot cache key expires; everyone stampedes
5
Cold cache
cache restarts empty; every request misses
6
Stale cache
a missed update serves wrong data for ages
What breaks · the famous one

Thundering herd: a popular cache key expires.

One key answers a million requests a second from cache. It expires. Now a million requests miss in the same instant and all hit the database together, the exact flood the cache was hiding. The database falls over, things slow down, and it spirals.

flowchart LR
  E(["hot key expires"]) --> M["1M requests miss
at the same instant"] M --> DB["all hit the database
together"] DB --> X(["database overloads"]) classDef bad fill:#FEE4E2,stroke:#1F2937,color:#0E1726; classDef io fill:#ffffff,stroke:#1F2937,color:#0E1726; class M,DB,X bad; class E io;
Jitter the timeout
add randomness so keys don't all expire at once
Let one fetch
first miss rebuilds it; everyone else waits for that result
Refresh early
rebuild just before it expires, in the background
The move to carry

Bottleneck → fix → what it buys → what it costs.

name
The bottleneck
"writes don't fit on one machine"
pick
The fix
"so I shard by tenant_id"
buy
What it buys
"lots more write headroom"
owe
What it costs
"big tenants run hot; cross-tenant reports get hard"

Four short sentences. Run them on any scaling choice and you sound like you decided it, because you did. The cost sentence is the one juniors skip.

The traps

Four ways people scale data badly.

1
Sharding too early
a one-way door, opened when a bigger box had years left.
2
A shard key you can't defend
picked for convenience; on fire on day one; can't change it.
3
"Copies are always current"
forgetting lag, then chasing a "vanishing write" for a day.
4
Cache with no cleanup plan
added for speed; now quietly serving stale data.

The fix for all four is the same: name the bottleneck first, then name the cost. Don't add a trick you can't bill.

Your turn · ~8 min

Shard one real table. Out loud.

Take one big table from your own app. Don't reach for a tool. Answer these three, and the design defends itself.

1
What's the shard key?
and is it in your most common query?
2
Where's the hot spot?
which value is busiest, and how would you spread or isolate it?
3
Reads from a copy?
if yes, which reads must still come from the leader?

Drop your shard key + its worst hot spot in the chat. We'll run a few live.

Recap · then what's next

Every scaling move has a bill. Name it.

Easy fixes first. The shard key is near-permanent, so choose it for even load. Copies are for more reads or staying up, pick which, and own the lag. Caches are a "might be a bit stale" promise, not free speed.

Project 2 · ADR #1 — Database Decision
Now defend it at scale, not just on features. Due Jun 19.
finish the ADR
Take-home
Name the shard key you'd pick for one dataset, and its worst hot spot.
bring it