Caching with Redis: Cache-Aside and the 3 Classic Failures

There are only two hard things in Computer Science: cache invalidation and naming things. – Phil Karlton

7 min read
On this page
This article is also available in Tiếng Việt.
banner

Hi everyone 👋. I'm Hung Anh.

When a database starts buckling under read traffic, nearly every backend engineer's first answer is: "put Redis in front of it". Fair enough — a Redis read costs about 0.1ms, while even a well-indexed MySQL query takes a few to tens of milliseconds. That's a hundred-fold gap.

But a cache isn't plug-and-play. There's a pattern you need to get right, an awkward question about consistency, and three classic failurespenetration, breakdown, avalanche — that have knocked over plenty of systems right at peak hour. Today I'll go through each one, with concrete defences.

Let's get started.

Cache-Aside: the foundational pattern

The most common way to use Redis as a cache is Cache-Aside (a.k.a. Lazy Loading): the application orchestrates everything; the cache and the DB know nothing about each other.

Reads:

  1. Ask Redis first (GET product:42).
  2. Hit → return it, done.
  3. Miss → query the DB → write the result into Redis with a TTL (SET product:42 {...} EX 600) → return it.

Writes:

  1. Update the DB first.
  2. Delete the cache key (DEL product:42) — not update it.

In pseudo-code:

public Product getProduct(long id) {
    String key = "product:" + id;

    Product cached = redis.get(key);
    if (cached != null) return cached;           // hit

    Product fromDb = db.findProductById(id);      // miss → DB
    redis.set(key, fromDb, Duration.ofMinutes(10));
    return fromDb;
}

public void updateProduct(Product p) {
    db.update(p);                                 // 1. DB first
    redis.del("product:" + p.getId());            // 2. then delete the cache
}

Why "delete the cache" instead of "update the cache"?

Two reasons:

  • Avoid wasted writes: plenty of data gets updated and then isn't read again for a while. Updating the cache means eagerly writing something nobody may need; deleting it and letting the next read repopulate is lazy in the productive sense.
  • Avoid write races: two requests update the same product; the DB applies B-after-A but the cache writes may land A-after-B — and now the cache holds stale data indefinitely. If you delete the key instead, the worst case is one extra cache miss.

And why "DB first, delete cache second"?

The reverse order (delete cache, then write DB) has an obvious hole: between your delete and the DB write completing, another read arrives — miss → reads the old value from the DB → stores it back into the cache. Result: stale data squatting in the cache until the TTL expires.

DB-first-delete-second isn't perfectly safe either (there's a theoretical, extremely narrow window when a slow miss-read straddles a write), but the odds are orders of magnitude smaller — and the TTL is always there as the final safety net. You're accepting time-bounded eventual consistency, which is the standard price of caching.

The three classic failures

A correct pattern still isn't enough. The following three scenarios share one ending — load the cache was supposed to absorb suddenly lands on the DB — but their causes and cures are completely different:

The three cache failures: penetration, breakdown, avalanche — causes and fixes

1. Cache Penetration — asking for what doesn't exist

A request asks for a key that exists in neither the cache nor the DB (say user_id = -1). Miss → hit the DB → the DB says "not found" → there's nothing to cache → the next request punches straight through again. If someone deliberately sprays junk IDs, your DB takes every punch.

Defences:

  • Cache the empty result too: when the DB says "not found", still SET key "" EX 60 — short TTL. Subsequent junk requests die at the cache layer.
  • Bloom filter: load all valid IDs into a Bloom filter; if the filter says an ID "definitely doesn't exist", reject the request at the door — no cache lookup, no DB query. (Redis Stack ships BF.ADD / BF.EXISTS.)
  • Don't skip the most basic layer: input validation — negative or malformed IDs should be rejected at the API.

2. Cache Breakdown — one hot key expires

A product page is on fire: 5,000 requests/second, all hitting the cache. Then the TTL of that exact key runs out. In an instant, 5,000 requests all miss and all rush the DB with the same query — the DB gets crushed by the expiry of a single key.

Defences:

  • Mutex / single-flight: let exactly one request rebuild the cache (use SET rebuild:key 1 NX EX 10 as a lock); everyone else waits a few dozen milliseconds and re-reads the cache. One query instead of five thousand.
  • Logical expiration: set no real TTL in Redis; store a "logically expires at" timestamp inside the value. When a read sees it's past due, it returns the stale data immediately and kicks off a background rebuild — users never wait on the DB.
  • For a handful of predictably scorching keys (home page, flash-sale product): warm them up and never let them expire naturally — refresh proactively on a schedule.

3. Cache Avalanche — the whole wall collapses at once

Two scenarios:

  • Mass expiry: you warm up a million keys at 00:00, all with TTL 3600 — at exactly 01:00 the whole million dies within the same second, and all traffic punches through to the DB.
  • Redis goes down: the cache node dies; same effect, even more abrupt.

Defences:

  • TTL + jitter: add a random amount to every TTL (3600 + random(0, 300)) so expiries spread out instead of clustering.
  • Redis HA: replicas + Sentinel, or Redis Cluster — a cache is critical infrastructure, not a nice-to-have.
  • Application-layer defence: rate-limits / circuit breakers in front of the DB, and a prepared degrade mode (serve slightly stale data, switch off secondary features) for when the cache is gone. Serving slower and thinner beats going down entirely.

Notice the common thread: all the cures follow one philosophy — never let the DB face the flood alone. Either block the flood upstream (Bloom filter, jitter), narrow the flow (mutex, rate-limit), or accept serving stale data (logical TTL, degrade).

Conclusion

Cache-Aside is only four steps, but doing it right is a chain of decisions: delete instead of update, DB before cache, TTL as the last safety net. And before your cache goes to production, ask all three questions:

  1. What if the key doesn't exist? → empty-value caching / Bloom filter
  2. What if the hottest key expires? → mutex / logical TTL
  3. What if a whole batch expires together (or Redis dies)? → jitter / HA / degrade

Answer those three with concrete design, and your cache layer becomes a real shield — instead of a time bomb parked in front of your database.

See you in the next posts. If you found this useful, don't forget to share it and leave a comment below 👇.

References

Do Hung AnhDo Hung Anh@anhdh

Related articles

Transaction Isolation Levels: The 4-Step Ladder in MySQL

What happens when two transactions read and write the same row at the same time? Depending on the isolation level you choose, the very same SELECT can return very different results. In this post I walk through the three read anomalies, the four SQL isolation levels, and how InnoDB implements them with MVCC — with runnable examples you can verify yourself.

7 min read

Distributed Locks and How to Implement Them with Redis

In distributed systems, ensuring data consistency and preventing race conditions is a major challenge, especially when many processes or services access shared resources concurrently. A distributed lock is an effective way to handle this problem. In this article, I will help you understand what a distributed lock is, why it is needed, the different ways to implement one, and how to build it with Redis.

16 min read

COUNT(*) vs COUNT(1): Which One Performs Best?

When counting rows in a table, we reach for the count function out of habit. But the count function accepts several kinds of arguments, such as count(1), count(*), count(field), and so on. So which form of count delivers the best performance?

9 min readDatabase Internals

Ready for more?