Skip to Content
Course content

206: Cache Stampede Prevention Techniques

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I remember the first time I ran into a "Cache Stampede." I had a high-traffic dashboard that pulled a complex set of aggregated statistics from a database. To keep it fast, I cached the result in Redis for ten minutes. Everything was buttery smooth—until it wasn't. Every ten minutes, exactly when the cache expired, my database CPU would spike to 100%, the site would lag for five seconds, and then it would recover. I was baffled because the traffic hadn't actually increased; the site just seemed to "choke" on a schedule.

The 10-Minute Crash

Let's look at the code I had written. It's the classic "Cache Aside" pattern that we've used in previous lessons. It seems perfectly logical on the surface:

public function getTrendingProducts() {
    $cacheKey = 'trending_products';
    $data = $this->cache->get($cacheKey);

    if ($data === null) {
        // The cache expired. Time to hit the DB.
        $data = $this->db->query("SELECT * FROM products WHERE ... complex join ...");
        $this->cache->set($cacheKey, $data, 600); // 10 minute TTL
    }

    return $data;
}

Here is the problem: when that key expires, it doesn't just disappear for one user. It disappears for everyone. If you have 500 concurrent users hitting that page at the exact millisecond the cache expires, all 500 requests see $data === null. They all simultaneously fire that "complex join" query at the database. That's the stampede. You've effectively bypassed your cache and DDOSed your own database.

Trying to Gatekeep with Locks

My first instinct was to make sure only one process could regenerate the cache. I thought, "I'll just use a lock." I tried implementing a simple mutex using Redis' SET NX (set if not exists) command.

if ($data === null) {
    $lockKey = $cacheKey . ':lock';
    if ($this->cache->set($lockKey, 'locked', ['nx', 'ex' => 10])) {
        // I got the lock! I'll update the cache.
        $data = $this->db->query("...");
        $this->cache->set($cacheKey, $data, 600);
        $this->cache->delete($lockKey);
    } else {
        // Someone else is already updating it. 
        // What do I do now?
    }
}

This is where I hit a wall. If I'm one of the 499 users who didn't get the lock, what happens? If I make the user wait (sleep and retry), I'm tying up PHP-FPM workers, and my server will eventually run out of memory or hit the max execution time. If I return an error, the user sees a broken page. Neither is acceptable.

The 'Stale-While-Revalidate' Pivot

I realized the real issue was that I was treating the cache as a binary: it's either there or it's gone. To fix this, I changed how I stored the data. Instead of relying solely on the Redis TTL to delete the key, I started storing the "expiration time" inside the cached value itself, while keeping the actual Redis TTL slightly longer.

Think of it as a "soft" expiration and a "hard" expiration. If the soft expiration has passed, the first person to notice it triggers a refresh, but everyone else keeps getting the slightly old (stale) data until the refresh is done.

public function getTrendingProducts() {
    $cacheKey = 'trending_products';
    $cached = $this->cache->get($cacheKey);
    $now = time();

    if ($cached) {
        // Check if the "soft" TTL has passed
        if ($now > $cached['expires_at']) {
            // It's stale! Try to get a lock to refresh it.
            if ($this->cache->set($cacheKey . ':lock', '1', ['nx', 'ex' => 10])) {
                // I'm the chosen one. I'll update the data in the background.
                // But first, I'll still return the stale data to avoid blocking.
                $this->triggerAsyncRefresh($cacheKey);
            }
        }
        return $cached['data'];
    }

    // Hard miss: No data at all. We have to block and wait here.
    return $this->refreshAndCache($cacheKey);
}

This is a massive improvement. The "stampede" is gone because only one worker ever hits the DB, and the other 499 users get a response in milliseconds—even if that response is 30 seconds out of date. In most web apps, "slightly stale" is a thousand times better than "site is down."

Going Pro: Probabilistic Early Recomputation

If you want to get really fancy, there's a technique called X-Fetch. Instead of waiting for the soft expiration to hit, you use a bit of probability to refresh the cache before it actually expires. As the expiration time approaches, the probability that a request will trigger a refresh increases.

It looks something like this: currentTime - (gap * log(random())) > expiry. I won't bore you with the calculus, but the result is that the cache is refreshed seamlessly in the background by a random lucky user just before it dies, meaning the "hard miss" (where the user actually has to wait) almost never happens.




📋 Practical Task

Exercise: Implementing a Stale-While-Revalidate Wrapper

You are tasked with fixing a "Product Catalog" page that is suffering from cache stampedes. You need to implement a wrapper function that handles the soft-expiration logic discussed in the lesson.

Requirements:

  • Create a function getSmartCache($key, $callback, $softTtl = 60, $hardTtl = 300).
  • The $callback is a function that simulates a heavy database call (use sleep(2) inside it to simulate the load).
  • If the data exists and is within the $softTtl, return it immediately.
  • If the data exists but has exceeded the $softTtl, attempt to acquire a lock. If the lock is acquired, execute the callback to refresh the cache, but still return the stale data to the current user.
  • If no data exists at all (hard miss), execute the callback and return the fresh data.
  • Store the cache value as an array containing both the data and the expires_at timestamp.

Test your implementation by:

  1. Calling the function once to prime the cache.
  2. Waiting for the $softTtl to pass.
  3. Calling the function again. You should notice that the second call returns immediately (stale data) while the cache is updated in the background for the next person.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.