Skip to main content

System Design Drill Day 2: Caching, TTLs and Cache Stampedes

How cache-aside caching with Redis works, how to choose TTLs, and how to stop a cache stampede from taking down your database.

Updated 8 min readMarkdown
Part of seriesSystem Design Drills
System Design Drill Day 2: Caching, TTLs and Cache Stampedes

A flash sale starts at noon. Half a minute in, the cache entry for the hottest product expires, every request for it misses Redis in the same instant, and all of them go straight to MySQL. At that point the cache has stopped being a speed boost and become a liability.

This drill puts Redis in front of MySQL for a Laravel product-detail API. We'll pick a TTL, decide what happens on writes, and make the endpoint survive promo traffic when a hot key expires.

Core concepts

A product page shows a name and a price that barely change, yet a naive app asks MySQL for them on every request. Keeping a copy somewhere fast and reusing it is all a cache is. The details are where things go wrong.

When the copy is there, that's a hit and you take the fast path. When it isn't, because it's the first request or the entry expired, that's a miss: you load from MySQL, the source of truth, and usually write the value back so the next request hits.

The TTL (time to live) decides how long a copy stays valid. A short TTL keeps data fresh but misses more often. A long one is faster and risks showing a price that changed several minutes ago.

Trouble starts when many requests miss at the same moment, because a TTL ran out or the cache started cold, and all of them go to the DB together. That's a cache stampede, also called a thundering herd. The usual defences are a lock so only one request refills the cache, a little randomness (jitter) in TTLs, and a soft TTL with a background refresh.

Every cache trades some consistency for latency, so you also have to decide what happens when the underlying data is written. You can invalidate the key, update it in place, or accept some staleness and keep the TTL short. Which one fits depends on the data.

Pause and think: A hot key expires and a crowd of requests miss at once. Which single change protects the DB the most?

Show answer

A short lock on the miss path (SET key token NX EX), so only one request goes to MySQL and the rest re-read Redis or get a slightly stale copy. Jittered TTLs help as well, but against a different problem. They spread out keys that would otherwise expire in the same second; one hot key can still stampede on its own.

The design problem

Our online marketplace has a Laravel API, GET /products/{id}, that returns product detail: name, price and a stock snippet. The DB query is fine at low traffic and spikes under promo traffic.

Design a Redis cache in front of MySQL for this endpoint. Choose a TTL and justify it. Work out the miss path: who writes to Redis, and what the key looks like. Then protect against a stampede when a hot product's TTL expires during a flash sale, and decide what happens on a price or stock update (invalidate, update the cache, or rely on a short TTL).

Boxes are enough: client, Laravel, Redis, MySQL, with the happy path and the miss path labelled. Interview-sketch level, no full code needed.

Try the problem before reading the solution.

Questions to think about

  1. When would you not cache product detail, even if Redis is available?

  2. Invalidate-on-write or a short TTL on its own: which suits flash-sale stock better, and why?

  3. If Redis is down, should the endpoint fail or fall through to MySQL? Weigh availability against the extra DB load.

Model solution

I'd use the key product:detail:{id}, holding a JSON blob. Anything per-user stays off this path entirely, since a shared key must never carry one customer's data.

The read path is plain cache-aside. Laravel checks Redis first and returns on a hit. On a miss it loads from MySQL, SETs the value with a TTL and returns it.

Cache-aside read path: Laravel checks Redis, on a miss reads MySQL and writes the value back with a TTL

Figure 1: Cache-aside: Redis first, MySQL only on a miss, then fill the cache.

Under promo traffic I'd set the TTL somewhere between 30 and 120 seconds. That keeps the price reasonably fresh, though stock will still drift a little. If every update invalidates the key, you can stretch it to 5 to 15 minutes. Both are defensible as long as you can explain the choice.

For the stampede, take a short Redis lock on a miss with SET product:lock:{id} <token> NX EX 5, where the token is a random value so the holder can later release only its own lock. The request that wins the lock loads from the DB and fills the cache; the others wait briefly and re-read Redis, or get a slightly stale soft-expiry copy if you keep one. Jitter the TTL as well (base ± random). Without jitter, keys cached together all expire in the same second, which is a stampede on a timer.

Sequence diagram of two concurrent cache misses where only the request holding the Redis lock queries MySQL

Figure 2: Only the lock holder hits MySQL. Everyone else waits briefly and reads the refilled key.

When the price changes or an admin edits the product, delete (or overwrite) product:detail:{id} and let the next read refill it. Stock is different. It changes with every order, so I wouldn't keep it in the same blob. Give it a short TTL plus invalidation on inventory events, or read it live from a faster counter.

Write path: a price edit deletes the cached key, while stock changes are kept out of the cached blob

Figure 3: Price edits delete the key. Volatile stock lives outside the blob.

If Redis goes down, fall through to MySQL. The data is actually fresher this way, since every read comes straight from the source of truth; the risk is load, because MySQL now takes every request the cache used to absorb. Fire an alert and watch DB load. Failing closed only makes sense if MySQL can't carry the full read load without Redis in front of it.

Answers to the questions

Show answer

1. When the data has to be exact on every read, or differs per user. Checkout should take the price from MySQL (the primary, as Day 4 explains), never from a cached blob, and anything personalised, like a cart or per-customer pricing, doesn't belong on a shared key. I'd also skip it for products almost nobody views. The hit rate would be close to zero, so you pay for an extra Redis round trip and get nothing back.

2. Neither on its own. A short TTL alone means visitors can see stock that's up to one TTL old, and during a flash sale that's exactly when it's wrong. Invalidating on every order means a hot product's key gets deleted constantly, so you're back to misses and fights over the lock. That's why the solution keeps stock out of the blob: a short TTL plus invalidation on inventory events, or a live counter. Either way, the real stock check happens in MySQL at checkout. The cached number is only for display.

3. For a product page, fall through to MySQL. A slower page beats an error page. The catch is that MySQL suddenly takes the full read load the cache was hiding, so give the Redis client a short timeout (otherwise every request hangs on a dead Redis before it even reaches the DB), fire an alert and watch DB CPU. Failing requests only starts to make sense if MySQL can't survive that load.

Interview one-liner

"Cache-aside in Redis keyed by product id, a TTL chosen for how fresh price must be, delete-on-write for updates, volatile stock kept out of the blob, a short lock plus jittered TTLs against stampedes, and fall through to MySQL if Redis is down."

Conclusion

The read path is the easy part of caching; the TTL, the write policy and the behaviour when a hot key expires are what decide whether the cache helps or hurts. Before a flash sale, check that a single expired key can't send every request to MySQL at once.

Try it yourself

Take one slow query in your own Laravel app and wrap it with Cache::remember() and a TTL. Then add Cache::lock('product:lock:'.$id, 5) so only one request rebuilds the value on a miss (Laravel stores a random owner token as the lock value for you, so release() only frees your own lock).

Clear the key and fire 50 concurrent requests at it with a tool like ab or hey. Count the DB queries in the query log with the lock and without it.


This is part of my daily System Design Drills series.

The soft TTL with background refresh is the nicest answer here, the herd never forms because only one worker rebuilds while everyone else serves the stale copy. Picking TTLs per data type instead of one global value is also the part people usually skip.

The jitter point deserves more attention than it gets. If every key gets the same TTL, they were all written in the same deploy and will expire together, so the herd reforms on a schedule. Even 10 percent randomness spreads the misses out enough to matter.

Thanks Abhinav, keys written in the same deploy expiring together is exactly how it bites in practice. A little jitter turns one big stampede into a trickle the database can handle.