Skip to lesson
‹

Courses / System Design Interview / Chapter 13

Module 2 · Building Blocks · Chapter 13 of 39

Distributed Caches

16 sections · ~33 min✓ Fact-checked

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 fromRough timeWhat it costs
A map inside your own server processabout 0.1 µsNothing extra, but every server keeps its own copy
A distributed cache on the same networkunder 1 msOne network round trip
A database, simple lookup by keyabout 1 to 10 msA connection, CPU, sometimes a disk read
A database, query with joins or sorting10 to 100 ms or moreHeavy 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:

LayerWhat it holdsShared byTypical size
Browser or appImages, scripts, the last screen you sawOne userMegabytes
CDN edgeRestaurant photos, static filesUsers near that edgeTerabytes across all edges
In-process cacheConfig, feature flags, a small hot listOne server processMegabytes to a few GB
Distributed cacheMenus, home screens, carts, query resultsAll app serversGigabytes to terabytes
Database buffer cacheRecently used pages of tables and indexesThe database itselfA large share of its RAM
Browser or app

What it holdsImages, scripts, the last screen you saw

Shared byOne user

Typical sizeMegabytes

CDN edge

What it holdsRestaurant photos, static files

Shared byUsers near that edge

Typical sizeTerabytes across all edges

In-process cache

What it holdsConfig, feature flags, a small hot list

Shared byOne server process

Typical sizeMegabytes to a few GB

Distributed cache

What it holdsMenus, home screens, carts, query results

Shared byAll app servers

Typical sizeGigabytes to terabytes

Database buffer cache

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.

A cart kept only in the cache is primary data

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.

Names you will hear

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.

python
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.

Animated sceneCache-aside: a miss, a hit, then an edit0:29

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.

It fails gently

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:

PatternFails whenUse for
Cache-asideA slow or lagging fill puts an old value back; an empty cache sends every read to the databaseMost read-heavy data, such as menus
Read-throughThe same, and reads fail outright if the cache layer is down and the app cannot reach the databaseTeams sharing one data access layer
Write-throughOne of its two writes fails and the stores disagree; values nobody reads still fill memorySettings read right after a change
Write-behindThe cache dies before it flushes, and those writes are goneView counts and likes
Write-aroundEvery first read after a write missesData 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.

Write-behind is a durability choice

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:

PolicyRemovesWorks well whenWeak spot
LRU (least recently used)The entry unused for the longest timeRecently read data is read again: menus, sessionsOne big scan of cold keys can push out the hot ones
LFU (least frequently used)The entry read the fewest timesPopularity is steady, like a famous restaurantOld favourites linger unless counts fade over time
FIFO (first in, first out)The oldest entrySimplicity matters more than hit ratioIgnores how popular an entry is
RandomAny entryReads are spread evenlyCan drop a hot key
TTL expiryEntries past their time to liveData has a natural freshness limitMemory can fill before TTLs run out
LRU (least recently used)

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

LFU (least frequently used)

RemovesThe entry read the fewest times

Works well whenPopularity is steady, like a famous restaurant

Weak spotOld favourites linger unless counts fade over time

FIFO (first in, first out)

RemovesThe oldest entry

Works well whenSimplicity matters more than hit ratio

Weak spotIgnores how popular an entry is

Random

RemovesAny entry

Works well whenReads are spread evenly

Weak spotCan drop a hot key

TTL expiry

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.

python
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.

Check two settings

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:

text
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:mumbai in 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#1 to #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:

ApproachHow long data can be staleCost
TTL onlyUp to the TTLNothing extra; fine when some staleness is acceptable
Delete on writeUsually milliseconds; a rare race can keep an old value until the TTLOne extra call per write
Update on writeShort, but two racing writers can leave the older valueOne extra call per write, plus building the value
Change data capture (CDC)A second or so, depending on the pipelineA service that turns each committed change into a delete
Versioned keysNone for readers that know the new versionOld 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:

How a stale value sneaks back in
  1. 1A reader misses the cacheit reads menu v1 from the database, then stalls on a long pause or a slow network
  2. 2A writer saves v2the database now holds the new menu
  3. 3The writer deletes the keythere is nothing to delete yet
  4. 4The reader wakes up and caches v1an old value with a fresh TTL
  5. 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.
Animated sceneA stale refill from a lagging replica0:21

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.

DefenceHow it worksCost
Fill from the primary after a writeThe writer sets a marker, recent:menu:42, for longer than the worst lag, say 5 s; a fill that finds it reads the primaryOne extra cache lookup per miss; extra primary reads only for keys just written
Delayed second deleteDelete the key again after a delay longer than the lag, say 3 s, from a timer or a queueOne more call per write; if the lag ever exceeds the delay, the race returns
Version tombstoneInstead 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 atomicallyA version column on every row, and a small script in the cache
TTL as the backstopAny stale value dies when its TTL endsMore 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.

Animated sceneA stampede, then the same moment with coalescing0:22

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:

FixHow it worksCost
Request coalescingOne 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 serversWaiters pay one load's latency; if the lock holder dies, they wait for its few-second expiry
Jittered TTLs300 s ± 30 s, so keys written together do not expire togetherAlmost free
Early refreshNear expiry, each read reloads in the background with a small chance that grows as the TTL runs outA little extra database load
Serve stale while reloadingReturn the old value briefly past its TTL while one request reloadsUsers 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:

FailureWhat users seeDefence
One cache node diesMisses for its share of keysA replica per shard; consistent hashing so only about 1/N of keys move
Whole cluster downSlow pages, then database timeoutsCap concurrent database queries, shed load, serve a simpler page
Cold startSlow pages for several minutesPreload the hottest keys; move traffic over gradually
Lookups for ids that do not existEach one reaches the databaseCache "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.

Test life without the cache

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

TermMeaning
Cache-asideThe app reads the cache, fills it from the database on a miss, deletes on write
TTLTime to live: how long an entry stays before it expires
LRUEvict the entry that has gone unused for the longest time
Hot keyOne key with so much traffic that its node becomes the bottleneck
Version tombstoneA marker left on write that rejects fills older than the new version
StampedeMany requests missing the same expired key and running the same query at once
Cache penetrationLookups 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.

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.

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.

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.

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.

Next chapter · 14 of 39Message Queues~34 min · 3 animated scenes