PHP
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Functions
-
Section 4: Object-Oriented PHP
-
Section 5: Working with Data
-
Section 6: Modern PHP (PHP 8)
-
Section 7: Working with Files and Networking
-
Section 8: Common PHP Frameworks Overview
-
Section 9: Tooling and Ecosystem
-
Section 10: Practical Projects
-
Section 11: Interview Practice
-
Section 12: More Standard Library
-
Section 13: Data Structures and Algorithms in PHP
-
Section 14: More Practice Exercises
-
Section 15: More Security and Best Practices
-
Section 16: More Testing and Tooling
-
Section 17: WordPress-Style CMS Concepts
-
Section 18: Advanced OOP Practice
-
Section 19: More Web Fundamentals
-
Section 20: Database Practice
-
Section 21: PHP Manual: Array Functions
-
Section 22: PHP Manual: Date and Calendar Functions
-
Section 23: PHP Manual: Filesystem and Directory Functions
-
Section 24: PHP Manual: Filter and Var Handling
-
Section 25: PHP Manual: Math Functions
-
Section 26: PHP Manual: JSON and XML
-
Section 27: PHP Manual: Network and Stream Functions
-
Section 28: PHP Manual: Error and Exception Handling
-
Section 29: PHP Manual: Output Control and Misc
-
Section 30: PHP Manual: FTP, Zip, and Mail
-
Section 31: Modern PHP Frameworks Deep Dive
-
Section 32: PHP Design Patterns
-
Section 33: More Practice Exercises
-
Section 34: PHP Performance and Deployment
-
Section 35: More Interview Practice
-
Section 36: More PHP Standard Library
-
Section 37: PHP Concurrency and Async
-
Section 38: More Web Development Practice
-
Section 39: PHP Testing Deep Dive
-
Section 40: Composer and Package Development
-
Section 41: PHP Security Deep Dive
-
Section 42: More Practical Projects
-
Section 43: Legacy PHP Maintenance
-
Section 44: More Algorithm Practice
-
Section 45: Final Practice and Review
-
Section 46: PHP for E-Commerce Patterns
-
Section 47: PHP API Design Deep Dive
-
Section 48: PHP Caching Strategies
-
Section 49: PHP Queue and Background Jobs
-
Section 50: PHP Multi-Tenancy Patterns
-
Section 51: PHP Real-Time Features
-
Section 52: PHP CMS and Content Modeling
-
Section 53: PHP Internationalization
-
Section 54: More Framework-Specific Practice
-
Section 55: PHP Legacy Code Refactoring
-
Section 56: More Practice Projects Round 2
-
Section 57: PHP Command-Line Applications
-
Section 58: PHP and Microservices
-
Section 59: More Interview and Review Round 2
206: Cache Stampede Prevention Techniques
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
$callbackis a function that simulates a heavy database call (usesleep(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
dataand theexpires_attimestamp.
Test your implementation by:
- Calling the function once to prime the cache.
- Waiting for the
$softTtlto pass. - 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.
There are no comments for now.