Systems Design
Systems Design

Caching, replicas & shards: scaling the read and write paths

Full · ~50 min
After this unit you can
  • Trace a request's read and write paths, put each kind of data in a cache layer that can hold it, size the database for the cache's miss rate, and use a queue to level bursty writes
  • Spot the races cache-aside leaves once the write order is right — a stale set, a stampede across the fleet — and fix each in the shared cache (a lease, a lock key), with the TTL as the bound on what's left
  • Predict a read-your-writes failure under asynchronous replication and choose a fix: read from the primary after a write, wait for the replica, or accept the lag
  • Choose a partition key and routing scheme, and explain why hash % N moves most keys on a resize while consistent hashing with virtual nodes moves about 1/N
  • Explain CAP and PACELC in plain terms and name the trade-off a concrete setting makes, such as asynchronous vs synchronous replication

Read

Simple
1 / 3

Every system has a read path and a write path

Flag

Follow one request through a system and you get a path: the hops it touches, in order. Most systems have two paths that matter, and they want opposite things.

READ:   client → CDN → app → cache ──miss──→ database replica
WRITE:  client → app → database primary → delete the cached copy
                              └──→ queue → workers (the slow follow-up work)
  • The read path wants copies close to the reader. Every copy — a CDN edge, a cache entry, a read replica — takes load off the source of truth and latency off the request. The price is staleness: a copy can be older than the original.
  • The write path wants one authoritative place to land, durably. Copying a write doesn't make it faster. You can only make it smaller (do less before replying) or spread it (partition the data so different writes land on different machines).

So the tools sort themselves by path:

ToolPathWhat it buysWhat it costs
Cache, CDNreadfewer trips to the source, lower latencystale data; invalidation
Read replicasreadmore read capacityreplication lag
Queuewritethe reply doesn't wait for slow work; bursts get absorbedwork finishes later; duplicates
Partitioning (sharding)write and storagewrites and data spread across machinescross-shard queries; resharding

The first number to get in any design is the read:write ratio, because it says which half of this table you're shopping in. The URL shortener, at 100 reads per write, lives almost entirely in the top half.

Medium
1 / 3

Cache-aside, sized — the miss rate is the database's load

Flag

The pattern itself is the NestJS track's Caching & rate limits with Redis unit: read the cache; on a miss, load from the database and leave a copy with a TTL; on a write, update the database first, then delete the key (deletes, unlike sets, can't land in the wrong order). This tier assumes that and asks the design questions around it: what to cache, how big, and what the database behind it has to survive.

What to cache is an estimate too. The shortener stores 6 × 10⁹ links — about 3 TB — but the redirect only needs the links people are clicking now:

Assume: 10 million distinct links are clicked on a given day     (assumption)
Cache:  10,000,000 × ~500 B per entry                            ≈ 5 GB

Five gigabytes fits in one cache node's memory; 3 TB doesn't fit in any. So the cache holds the working set, the TTL decides how long a link stays in it once its clicks stop, and the database still holds everything. Get the assumption from real traffic when you have it.

The hit ratio is the database's load, read upside down. At 10,000 reads a second:

hit ratio 99%  →  1% miss  →    100 reads/s reach the database
hit ratio 98%  →  2% miss  →    200 reads/s   (one point lower, twice the database load)
hit ratio 90%  → 10% miss  →  1,000 reads/s

A dashboard showing 98% looks almost the same as 99%. The database sees its load double. Near the top, read a hit ratio as its complement.

Size the database for a bad day's miss rate, not today's. Three ordinary events push it up:

  • a deploy restarts every server, and every in-process cache starts empty;
  • a change to key names (a new prefix, a new field in the key) makes every old entry unreachable at once;
  • traffic shifts to keys nobody has asked for lately — a newsletter links to old posts.

And the worst day is the cache being gone altogether: 100% misses. The complex tier of A framework for any design does that arithmetic, and AWS's caching article gives the test: run load tests with the cache disabled.

The TTL is a requirement, not a tuning knob. Azure's Cache-Aside guidance is to fit the expiry to how the data is used: too short and requests keep going back to the database, too long and stale data lingers. Delete-on-write only covers writers that go through your code. If another service, an admin script or a migration can change the table, the TTL is the only freshness guarantee those writes get, so pick it by asking "how stale may this be?" — and write the answer down next to the requirement it serves.

Complex
1 / 3

The two races cache-aside still has

Flag

Updating the database before deleting the key closes one race (the NestJS track's Caching & rate limits with Redis unit). Two remain, and at fleet scale the fixes have to live in the shared cache, not in one process.

The stale set. A reader that misses can be slower than a writer:

reader A:  GET link:x        → miss
reader A:  SELECT … → old row
                                writer B:  UPDATE … → COMMIT
                                writer B:  DEL link:x
reader A:  SET link:x = old row     ← lands after the delete

The delete happened — and then A refilled the cache with the row it read before the update. Every reader gets the old value until the TTL expires. Facebook's memcache paper calls this a stale set and fixes it with leases: on a miss, the cache hands the reader a 64-bit token bound to the key; a delete invalidates outstanding tokens; a set carrying an invalidated token isn't stored.

Redis has no leases, and a plain version check can't stand in for one: after B's DEL there's nothing left in the cache to compare A's old version against. You can build the lease:

  • On a miss, the reader takes a token key: SET lease:link:x <random> NX PX 5000.
  • The writer's invalidation deletes lease:link:x along with link:x.
  • The fill is a small Lua script that writes link:x only if lease:link:x still holds the reader's token. Redis runs a script atomically, so nothing slips in between the check and the write.

(Versions work if the writer leaves its version behind instead of deleting — the new value, or a tombstone holding the version — and every fill refuses to overwrite a newer version: the version-column idea from Transactions & isolation, applied to the cache.) With neither, the TTL is your only bound on how long a stale set survives, so choose it knowing that.

The stampede across the fleet. A hot key expires and every request that misses asks the database for the same row. In-process coalescing — one load per key per process, like cache-manager's wrap — caps that at one query per server: with 50 servers, a cold key still costs up to 50 identical queries, every time it expires. Removing the multiplier needs a decision every server can see, which means the cache. AWS's Builders' Library describes coalescing done by the servers or by an external cache. Memcache does it with the same leases: a server hands out a token for a key at most once every 10 seconds, and other clients that miss are told to wait briefly. The paper reports a set of keys prone to herds going from a peak of 17K database queries a second without leases to 1.3K with them. The Redis lease above has the same property — whoever loses the NX waits briefly and reads again.

Quiz

GET /api/dashboard returns JSON that's different for every signed-in user and is expensive to compute. Where can you cache it?

Flag

Producers send an average of 1,200 messages/s; the consumers can process 1,000/s. What does putting a queue between them do?

Flag

Your cache-aside code already updates the database and then deletes the key, yet a link occasionally serves its old destination for a full TTL after an edit. Which change closes that gap with Redis as the cache?

Flag

Each of 40 app servers already coalesces concurrent misses in-process (one load per key per server, like cache-manager's wrap). When a very hot key expires, the database still takes a burst of identical queries every time. What removes the rest of the burst?

Flag

Reads go to async Postgres replicas. A user saves their profile, gets 200 OK, and the reloaded page shows the old name for about a second. What's the targeted fix?

Flag

You shard an orders table by created_at ranges, one shard per month. What goes wrong first?

Flag

A cache cluster routes with node = hash(key) % N. Why is adding a fifth node to four so painful?

Flag

A teammate says: "Our Postgres primary with async read replicas is a CA system, so network partitions don't concern us." What's the best correction?

Flag

Fill in the blank

Read-your-writes on PostgreSQL 18 replicas: you kept the primary's WAL position from the moment the user's write committed. Serve their next read from a replica only if it has replayed past that point.

Flag
-- on the primary, right after the user's write commits; keep the result in their session
SELECT pg_current_wal_lsn();            -- e.g. 0/3000148

-- on a replica, before serving that user's next read
SELECT () >= '0/3000148'::pg_lsn;   -- false → wait briefly, or read from the primary

Keep a hash ring's positions in numeric order.

Flag
positions.sort((a, b) => );

After a binary search, low is the index of the first position ≥ the key's hash — or positions.length if there's none. Wrap around the ring.

Flag
const index = low === positions.length ?  : low;

Make each commit wait until the synchronous standby has replayed it, so a read on that standby right after the commit sees the change.

Flag
SET synchronous_commit = ;

Redis Cluster's key → slot function. The modulus never changes, which is why adding a node moves slots instead of rehashing keys.

Flag
HASH_SLOT = CRC16(key) mod 

Predict the output

// Estimate
reads_per_second = 10_000

// Monday:  cache hit ratio 99%
// Tuesday: a deploy changes key names for some pages; hit ratio 98%

database_reads_per_second(Monday)  = ?
database_reads_per_second(Tuesday) = ?
Flag
// hash32 maps a string to an unsigned 32-bit integer (the challenge's hash.ts)
const keys = Array.from({ length: 10_000 }, (_, i) => `user:${i}`);

const moved = keys.filter((k) => hash32(k) % 4 !== hash32(k) % 5).length;
console.log(moved / keys.length);
Flag
// cache-aside, TTL 1 hour; the database row for x starts as OLD
reader A:  cache.get("link:x")           // miss
reader A:  row = db.select("x")          // reads OLD
writer B:  db.update("x", NEW)           // commits
writer B:  cache.del("link:x")
reader A:  cache.set("link:x", row, { ttlSeconds: 3600 })
Flag

Challenge

Fix a consistent-hash shard router

Flag

hash-ring.ts decides which node (a cache server, a database shard) owns each key, using consistent hashing. Every node is hashed onto a ring of 32-bit positions — once per virtual node, so replica i of node n sits at hash(`${n}#${i}`) — and a key belongs to the node at the first position at or after the key's own hash. A key that hashes above the highest position wraps around to the lowest.

That rule is what makes resizing cheap. A new node only takes over the arcs just before its own positions, so only those keys move. Compare hash(key) % nodeCount: going from 4 nodes to 5 changes the answer for about 80% of keys, which for a cache means a fleet-wide miss storm. The preview prints both numbers.

The ring compiles and answers every lookup, and it's wrong in four ways:

  1. Order — positions are sorted with sort() and no comparator, which compares numbers as strings (10000 sorts before 900), so a lookup walks the ring out of order.
  2. Wrap-around — a key that hashes above the highest position finds no position "at or after" it, and gets undefined instead of the lowest position's node.
  3. Virtual nodes — every replica of a node lands on the same point, so each node really has one position and the load is lopsided.
  4. Removal — removeNode drops the node's owner entries but leaves its positions on the ring, so keys still land on a node that's gone.

hash.ts (read-only) provides hash32. The tests pass their own hash function through the constructor to put nodes and keys at exact positions — the same seam you'd use to test any router without depending on what a real hash happens to produce.

Your task

Edit hash-ring.ts so the tests pass:

  • A key routes to the node at the first ring position at or after its hash, with positions compared as numbers
  • A key hashing past the highest position wraps around to the lowest one
  • With virtual nodes, each of 4 nodes owns between 15% and 35% of 10,000 keys
  • Removing a node moves only the keys it owned, and no key routes to it afterwards
  • Adding a 5th node moves keys only to the new node — between 10% and 30% of them

Loading the in-browser editor…

Further reading