Part III · Chapter 5 · Cache and Queue · lesson 3
The cache restarted and the database fell over
13 min
Builds on Should you put Redis in front of it? and Why it gets slow at 70% busy, not at 100%
Part III · Chapter 5 · Cache and Queue · lesson 3
13 min
Builds on Should you put Redis in front of it? and Why it gets slow at 70% busy, not at 100%
The Redis instance failed over at 14:02. It came back empty, as it does. Within a few seconds Postgres was at every connection it had, the pool was exhausted, the API was returning gateway timeouts, and the cache — now warm again — was being filled by requests that had already given up and been retried.
The cache was down for about four seconds. The outage ran to eleven minutes.
Predict
Three multipliers stack when a cache empties, and they are worth separating because each has a different fix.
Hit-rate inversion. The origin was sized for the miss traffic. Losing the cache multiplies its read load by one over the miss rate, which at a high hit rate is a large number.
Concurrent duplicate misses. Without coordination, every in-flight request for the same key issues its own origin query. One popular key becomes as many queries as you have concurrent readers.
Retry amplification. Requests that time out come back. Arrivals rise exactly when the service rate has fallen, which is the mechanism by which a brief saturation becomes a sustained one.
The first is arithmetic you cannot argue with. The second and third are design choices, and they are where all the available relief lives.
Request coalescing, also called singleflight. One in-flight origin call per
key; every other caller waits on its result. In Go this is
golang.org/x/sync/singleflight. On the JVM, Caffeine's LoadingCache holds a
per-key lock during load and gives you this for free. In Python, a dict of
asyncio.Future keyed by cache key, which is thirty lines and worth owning.
nginx has proxy_cache_lock on, and Varnish coalesces by default.
The limit worth stating out loud: coalescing is per process. Twenty application
instances with perfect singleflight produce twenty origin queries per key, not
one. Across a fleet you need a shared lock — SET lock:key token NX PX in
Redis, with the loser either waiting briefly or serving stale — and that lock
lives in the thing that just restarted empty.
Staggered expiry. Add jitter to every time-to-live so keys do not expire together. Without it, any event that fills the cache in bulk — a deploy that warms it in a loop, a nightly job, the recovery from the last incident — writes a cohort of keys with one expiry instant, and you get this outage again on a schedule. Jitter of ten to twenty per cent of the window, applied at write time, costs one line.
Jitter does nothing for the case in the opening, where the cache lost all its keys at once. It prevents the self-inflicted repeat, which is the more common version of this incident.
Early refresh, so expiry is not a cliff. Serve the stale value and refresh
in the background: stale-while-revalidate in HTTP, proxy_cache_use_stale updating in nginx, refreshAfterWrite in Caffeine, or probabilistic early
expiry — refresh with rising probability as the expiry approaches, which is the
XFetch result and about four lines of arithmetic. The hot keys then never expire
under load, because something always refreshes them first.
And the one that actually keeps the database alive: a bounded connection pool,
with a queue you have sized and a timeout shorter than the caller's. Not a
cache mechanism at all. Postgres degrades badly past a few hundred active
connections, so the pool's maximum is the thing that decides whether a cold
cache is a slow minute or a dead database. SetMaxOpenConns in Go, HikariCP's
maximumPoolSize, PgBouncer in transaction mode with a small server pool in
front of a large client pool.
Order of operations, if you do one thing: bound the pool. Coalescing and jitter reduce how often you need that bound to save you. The bound is what saves you.
Singleflight in process, jitter on every write, stale-while-revalidate on
anything whose staleness you can tolerate for a few seconds, and a connection
pool whose maximum is a number somebody chose deliberately with a queue timeout
below the client's.
Then the part that is not a mechanism: a load-shedding decision made in advance. When the pool queue is full, the service returns an error immediately rather than waiting — and it returns it to the cheapest requests first if you can tell them apart. Shedding a tenth of traffic to keep nine tenths is a decision somebody has to make while nothing is on fire, because during the incident it will look like making things worse.
What I would not do is size the database to serve the full read load unaided. That is the correct answer for a system where losing the cache must be a non-event, and it costs you the entire saving the cache was bought for.
What would you do
A read-heavy API behind a single managed Redis with automatic failover. A warm hit rate in the mid nineties. One Postgres primary, shared with the write path, sitting behind a pool whose maximum nobody has ever changed from the framework default.
Two weeks of engineering time. The failover above was the second in six months, and both times the database was the thing that fell over rather than the cache.
Check
Everything above assumes the database cannot serve the full read load. Check that, because it is cheap to check and it changes the entire design.
Run a load test at peak-equivalent traffic with the cache disabled in configuration — not flushed in production, disabled in a test environment with production-shaped data. If the origin holds, you own an optimisation: the cache makes things faster and losing it makes them slower, and none of the machinery in this lesson is worth building. Jitter is still free. Skip the rest.
If the origin does not hold, you own a dependency, and the honest consequence is that your availability is now the product of two services rather than one. A cache you cannot survive losing belongs on the dependency list, in the architecture diagram, and in whatever number you have promised anybody about uptime.
The test has a shelf life. Traffic grows, and a system that could serve its full read load unaided last year is a system with a cold-cache dependency this year, discovered during the incident. Put the load test on a calendar, and re-run it whenever the hit rate or the traffic moves by a factor that matters.
Caches get drawn as an optimisation because that is how they were introduced. Somebody added one to make a page faster, and the diagram it appeared in was a performance diagram.
The transition from optimisation to dependency happens quietly and without a commit: traffic triples, the origin is never resized because the cache absorbed the growth, and one day the system cannot run without it. No code changed. The diagram did not change. What changed is that the hit rate became load-bearing.
This is why the load test above is worth more than any amount of reasoning. It is the only artefact that distinguishes the two states, and the answer it gives expires.