Notes
The signals
The signal is a lopsided read to write ratio. Words like view, browse, load the page, show the profile. A URL shortener redirects about a hundred times per link created. A product page might be read a thousand times per edit. When your estimate shows reads dominating, say so, and tell the interviewer you will optimise the read path.
- "view", "browse", "load the
- page", "show the profile"
- read:write of 10:1 or more
- the same item read by many
- users (popular post, product)
- p99 latency targets on reads
- examples: URL redirect 100:1,
- product page 1000:1,
- news feed, Pastebin
The naive design
The naive design sends every read to one database. A single well-indexed Postgres server might handle tens of thousands of simple queries a second, but far fewer once there are joins and sorts. Writes compete for the same CPU and memory, and connection counts run out. At forty thousand reads a second, latency climbs and then requests start timing out.
one Postgres box, indexed reads: roughly 10k-50k simple QPS far fewer for joins and sorts need 40,000 reads/s at peak + connection limits (hundreds) + writes compete for the same CPU and buffer cache → latency climbs, then timeouts
Move 1: fix the query
Move one is free: make each read cheap. Add the index that matches the query, see Concepts episode seventeen. Remove N plus one patterns, where a list page fires one query per row. Select only the columns you need, and paginate with a cursor instead of a huge offset. Sometimes denormalising, like storing the author name on the post, removes a join from the hottest path.
- add the right index
- (Concepts episode 17)
- kill N+1 queries: one query
- for the list, not one per row
- select only needed columns
- denormalise the hot read
- (store author name on post)
- paginate with a cursor,
- not OFFSET 100000
Move 2: read replicas
Move two: read replicas. The primary takes every write and streams its change log to several copies; reads are spread across them, so five replicas give roughly five times the read capacity. The catch is replication lag. It is usually milliseconds, but under load it can be seconds, and a user who edits their profile and refreshes might see the old value. Concepts episode twenty covers replication.
The standard fix is read-your-writes for the person who just wrote. For about ten seconds after a write, route that user's reads to the primary, or remember the log position of their write and only read from a replica that has reached it. Everyone else can tolerate a second of lag. That one sentence shows you understand replication, not just the diagram.
- primary takes all writes
- replicas stream its log
- reads go to any replica
- 5 replicas ≈ 5x read capacity
- catch: replication lag,
- usually ms, sometimes seconds
- user edits, refreshes, sees
- the old value
- after a user writes, route
- their reads to the primary
- for ~10 s (cookie or flag)
- or pass the write position and
- wait for a replica to reach it
- everyone else: replicas are
- fine, a second of lag is ok
Move 3: cache
Move three, the big one: a cache. With cache-aside, the app checks Redis first. On a miss it reads the database, then stores the result with an expiry. If ninety-five percent of reads hit, forty thousand reads a second becomes two thousand at the database. A single Redis node serves around a hundred thousand operations a second in under a millisecond. See Concepts episode eleven.
v = cache.get(key) if v is null: v = db.query(...) cache.set(key, v, ttl=300) return v 95% hit ratio on 40k reads/s → only 2,000 reach the DB Redis node: ~100k ops/s, <1 ms
Invalidation
Caches go stale, so plan invalidation. A TTL bounds how stale anything can be. On a write, update the database, then delete the cache key rather than setting it, because two concurrent writers setting values can leave the older one in the cache. For stricter setups, a consumer of the database change log deletes keys, so no code path can forget.
- TTL: bounds staleness
- (5 min for a product page)
- on write: update DB, then
- DELETE the cache key
- (do not SET: races)
- versioned keys: product:9:v42
- change data capture: a
- consumer of the DB log
- invalidates keys
Hot keys
Two traps. A stampede: a hot key expires and five thousand requests miss at the same instant and hammer the database. Fix it with request coalescing, so one request fetches and the rest wait, plus jittered expiry and background refresh. A hot key: one celebrity's profile lands on a single cache shard. Add a tiny in-process cache in front, or store several copies under suffixed keys.
- stampede: a hot key expires,
- 5,000 requests miss together
- → request coalescing: one
- fetch, others wait for it
- → jitter TTLs (300 ± 30 s)
- → refresh early in background
- hot key: one Redis shard
- → small in-process cache
- → copies: key#1 ... key#8
Move 4: CDN
Move four: if many users get identical bytes, serve them from a CDN. Images, video, scripts, and even public API responses with the right cache headers. An edge hit costs tens of milliseconds and zero load on your origin. Purge on publish, or better, put a version in the URL so new content simply has a new address. Concepts episode twelve goes deeper.
- same bytes for every user?
- → cache at a CDN
- images, video, JS, CSS
- public pages, API responses
- with Cache-Control headers
- edge hit: tens of ms, zero
- load on your servers
- purge on publish or use
- versioned URLs
Move 5: precompute
Move five: precompute. If every read does the same heavy computation, do it once at write time. Keep a like count column instead of counting rows. Fan a post out to followers' timelines so reading a feed is one lookup. Keep a leaderboard in a sorted set. Refresh a materialised view every minute for dashboards. You trade write cost and storage for very cheap reads.
- do the expensive work at
- write time, once
- feed: fan-out to timelines
- counts: keep like_count
- instead of COUNT(*)
- leaderboard: sorted set
- search: build an index
- reports: materialised view
- refreshed every minute
Trade-offs
The trade-offs. Every copy, replica, cache or edge, adds staleness, so state the freshness budget, like a few minutes for a product description and zero for a bank balance. A cache costs memory and can fail; after a restart it is cold and the database takes the full load, so warm it or rate limit misses. Precomputing makes writes heavier. And sharding is for when writes or data outgrow one primary, not reads.
- every layer adds staleness
- → say how stale is ok
- more copies, more ways to
- disagree
- cache = memory cost + one
- more thing to fail
- (a cold cache after restart)
- precompute = slower, bigger
- writes
- shard only when data or
- writes outgrow one primary
Where it shows up
You will use this in almost every problem. The URL shortener redirect path is a textbook cache plus replicas. Pastebin and YouTube push content to a CDN. News feed and Twitter precompute timelines. E-commerce caches product pages. Yelp caches nearby results by area. Whenever the read path is the hot path, walk through these moves.
- URL shortener: redirect path
- Pastebin: read-mostly blobs
- News feed, Twitter: timelines
- Instagram, YouTube: media
- via CDN
- E-commerce: product pages
- Yelp: nearby search results
- Leaderboard: top-N reads
Say it like this
Here is the phrasing. Reads are a hundred times writes, so I will optimise the read path. First indexed queries. Then read replicas, with read-your-writes for the author. Then a Redis cache-aside with a five-minute expiry, deleting the key on write and coalescing misses. A CDN for media and public pages. And precomputed counts so nothing aggregates at read time.
- "Reads are 100x writes, so I
- optimise the read path.
- Indexed queries, then read
- replicas with read-your-
- writes for the author, then
- a Redis cache-aside with a
- 5 min TTL and delete on
- write, a CDN for media, and
- precomputed counts."