DEV Community

Ammar Eyad
Ammar Eyad

Posted on

Common Cache Production

Cache Stampede

Cache Stampede, Hot Keys, and Cache Penetration

Caching is one of the most common ways to improve application performance and reduce database load.

But as traffic grows, caching introduces its own problems.

Three common ones are:

  • Cache Stampede
  • Hot Keys
  • Cache Penetration

Let's understand each one with simple examples.


1. Cache Stampede

A cache stampede (also called the thundering herd or dogpiling problem) happens when a popular cached value expires and many requests try to fetch the same data from the database at the same time.

Instead of protecting the database, the cache suddenly becomes the reason the database gets overloaded.

When and Why Does It Happen?

Normally, we put a cache such as Redis in front of our database:

Client
   ↓
Application
   ↓
  Cache
   ↓
 Database
Enter fullscreen mode Exit fullscreen mode

Cached data usually has a Time To Live (TTL) so that it doesn't stay stale forever.

A cache stampede can happen when:

1. High Concurrency

Thousands of users request the same piece of data.

For example:

GET homepage
GET homepage
GET homepage
GET homepage
...
Enter fullscreen mode Exit fullscreen mode

2. Simultaneous Expiration

The cached value reaches its TTL and expires.

Cache
product:123 → EXPIRED
Enter fullscreen mode Exit fullscreen mode

3. Race Condition

The first request goes to the database to retrieve the data.

But before that request finishes and updates the cache, thousands of other requests also see a cache miss.

Request 1 ──→ Cache MISS ──→ Database
Request 2 ──→ Cache MISS ──→ Database
Request 3 ──→ Cache MISS ──→ Database
Request 4 ──→ Cache MISS ──→ Database
...
Request 10000 ─→ Cache MISS ──→ Database
Enter fullscreen mode Exit fullscreen mode

Now thousands of requests are doing the same database query.

This can overload the database and potentially cause it to slow down or crash.


How Do We Solve a Cache Stampede?

The main goal is simple:

Don't allow thousands of requests to perform the same database work simultaneously.

There are several approaches.

1. Locking

When the cache expires, the first request acquires a lock.

Other requests wait briefly instead of querying the database.

Request 1 → Cache MISS → Acquire Lock → Database
Request 2 → Cache MISS → Wait
Request 3 → Cache MISS → Wait
Request 4 → Cache MISS → Wait
Enter fullscreen mode Exit fullscreen mode

The first request retrieves the data and updates the cache:

Database
   ↓
Update Cache
   ↓
Release Lock
Enter fullscreen mode Exit fullscreen mode

The waiting requests can then read the newly populated cache.


2. Stale-While-Revalidate

Instead of making users wait for fresh data, we temporarily serve the existing stale value.

At the same time, a background process refreshes the cache.

User Request
     ↓
Stale Cache
     ↓
Return Immediately

Background Process
     ↓
Database
     ↓
Update Cache
Enter fullscreen mode Exit fullscreen mode

This works well when serving slightly outdated data is acceptable.


3. External Pre-Warming

Another approach is to refresh the cache before it expires.

For example, a scheduled job can periodically load popular data:

Cron Job
   ↓
Database
   ↓
Update Cache
Enter fullscreen mode Exit fullscreen mode

This means user traffic doesn't have to be the thing that triggers the cache refresh.


2. Hot Keys

A hot key happens when a single cache key receives a very large number of requests.

For example:

product:123
Enter fullscreen mode Exit fullscreen mode

Imagine we have:

  • 10 application servers
  • 100,000 user requests
  • One Redis server

The load balancer distributes requests across the application servers:

                Load Balancer
                 /    |    \
                /     |     \
            App 1   App 2   App 3 ... App 10
               \       |       /
                \      |      /
                    Redis
Enter fullscreen mode Exit fullscreen mode

The application servers can scale horizontally.

But if all requests need the same Redis key:

product:123
Enter fullscreen mode Exit fullscreen mode

then all application servers send requests to Redis.

App 1 ──┐
App 2 ──┤
App 3 ──┤
App 4 ──┤
...     ├──→ Redis
App 10 ─┘
Enter fullscreen mode Exit fullscreen mode

The problem is that one Redis node is receiving a huge amount of traffic for the same key.

The Redis CPU or network capacity can become the bottleneck.


Solution 1: L1 / L2 Cache

One of the simplest solutions is to introduce a local cache.

We can think of the caches as:

L1 → Application Server Local Memory
L2 → Redis
Enter fullscreen mode Exit fullscreen mode

The application checks L1 first.

If the data isn't there, it checks Redis.

Request
   ↓
L1 Cache
   ↓
 Cache Hit? ── Yes → Return
   │
   No
   ↓
Redis
   ↓
Store in L1
   ↓
Return
Enter fullscreen mode Exit fullscreen mode

Without L1 Cache

Suppose we have:

  • 10 application servers
  • 100,000 requests

Without L1:

100,000 requests
       ↓
100,000 Redis calls
       ↓
Redis becomes the bottleneck
Enter fullscreen mode Exit fullscreen mode

With L1 Cache

Each application server can keep the hot data in local memory for a short period, for example 5 seconds.

Initially:

App 1 → Redis
App 2 → Redis
App 3 → Redis
...
App 10 → Redis
Enter fullscreen mode Exit fullscreen mode

After that, each server has the value locally.

The remaining requests can be served directly from memory:

100,000 requests
       ↓
   L1 Cache
       ↓
Most requests served locally
Enter fullscreen mode Exit fullscreen mode

So instead of sending every request to Redis, each application server can serve the majority of requests from its own memory.

Why Is L1 So Effective?

1. No Network Overhead

Reading from local memory is much faster than making a network call to Redis.

Local Memory → Very fast
Redis        → Network round trip
Enter fullscreen mode Exit fullscreen mode

2. Application Servers Scale Horizontally

You can add more application servers behind a load balancer.

This helps distribute user traffic across multiple machines.

A single hot Redis key, however, can still concentrate traffic on one Redis node.


Solution 2: Key Splitting

Another approach is to split a hot key into multiple keys.

In Redis Cluster, a single key is mapped to a single shard.

So instead of:

product:123
Enter fullscreen mode Exit fullscreen mode

we can create multiple copies:

product:123:1
product:123:2
product:123:3
...
product:123:100
Enter fullscreen mode Exit fullscreen mode

When a request arrives, the application chooses one of the keys.

For example:

Request 1 → product:123:17
Request 2 → product:123:42
Request 3 → product:123:8
Request 4 → product:123:91
Enter fullscreen mode Exit fullscreen mode

Now the traffic can be distributed across different Redis cluster nodes.

The Idea

Instead of:

100,000 requests
       ↓
product:123
       ↓
One Redis shard
Enter fullscreen mode Exit fullscreen mode

we can have:

100,000 requests
       ↓
 ┌─────┼─────┐
 ↓     ↓     ↓
Key 1 Key 2 Key 3 ... Key 100
Enter fullscreen mode Exit fullscreen mode

This spreads the read traffic instead of hammering a single Redis key.


Solution 3: Read Replicas

If you're using a Redis primary-replica architecture, another option is to add read replicas.

The basic idea is:

              Primary
             /   |   \
            /    |    \
       Replica Replica Replica
Enter fullscreen mode Exit fullscreen mode

Writes go to the primary:

Application
     ↓
 Primary
Enter fullscreen mode Exit fullscreen mode

Reads can be distributed across replicas:

Application
     ↓
 ┌───┼────────┐
 ↓   ↓        ↓
R1   R2       R3
Enter fullscreen mode Exit fullscreen mode

This allows the system to distribute a large number of read requests across multiple Redis nodes.


3. Cache Penetration

Cache penetration happens when requests repeatedly ask for data that doesn't exist.

For example:

GET /users/999999
Enter fullscreen mode Exit fullscreen mode

If the user doesn't exist:

Request
   ↓
Redis
   ↓
Cache MISS
   ↓
PostgreSQL
   ↓
User NOT FOUND
Enter fullscreen mode Exit fullscreen mode

The problem becomes serious when thousands of requests are made for invalid IDs:

/user/999999
/user/837462
/user/123456
/user/555555
...
Enter fullscreen mode Exit fullscreen mode

Every request misses the cache and reaches the database.

Request 1 → Redis MISS → PostgreSQL → NOT FOUND
Request 2 → Redis MISS → PostgreSQL → NOT FOUND
Request 3 → Redis MISS → PostgreSQL → NOT FOUND
...
Request 10000 → Redis MISS → PostgreSQL → NOT FOUND
Enter fullscreen mode Exit fullscreen mode

Unlike a cache stampede, the problem here isn't that valid cached data expired.

The problem is that the requested data doesn't exist in the first place.


Solution: Bloom Filter

A Bloom Filter is a memory-efficient data structure that can quickly tell us whether an item might exist in a dataset.

The application can check the Bloom Filter before querying Redis or PostgreSQL.

Request
   ↓
Bloom Filter
   ↓
Could this ID exist?
   /          \
 No            Yes
 ↓              ↓
Return        Redis
Not Found       ↓
             PostgreSQL
Enter fullscreen mode Exit fullscreen mode

For example, suppose we have these users:

1
2
3
4
5
Enter fullscreen mode Exit fullscreen mode

The Bloom Filter knows about these IDs.

Now a request comes for:

999999
Enter fullscreen mode Exit fullscreen mode

The Bloom Filter can say:

999999 → Definitely does not exist
Enter fullscreen mode Exit fullscreen mode

So we can stop the request immediately without hitting Redis or PostgreSQL.

Important Bloom Filter Property

A Bloom Filter can have false positives, but it does not have false negatives.

In simple terms:

Bloom Filter → "Definitely doesn't exist"
        ↓
     Stop here

Bloom Filter → "Might exist"
        ↓
   Check Redis
        ↓
   Check Database
Enter fullscreen mode Exit fullscreen mode

So if the Bloom Filter says an ID doesn't exist, we can safely reject the request.

If it says the ID might exist, we continue normally.

Why Is This Useful?

Imagine:

1,000,000 invalid requests
Enter fullscreen mode Exit fullscreen mode

Without a Bloom Filter:

1,000,000 requests
        ↓
      Redis
        ↓
  PostgreSQL
Enter fullscreen mode Exit fullscreen mode

With a Bloom Filter:

1,000,000 requests
        ↓
   Bloom Filter
        ↓
Most invalid requests stopped here
        ↓
Only possible valid requests
        ↓
      Redis
        ↓
   PostgreSQL
Enter fullscreen mode Exit fullscreen mode

This can significantly reduce unnecessary traffic to Redis and PostgreSQL.

Bloom Filter is especially useful when the database contains a large number of IDs and the system receives many requests for IDs that don't exist.


Cache Stampede vs Hot Key vs Cache Penetration

These three problems are related, but they are not the same.

Problem Main Issue Typical Solutions
Cache Stampede Many requests hit the database after cache expiration Locking, stale-while-revalidate, pre-warming
Hot Key One cache key receives too many requests L1 cache, key splitting, read replicas
Cache Penetration Requests repeatedly ask for data that doesn't exist Bloom Filter, negative caching, validation

Simple Way to Remember

Cache Stampede:

The cache expires and everyone goes to the database.

Hot Key:

Everyone keeps asking Redis for the same key.

Cache Penetration:

Everyone keeps asking for data that doesn't exist.

Understanding the difference helps you choose the right solution when designing a high-scale system.

Top comments (0)