Courses / System Design Interview / Chapter 13
Module 2 · Building Blocks · Chapter 13 of 39
Distributed Caches
Builds on Back-of-the-Envelope Estimates, Databases: Replication, Partitioning and Choosing a Store and Designing a Key-Value Store. Next up: Message Queues.
Part 1 · Why Cache
Why a cache pays for itself
It is the innings break of a cricket final, and in ten minutes lakhs of people open the same food delivery app. Almost all of them load the same few things: their city's home screen and the menus of the most popular restaurants nearby. Those menus last changed in the afternoon, yet the database fetches each one again for every customer, thousands of times a second. Queries queue, a page that took 200 ms takes 4 seconds, and the order button spins. Nothing is broken. The database is answering the same question over and over, and a cache is what stops it.
A cache is a small, fast store that keeps copies of data that is slow or expensive to fetch, so the next request can skip that work. A distributed cache spreads that store over several machines and shares it with every application server, so a menu loaded by one server is ready for all the others. This chapter follows that food delivery app through the innings break: its menus, its city home screens and its carts.
The gap a cache exploits is large. Here is roughly what one read costs from each place the app could get a menu:
| Read from | Rough time | What it costs |
|---|---|---|
| A map inside your own server process | about 0.1 µs | Nothing extra, but every server keeps its own copy |
| A distributed cache on the same network | under 1 ms | One network round trip |
| A database, simple lookup by key | about 1 to 10 ms | A connection, CPU, sometimes a disk read |
| A database, query with joins or sorting | 10 to 100 ms or more | Heavy work that slows other queries down |
The gaps between the rows hold on any hardware. Speed is only half the gain: every read the cache answers is one the database never sees, leaving its connections and CPU for the writes only it can do. So how many reads must the cache answer?
Hit ratio, and what is worth caching
Think of the cache as a filter in front of the database: what matters is how much gets through. The hit ratio is hits ÷ all reads, and the misses are what the database still has to serve.
At the innings break the app makes about 500,000 menu and home-screen reads a second. At a 95% hit ratio, 25,000 of them reach the database; at 99%, only 5,000. Going from 95% to 99% cuts database reads to a fifth, which is why teams chase the last few points. Latency moves too: if a hit takes 1 ms and a miss about 30 ms, the average read at 99% is 0.99 × 1 + 0.01 × 30 ≈ 1.3 ms.
The hit ratio depends on what you put in the cache. Good candidates are read far more often than written, expensive to fetch or compute, and fine if a few seconds stale. Data that many users share gives the biggest win, because one copy serves them all: a restaurant's menu, a city's home screen, the result of "top 20 restaurants near me".
Some data looks cacheable but is not, because a stale or wasted copy costs more than the read it saves:
Next comes where that copy should live, because there is more than one layer to put it in.
Where caches live
A menu can be cached at several points between the customer's phone and the database disk, and each layer trades speed against how widely the copy is shared. From the user inward:
| Layer | What it holds | Shared by | Typical size |
|---|---|---|---|
| Browser or app | Images, scripts, the last screen you saw | One user | Megabytes |
| CDN edge | Restaurant photos, static files | Users near that edge | Terabytes across all edges |
| In-process cache | Config, feature flags, a small hot list | One server process | Megabytes to a few GB |
| Distributed cache | Menus, home screens, carts, query results | All app servers | Gigabytes to terabytes |
| Database buffer cache | Recently used pages of tables and indexes | The database itself | A large share of its RAM |
What it holdsImages, scripts, the last screen you saw
Shared byOne user
Typical sizeMegabytes
What it holdsRestaurant photos, static files
Shared byUsers near that edge
Typical sizeTerabytes across all edges
What it holdsConfig, feature flags, a small hot list
Shared byOne server process
Typical sizeMegabytes to a few GB
What it holdsMenus, home screens, carts, query results
Shared byAll app servers
Typical sizeGigabytes to terabytes
What it holdsRecently used pages of tables and indexes
Shared byThe database itself
Typical sizeA large share of its RAM
This chapter is about the shared layer in the middle. An in-process cache is faster, but with 20 app servers you have 20 copies that fill separately, disagree after an edit and vanish on every deploy. A distributed cache costs one network hop, yet every server sees the same entry, and capacity grows with each node you add.
If the only copy of a cart or a session lives in the cache, the cache is no longer a copy: it is the database for that data. Eviction, a restart or a failover then empties carts or logs users out. Either keep them in a durable store with the cache in front, or run that cache as a primary store, with replicas, persistence to disk and an eviction policy that never drops those keys.
Redis is the usual default: counters, sorted sets, replication, optional persistence and a cluster mode. Valkey is an open-source fork of it. Memcached is simpler and multi-threaded, with no persistence; pick it for large caches of plain values. Every major cloud sells managed versions, which most teams choose over running their own.
Part 2 · Reading and Writing Through a Cache
Cache-aside: the default pattern
With the layer chosen, the next question is how data moves in and out of it. In cache-aside, also called lazy loading, the application talks to both the cache and the database. On a read it asks the cache first. On a miss it reads the database, stores the result in the cache with a TTL (time to live, after which the entry expires) and returns it.
def get_menu(restaurant_id): key = f'menu:{restaurant_id}' menu = cache.get(key) if menu is not None: return menu # hit menu = db.load_menu(restaurant_id) # miss: ask the source of truth cache.set(key, menu, ttl=300) # keep it for 5 minutes return menu def update_menu(restaurant_id, menu): db.save_menu(restaurant_id, menu) cache.delete(f'menu:{restaurant_id}') # the next read reloads it
The cache holds only what someone asked for, and a write simply removes the old copy.
Follow three reads of menu 42 through two different servers, with an edit in between.
First read of menu 42: server 1 misses, reads the database, fills the cache
Read it as steps
One customer app, two app servers, one shared cache in front of the database.
The second read hit because the cache is shared; with a cache inside each server, server 2 would have missed too. The write deleted the entry instead of overwriting it, so the next read loaded whatever the database held at that moment. Delete on write, reload on read is simpler and safer than keeping the cached copy in step by hand, though it still leaves the races covered later in this chapter.
If the cache is unreachable, cache-aside treats every read as a miss and goes to the database, provided cache calls have a short timeout. The site gets slower, not broken, as long as the database can take the extra load.
Other patterns, and how each fails
Cache-aside keeps the caching logic in your code. Other patterns move some of it into the cache layer, usually a library or caching service that can reach the database. Each is defined by what happens on a write:
- Read-through: the app talks only to the cache, which loads a missing value from the database itself.
- Write-through: each write goes through the cache layer, which writes the database and stores the new value before confirming.
- Write-behind (or write-back): the cache confirms the write at once and saves it to the database later, often in batches.
- Write-around: writes go straight to the database and skip the cache, which fills only on reads, as in cache-aside.
Each pattern also has a failure it is known for, and that failure decides where it belongs:
| Pattern | Fails when | Use for |
|---|---|---|
| Cache-aside | A slow or lagging fill puts an old value back; an empty cache sends every read to the database | Most read-heavy data, such as menus |
| Read-through | The same, and reads fail outright if the cache layer is down and the app cannot reach the database | Teams sharing one data access layer |
| Write-through | One of its two writes fails and the stores disagree; values nobody reads still fill memory | Settings read right after a change |
| Write-behind | The cache dies before it flushes, and those writes are gone | View counts and likes |
| Write-around | Every first read after a write misses | Data read rarely, or long after it is written |
Real systems mix them: cache-aside for reads, write-around for most writes, write-through for the few keys read immediately after a change, and write-behind only for counters. Write-through needs one rule: write the database first, and if the cache update then fails, delete the key rather than keep serving the old value.
Until the flush, only the cache has the write. Use it where losing a few seconds of data is acceptable, such as a restaurant's view count, and never for orders or payments.
Part 3 · Memory, Nodes and Hot Keys
Eviction: what to drop when memory is full
Patterns decide what goes into the cache; memory decides how much stays. A cache node might have 64 GB of RAM in front of a 2 TB database, so it can never hold everything. When it is full and a new entry arrives, it must evict, that is, remove an existing entry, and the eviction policy decides which. Each policy bets on a different sign that an entry will be read again:
| Policy | Removes | Works well when | Weak spot |
|---|---|---|---|
| LRU (least recently used) | The entry unused for the longest time | Recently read data is read again: menus, sessions | One big scan of cold keys can push out the hot ones |
| LFU (least frequently used) | The entry read the fewest times | Popularity is steady, like a famous restaurant | Old favourites linger unless counts fade over time |
| FIFO (first in, first out) | The oldest entry | Simplicity matters more than hit ratio | Ignores how popular an entry is |
| Random | Any entry | Reads are spread evenly | Can drop a hot key |
| TTL expiry | Entries past their time to live | Data has a natural freshness limit | Memory can fill before TTLs run out |
RemovesThe entry unused for the longest time
Works well whenRecently read data is read again: menus, sessions
Weak spotOne big scan of cold keys can push out the hot ones
RemovesThe entry read the fewest times
Works well whenPopularity is steady, like a famous restaurant
Weak spotOld favourites linger unless counts fade over time
RemovesThe oldest entry
Works well whenSimplicity matters more than hit ratio
Weak spotIgnores how popular an entry is
RemovesAny entry
Works well whenReads are spread evenly
Weak spotCan drop a hot key
RemovesEntries past their time to live
Works well whenData has a natural freshness limit
Weak spotMemory can fill before TTLs run out
LRU is the usual choice, because recent use predicts the next read well. A textbook LRU pairs a hash map with a list ordered by last use; large caches approximate it by evicting the oldest of a few sampled keys, which saves the list's memory.
from collections import OrderedDict class LRUCache: def __init__(self, capacity): self.capacity = capacity self.items = OrderedDict() # oldest first, newest last def get(self, key): if key not in self.items: return None self.items.move_to_end(key) # mark as most recently used return self.items[key] def put(self, key, value): self.items[key] = value self.items.move_to_end(key) if len(self.items) > self.capacity: self.items.popitem(last=False) # evict the least recently used
The ordered dictionary is the hash map and the list in one, so get and put run in constant time.
Some caches default to evicting nothing and rejecting writes once memory is full. Set the memory limit well below the machine's RAM, leaving room for fragmentation and replica syncs, and give almost every key a TTL: 5 to 10 minutes for a menu, a day for the list of cities you serve.
Sizing and sharding the cluster
Eviction handles a full node; the next question is how many nodes you need. Size a cache three ways, by memory, by operations a second and by network, and take the largest answer. For the food delivery app at the innings break:
memory 3 lakh menus × 5 KB ≈ 1.5 GB 50 lakh carts and sessions × 1 KB ≈ 5 GB + key overhead and headroom ≈ 10 GB → 1 node reads 500,000 a second ÷ ~100,000 per node = 5 nodes flat out keep each about 60% busy → 8 nodes network 500,000 × 4 KB average ≈ 2 GB/s ≈ 16 Gbps ÷ 8 nodes ≈ 2 Gbps each, on 10 Gbps cards → fine
Memory says one node; reads say eight. Add a replica to each for failover: 16 in all.
Spreading keys over eight nodes is sharding: each node owns part of the key space, and every app server must agree on which. The naive rule, node = hash(key) % N, spreads keys evenly until N changes. Going from 8 nodes to 9 moves about 89% of keys, so most reads miss at once and the database takes the hit just as you add capacity.
Consistent hashing, from the key-value store chapter, places nodes and keys on a ring so a new node takes over only its own slice: about one key in nine. Some clusters instead split the key space into a fixed number of hash slots, 16,384 in Redis Cluster, and move whole slots to a new node, with the same effect. The routing can live in a client library in each app server, in a proxy, or in the cluster itself, which redirects a request that reached the wrong node.
Sharding spreads keys evenly. It does not spread traffic evenly, and the next page shows what happens when one key gets more than a node can serve.
Hot keys
Sharding spreads keys, not traffic. At the innings break every customer in Mumbai opens the app to the same home screen, so the key home:mumbai might get 100,000 reads a second, and all of them land on the one node that owns it. That node becomes the bottleneck while the other seven idle.
The limit is often the network before the CPU. The home screen is about 20 KB, so 100,000 reads a second is 2 GB a second, about 16 Gbps: more than a 10 Gbps network card can carry. A hot key is a traffic problem that adding nodes cannot fix, because the key still lives on one of them.
There are three fixes, from cheapest to most involved:
- A short local cache: each app server keeps
home:mumbaiin its own memory for 1 second. With 100 app servers, the cache node sees at most about 100 reads a second instead of 100,000. The price is up to a second of staleness. - Key copies: store the value under several names,
home:mumbai#1to#8, placed on different nodes, and let each reader pick one at random, about 12,500 reads a second each. Every write must update or delete all eight. - Read replicas: give that shard two replicas and spread its reads over all three nodes. They also take over if the primary dies, but they lag slightly behind it.
Hot keys are usually predictable: a city's home screen at dinner time, a sale banner, a viral restaurant. Many teams sample key access counts on the cache nodes and cache only the top few keys locally, so everything else stays fresh.
Part 4 · Keeping Cached Data Fresh
Invalidation and stale data
So far the cache has been fast; the harder question is whether it tells the truth. Data is stale when the cache holds a value the database has since changed, such as a menu still offering a dish marked sold out. Staleness cannot be removed without giving up speed, so ask how long it lasts and who would notice. The common approaches differ in exactly that:
| Approach | How long data can be stale | Cost |
|---|---|---|
| TTL only | Up to the TTL | Nothing extra; fine when some staleness is acceptable |
| Delete on write | Usually milliseconds; a rare race can keep an old value until the TTL | One extra call per write |
| Update on write | Short, but two racing writers can leave the older value | One extra call per write, plus building the value |
| Change data capture (CDC) | A second or so, depending on the pipeline | A service that turns each committed change into a delete |
| Versioned keys | None for readers that know the new version | Old versions use memory until they expire |
Versioned keys put the version in the name, like menu:42:v7, so readers that learn the current version from the database never read an old entry. Delete on write, the usual default, still leaves one race, which needs a slow reader and a writer to interleave:
- 1A reader misses the cacheit reads menu v1 from the database, then stalls on a long pause or a slow network
- 2A writer saves v2the database now holds the new menu
- 3The writer deletes the keythere is nothing to delete yet
- 4The reader wakes up and caches v1an old value with a fresh TTL
- 5Every read now returns v1until the TTL runs out or another write deletes it
This race needs an unlucky pause between the reader's database read and its cache write, so it is rare. The next page shows a version of it that needs no bad luck at all.
Refilled from a lagging replica
Many apps send reads to database replicas, as in the databases chapter, and a cache fill is a read. Replicas apply the primary's changes a little late: usually milliseconds, sometimes seconds at the dinner peak. A fill that reads a replica inside that gap caches the old menu with a fresh TTL. It needs no unlucky pause, only a busy replica, which makes it a common stale-cache bug in production.
Predict before you watch: the writer updates the primary, then deletes the cached menu. Can the next reader still cache the old one?
- Yes, if its fill reads a replica that has not applied the update yet.
- The delete already happened, so the old menu stays until its TTL ends.
The writer saves menu v2 on the primary, then deletes the cached copy
Read it as steps
A writer, the cache, a reader, a replica about 2 seconds behind, and the primary.
The delete was right; the fill read the wrong copy. Any scheme built only on deletes leaves a window as long as the replica lag, and a busy evening stretches that window. The defences on the next page each close it in a different way.
Defences against stale refills
Three defences close the lag window, and a fourth limits the damage when the others miss. All of them also stop the slow-reader race from two pages back, because both races end the same way: an old value written after the delete.
| Defence | How it works | Cost |
|---|---|---|
| Fill from the primary after a write | The writer sets a marker, recent:menu:42, for longer than the worst lag, say 5 s; a fill that finds it reads the primary | One extra cache lookup per miss; extra primary reads only for keys just written |
| Delayed second delete | Delete the key again after a delay longer than the lag, say 3 s, from a timer or a queue | One more call per write; if the lag ever exceeds the delay, the race returns |
| Version tombstone | Instead of deleting, the writer leaves a tombstone holding the new version; a fill is stored only if its version is at least the one already there, checked atomically | A version column on every row, and a small script in the cache |
| TTL as the backstop | Any stale value dies when its TTL ends | More misses; it limits the damage instead of preventing it |
The tombstone is the only defence that does not depend on timing, so its details matter. A plain delete leaves nothing to compare against, so a fill carrying version 1 would be stored; the tombstone remembers that version 2 exists, and readers treat it as a miss. It must outlive the worst replica lag, and eviction can still drop it under memory pressure, which is why the TTL stays as a backstop.
Change data capture removes a different bug: a code path that forgets to delete. A small service reads the database's change log and deletes the matching key after every committed change. It does not remove the lag, so pair it with a second delete or tombstones, and alert when lag nears your delay.
Part 5 · When Things Go Wrong
The thundering herd
Stale data is a correctness problem; the next failure is a load problem. Every key expires eventually, including the hottest one. When home:mumbai expires at the innings break, every request for it misses until someone reloads it, and each one runs the same heavy query. This is a cache stampede, also called the thundering herd. The database is sized for the usual trickle of misses, so it slows, and more requests miss while the value is rebuilt.
At the innings break, home:mumbai expires while thousands open the app
Read it as steps
Three app servers share one cache; the database is sized only for the usual misses.
Three servers sent three queries; in production thousands of requests send thousands, against one with coalescing. Coalescing is the main fix, and the others stop a herd from forming:
| Fix | How it works | Cost |
|---|---|---|
| Request coalescing | One request reloads and the rest wait for it: a map of loads in progress inside a server, a lock key set only if absent across servers | Waiters pay one load's latency; if the lock holder dies, they wait for its few-second expiry |
| Jittered TTLs | 300 s ± 30 s, so keys written together do not expire together | Almost free |
| Early refresh | Near expiry, each read reloads in the background with a small chance that grows as the TTL runs out | A little extra database load |
| Serve stale while reloading | Return the old value briefly past its TTL while one request reloads | Users may see data a few seconds old |
Waiters need a limit too: after a few hundred milliseconds, serve the stale copy or go to the database under a concurrency cap, never in a tight retry loop.
When the cache itself dies
A stampede loses one key; sometimes you lose all of them. A cache holds copies, so losing it loses no data, yet it can still take the site down. At the innings break the app makes 500,000 reads a second at a 99% hit ratio, so the database serves 5,000. If the whole cluster disappears, the database suddenly gets all 500,000, a hundred times its normal read load.
A cold start does the same thing more slowly. A fresh cache after a restart or a migration has a hit ratio of zero and fills only as misses reach the database, so the first minutes after bringing it up are the most dangerous. Each way of losing the cache needs its own defence:
| Failure | What users see | Defence |
|---|---|---|
| One cache node dies | Misses for its share of keys | A replica per shard; consistent hashing so only about 1/N of keys move |
| Whole cluster down | Slow pages, then database timeouts | Cap concurrent database queries, shed load, serve a simpler page |
| Cold start | Slow pages for several minutes | Preload the hottest keys; move traffic over gradually |
| Lookups for ids that do not exist | Each one reaches the database | Cache "not found" briefly; reject impossible ids early |
The last row is called cache penetration: a bug or a scraper asking for restaurant ids that do not exist misses every time, because there is nothing to cache. A short-lived "not found" marker handles the common case, and a Bloom filter (a compact structure that can answer "definitely not present") stops the rest before they reach the database.
Treat the cache as a speed-up, not a dependency. Turn it off in a test environment and check that the site still works, slower, with the database protected by limits rather than falling over.
In the interview
Almost every read-heavy design gets a cache, and a strong answer says what is cached, how it stays fresh and what happens when it fails, each with a number.
- Say
- "Menus are read about 100 times per edit, so cache-aside: delete the key on every write, with a 5-minute TTL as the backstop."
- Say
- "For 5 seconds after a write, fills read the primary, so a lagging replica cannot put the old menu back."
- Say
- "Memory says one node, about 10 GB, but 500,000 reads a second says eight; I size by the larger and give each a replica."
- Avoid
- "Add a cache to make it fast", with no hit ratio: at 95%, losing the cache sends the database twenty times its normal reads.
- Avoid
- Caching the balance that approves a payment. It must be exact at that moment, so the speed is not worth the risk.
Wrap-up
| Term | Meaning |
|---|---|
| Cache-aside | The app reads the cache, fills it from the database on a miss, deletes on write |
| TTL | Time to live: how long an entry stays before it expires |
| LRU | Evict the entry that has gone unused for the longest time |
| Hot key | One key with so much traffic that its node becomes the bottleneck |
| Version tombstone | A marker left on write that rejects fills older than the new version |
| Stampede | Many requests missing the same expired key and running the same query at once |
| Cache penetration | Lookups for ids that do not exist, which miss every time |
Next: Message Queues. A cache absorbs repeated reads; a queue absorbs bursts of writes and work, so a spike becomes a backlog instead of an outage.
Part 6 · Check yourself
Quick check
Q1Your app keeps carts only in the cache. Why is that cache now primary data, and what changes in how you run it?
Think it through, then check.
- It holds the only copy, so eviction, a restart or a failover empties carts that nothing can reload.
- Run it like a database (replicas, persistence, an eviction policy that never drops carts), or store carts durably with the cache in front.
Q2After a menu edit, customers sometimes see the old menu for exactly 5 minutes, mostly at the dinner peak, though every write deletes the key successfully. Why, and what fixes it?
Think it through, then check.
- Fills read replicas. At the peak a replica lags, so a miss just after the delete caches the old menu with a fresh TTL, and nothing deletes it again.
- Fill from the primary just after a write, delete again once the lag has passed, or leave a version tombstone that rejects older fills.
Q3On a quiet afternoon the app makes 80,000 reads a second at a 99% hit ratio; the database can take about 4,000. The cache restarts empty. What happens, and what hit ratio must it reach before full traffic returns?
Think it through, then check.
- At first every read misses: 80,000 a second, twenty times what the database can take.
- Misses must stay under 4,000 a second, 5% of 80,000, so the hit ratio must reach at least 95%.
- Preload the hottest keys, shift traffic over gradually and cap concurrent database queries.
Q4A restaurant page shows a rating that must reflect a new review at once, and a "viewed 1,200 times today" counter. Pick a write pattern for each, and say what you give up.
Think it through, then check.
- Rating: write the database, then delete the key, so the next read loads the new value. You pay one miss per review.
- Counter: write-behind, counting in the cache and flushing every few seconds. A node that dies before a flush loses a few seconds of views.
Q5From the key-value store chapter: virtual nodes spread keys evenly around the ring. Yet at the innings break one cache node runs out of CPU and network while the others idle. Why, and what helps?
Think it through, then check.
- Virtual nodes balance keys per node, not traffic per key: every read of
home:mumbailands on the one node that owns it. - Keep that key in each app server's memory for a second (100 servers then send about 100 reads a second), or spread copies
#1to#8over several nodes.
All chapters · Previous: Monitoring: Metrics, Logs, Traces and Alerts