sehaj/learn
System design/06 Caching

06.01 · concept · 9 min

Where caches live

A request passes through half a dozen places that can keep a copy: the browser, a CDN, a proxy, the app's own memory, a shared cache and the database. What each layer is good for, and what it costs.

The problem: "add a cache" isn't one decision

By the end of Module 05, Snip already has caches in three places: a CDN for its static files, a tiny in-process cache on each app server for viral codes, and a Redis cluster for every redirect. They weren't planned as a set; each was added when something hurt. Now the team wants to know: where should a copy of a link live, and why there and not somewhere else?

Follow one redirect for snip.link/x7Kp from a phone in Sydney. Every hop it passes is a place that could answer it:

Every place a redirect could be answered
missmissBrowserHTTP cacheCDN edgenear the userReverse proxyresponse cacheSnip appin-process cacheRedisshared cachePostgresbuffer cache
Each layer only sees what the layers in front of it missed.

6 steps. Use Next and Back to follow the request.

Text version
  1. The browser keeps responses it's allowed to (by their Cache-Control headers). A repeat click answered here costs nothing at all.
  2. A CDN edge in Sydney can answer for every user in the city: one copy, shared by thousands, a few milliseconds away.
  3. A reverse proxy in front of the app (03.07) can cache whole responses too, shared by every user who reaches that datacenter.
  4. Inside the app, a small map in memory answers in about 100 ns, but only for the requests that land on that one server.
  5. Redis is shared by all the app servers: one network round trip (about 0.5 ms) and the same answer whichever server asks.
  6. Even the database caches: Postgres keeps recently used pages in memory, and the operating system keeps more. A 'database read' is often a memory read.
  • Browser → CDN edge
  • CDN edge → Reverse proxy
  • Reverse proxy → Snip app
  • Snip app → Redis: miss
  • Snip app → Postgres: miss

The layers, compared

LayerTypical cost of a hitShared byWho can clear it
BrowserNothing: no request is sentOne userOnly expiry: you can't reach into someone's browser
CDN edgeA few ms, nearbyEveryone near that edgeYou, by purging, or expiry
Reverse proxyUnder a ms, inside the datacenterEveryone reaching that datacenterYou
In-process (app memory)About 100 nsRequests on one app serverEach server separately
Shared cache (Redis, Memcached)About 0.5 ms round tripEvery app serverYou, with one delete
Database buffer cacheA query's CPU, no diskEvery queryThe database, automatically

Two things change as you move outwards, towards the user. Hits get cheaper and save more work, because a hit at the edge skips everything behind it. But you get less control: a copy in a browser stays until it expires, whatever you do. That's why the outer layers suit data that's the same for everyone and rarely changes (fingerprinted files, 02.05 A CDN for static content), and the inner layers suit data that changes or is personal.

Local or shared?

The biggest design choice inside the datacenter is whether the app keeps its own copy in memory or asks a shared cache.

In-process cacheShared cache (Redis)
SpeedFastest: no networkOne round trip
Hit rateEach server warms its own copy, so lowerOne copy serves everyone, so higher
After a deployEvery new server starts emptyStays warm
Consistency40 servers, 40 copies that can disagreeOne copy
MemoryTakes memory from the appIts own machines

The common answer is both, as two levels: a small in-process cache with a short TTL (seconds) for the hottest keys, in front of a large shared cache with a longer one. The in-process level absorbs viral traffic (05.02 Hot partitions and celebrity keys); the shared level gives a high hit rate and survives deploys. The short TTL bounds how long the 40 local copies can disagree.

The danger: a cache you can't live without

A cache that's down should make Snip slower, not stop it. But at 100,000 redirects a second with a 99% hit rate, the database sees 1,000 reads a second, and it was sized for that. If Redis disappears, it sees 100,000, a hundred times its normal load, and falls over. The cache has stopped being an optimisation and become part of the capacity.

Ways to stay honest about it:

  • Know the number. Work out what the database would see with the cache empty, and decide whether you can survive it.
  • Protect the database anyway. Limit how many cache misses can reach it at once (03.11 Backpressure and load shedding), and coalesce them (06.05 Stampedes and request coalescing), so a cold cache means slow requests rather than a dead database.
  • Warm before serving. Bring new cache nodes or regions into use gradually, filled from a warm copy.
  • Test it. Turn the cache off in a test environment, or empty one node in production, and watch what happens.

Seen in production

Amazon

2019

Service caches

The article compares in-memory caches inside each service with external caches such as Memcached or Redis. In-memory caches suffer from cold starts: a new server begins empty and sends a burst of requests to the service behind it. It describes a service that "has become addicted to its cache", where behaviour differs sharply depending on whether an item is cached, and recommends planning for the cache being empty. It also covers caching error responses with their own TTL, and keeping a soft TTL and a hard TTL, so stale data can be served when the service behind the cache is down.

What we foundLocal caches start cold on every new server, and a service that has grown used to its cache can fall over without it.

Caching challenges and strategies (Jas Chhabra, Amazon Builders' Library)builder.aws.com ↗
Predict first

Snip deploys 40 new app servers every afternoon. Right after each deploy, the database's read load triples for a minute. Which cache is the likely cause?

Commit to an answer before you read on.

Recap

Interview questions

MidfreqMust-knowConcept

Where can you cache in a web system, and how do you decide where to put a cache?

Report a mistakeEdit this page
☰ Caching06.01 · 47/147next →