PHP Cache Stampede — Why 500 Requests Killed Our Database
One expired cache key triggered 500 database queries, spiking response times from 200ms to 50s.
20+ years shipping production PHP systems at scale. Notes here come from systems that actually shipped.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- Caching stores expensive computation results for reuse across requests.
- APCu: in-memory per-server cache, sub-microsecond (<1µs) reads, limited to single node.
- Redis: shared distributed cache, ideal for multi-server apps, adds ~1ms network latency per operation.
- File cache: simple, no external services, but degrades above ~500 writes/sec due to disk I/O.
- HTTP cache (Cache-Control/ETag): offloads work to browsers/CDNs, best for public content with zero server cost.
- Production insight: cache stampede is the #1 killer under load — always use locking or stale-serve to survive.
Imagine your favourite pizza shop. Every time someone orders a Margherita, the chef could start from scratch — kneading dough, chopping tomatoes, grating cheese. Or, he could make twenty Margheritas at once and keep them warm on a shelf. When the next order comes in, he grabs one instantly. Caching is that warm shelf. Your PHP app does expensive work once — a database query, an API call, a rendered HTML block — stores the result, and hands it out instantly to every request that follows. No repeated heavy lifting.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Every PHP app eventually meets the same wall: the database query that snapped at 100 users crawls at 10,000. You can scale horizontally, but that burns money. Caching is the cheap fix — compute once, serve a million times. The catch? Get it wrong and you'll serve stale data or crash the database during a stampede.
Here's the hard truth: caching isn't free. Every cache hit saves time, but every cache miss costs more than a direct computation because of serialisation overhead. That's why your cache hit ratio matters more than the cache type you pick.
By the end of this article you'll know exactly when to use APCu, Redis, file cache, or HTTP headers. You'll understand TTL design, stampede prevention, invalidation patterns, and the production mistakes that silently corrupt data. Real, runnable code accompanies every concept.
What Is Caching in PHP? — The Core Problem and How to Fix It
Caching in PHP isn't about storing everything — it's about identifying the expensive operations your app repeats. Think: the same database query returning identical results, an API call that hasn't changed, or a heavyweight HTML snippet. Caching stores that result once and serves it directly for subsequent requests.
Here's what no one tells you: a cache miss costs more than a direct computation because of serialisation overhead. If your hit ratio drops below 90%, caching may actually hurt performance. That's why you must monitor it — not just set it and forget it.
A typical example: a product list query that takes 200ms. If you cache it for 60 seconds and serve 1000 requests per minute, you save 200ms × 999 = ~200 seconds of DB time per minute. That's the ROI. But get the TTL wrong and the stampede will burn you.
APCu: In-Process Cache for Microsecond Reads
APCu (APC User Cache) stores key-value pairs in shared memory available to all PHP-FPM workers on the same machine. It's the fastest cache you can get because there's no network I/O — reads take 100–300 nanoseconds. Perfect for computed data that is cheap to recompute and shared across requests on a single server.
But APCu's speed is also its biggest limitation: it only lives on one server. If you have multiple web servers, each has its own copy, and cache invalidation becomes a coordination problem. You also burn RAM per server — a 1 GB APCu store consumes 1 GB of memory on every node.
Here's a typical pattern: caching an expensive database query result that changes rarely. APCu fragmentation silently increases memory usage — monitor it via apcu_sma_info() in production and restart PHP-FPM when fragmentation exceeds 20%.
Redis: Shared Distributed Cache for Multi-Server Apps
Redis is an in-memory data store accessed over TCP. In PHP, you'll typically use the PhpRedis extension or the predis/predis library. Redis gives you a single cache that every PHP worker — no matter which server — can read from and write to. This makes cache invalidation straightforward: delete the key and every server sees the change instantly.
The trade-off is network latency: each cache operation adds ~0.2–1ms round-trip. At 10,000 requests per second, that's 10 seconds of network overhead. But that's still orders of magnitude faster than querying a database.
A common pattern: cache an API response to avoid hitting rate limits or third-party latency. Redis connections are persistent — always reuse the same connection across requests. Opening a new connection per request adds ~5ms overhead, which destroys the benefit.
Monitoring tip: track keyspace_hits and keyspace_misses in Redis info. A hit ratio below 80% means your cache is mostly wasting memory.
- All servers see the same data, always.
- Operations are atomic (INCR, SETNX) – great for counters and locks.
- Memory is finite – set eviction policy (allkeys-lru is common).
- Network can be a bottleneck – pipeline multiple commands in one round trip.
File Cache: Simple, No Dependencies, but Watch the I/O
Sometimes you can't install extensions or run a separate service. A file-based cache writes serialised data to disk. It's trivial to implement — file_put_contents to write, file_get_contents to read, plus a timestamp check for TTL. This works great for small deployments, shared hosting, or as a fallback when Redis is unavailable.
The catch: file I/O is orders of magnitude slower than memory. At high concurrency, multiple processes writing to the same file create corruption risks. And there's no built-in eviction — your storage grows unbounded unless you clean it up.
Here's a safe implementation with proper locking and TTL. Under high concurrency, file locks cause contention — measure with iostat or lsof. If you see high wait time, switch to memory-based cache immediately.
Pro tip: for low-traffic sites (<10 req/s), file cache is perfectly fine. For anything above 100 req/s, you need memory.
clearstatcache() if polling often.HTTP Cache: Use Browser and Edge to Eliminate Requests Entirely
HTTP caching works by telling the browser (or a reverse proxy like Varnish or CloudFlare) how long it can reuse a response without asking your PHP server at all. This isn't just fast — it eliminates the PHP execution entirely for cached resources. For public, static-like content (images, CSS, API responses that don't change per user), HTTP caching is the highest-leverage optimisation.
Two key headers: Cache-Control: max-age=3600 tells the browser to cache for 1 hour. ETag provides an opaque hash — the browser sends If-None-Match on subsequent requests, and you return 304 Not Modified without sending the body.
Important nuance: HTTP caching doesn't happen inside PHP. It's a contract between your server and the client. You must ensure your PHP correctly sets headers and respects If-Modified-Since or If-None-Match. For global audiences, use a CDN with revalidation — stale content gets cleared from the edge within minutes when you invalidate via the CDN API.
curl -I to see what your server sends.Cache-Control: no-cache for dynamic user-specific pages, or use private.public, max-age=86400 with ETag. Highly effective for assets.private, must-revalidate with ETag. Store user-specific ETag in session or DB.no-cache, no-store, must-revalidate and Pragma: no-cache.Cache Invalidation: The Hardest Problem in Computer Science
‘There are only two hard things in computer science: cache invalidation and naming things.’ — Phil Karlton. Cache invalidation is about ensuring that when the underlying data changes, the cached version is either removed or updated. Without it, users see stale data. Over-invalidation trashes your cache hit ratio and kills performance.
- TTL-based: Entry expires after a fixed time. Simple but you either serve stale or recompute early.
- Write-through: On every write to the database, also update or delete the cache key. Ensures consistency but adds write latency.
- Publish-subscribe: Another component (e.g., a queue worker) broadcasts invalidation events. All cache layers (APCu, Redis) listen and evict.
In practice, most applications use a combination: a short TTL as a safety net, plus explicit invalidation on writes. Cache poisoning happens when an attacker controls the cache key (e.g., SQL injection in the key). Always sanitise cache keys.
TTL + explicit delete on write + serve stale during recompute.Cache Monitoring and Observability in Production
You can't fix what you don't measure. Cache monitoring is often an afterthought — teams add caching, see a performance boost, then never look again until something breaks. The two numbers that matter: hit ratio and eviction rate.
For APCu, use apcu_cache_info(true) to see hits, misses, and memory usage. For Redis, INFO stats gives keyspace_hits and keyspace_misses. For file cache, track disk usage and file age.
Set up alerts: when hit ratio drops below 90% for more than 5 minutes, something changed — a deployment, a growth spike, or a bug in key naming. Missing keys silently degrade performance. Also monitor evictions; a sudden increase means your cache size is too small.
Real example: a team used Redis with a 256 MB limit. Traffic grew, evictions jumped, hit ratio dropped to 50%. The app slowed to a crawl. They increased maxmemory to 2 GB and added monitoring — problem solved before the next spike.
OpCache: The Free Cache You're Already Running Wrong
Most devs treat OpCache as a set-it-and-forget-it checkbox. That's a production fire waiting to happen. OpCache stores precompiled PHP bytecode in shared memory, skipping the parse/compile phase on every request. PHP 8.x makes this even faster with JIT compilation, but only if you configure it for your actual workload. The default settings are optimized for shared hosting, not for your app. The whocares: validation timestamps are off by default. That means if you deploy new code via FTP (don't) or a symlink swap, OpCache serves stale bytecode until you restart PHP-FPM or call opcache_reset(). Set opcache.validate_timestamps=1 in production, or use a deployment script that invalidates the cache. OpCache is always the first caching layer because it's free, already installed, and reduces response time by 10-30ms.
opcache_invalidate() on changed files. Your deployment is not complete until the cache is warm.Cache Stampede Protection: Don't Let a Miss Kill Your DB
A cache stampede is when 100 requests all miss at once, and they all hit the database. It's not a theory — it's the reason your pager went off at 3 AM. The fix is simple: use a mutex or probabilistic early expiration. PHP 8.x gives you fiber-based concurrency, but for cache stampedes, you want a distributed lock via Redis or APCu. The pattern: when a cache miss happens, try to acquire a lock. Only the first request rebuilds the cache; the rest wait or serve stale data. Redis SET NX + EXPIRE is the standard. APCu can also serve as a single-server lock. Another approach: set the TTL to 2x the expected rebuild time, and randomly expire earlier. This spreads out the refresh load. Don't use file-based mutexes on NFS — they're not atomic. Test your stampede handling with a chaos monkey script that flushes your cache in production during peak load.
Laravel Cache: Unified API for Multiple Drivers
Laravel provides a clean, unified API for various caching backends, allowing you to switch between file, database, Redis, Memcached, or DynamoDB caches without changing your application code. This abstraction simplifies cache management and makes it easy to scale from development to production.
To get started, configure your default cache driver in config/cache.php. For example, to use Redis:
``php 'default' => env('CACHE_DRIVER', 'redis'), ``
Then, use the Cache facade or the helper:cache()
```php use Illuminate\Support\Facades\Cache;
// Store an item for 10 minutes Cache::put('key', 'value', 600);
// Retrieve an item $value = Cache::get('key');
// Store if not exists (stampede protection) $value = Cache::remember('key', 600, function () { return expensiveDatabaseQuery(); }); ```
The remember method is particularly useful for cache stampede prevention: it only executes the closure if the key is missing, and locks the cache to prevent multiple simultaneous regenerations.
Laravel also supports cache tags (for Redis/Memcached) to invalidate groups of cached items:
``php Cache::tags(['people', 'artists'])->put('John', $john, 600); Cache::tags('people')->flush(); ``
For distributed systems, consider using the Redis driver with persistent connections and read/write splitting for optimal performance.
remember() method provides built-in stampede protection.OPcache Configuration for Production Performance
OPcache improves PHP performance by storing precompiled script bytecode in shared memory, eliminating the need to parse and compile scripts on every request. However, default settings are often suboptimal for production.
Key configuration directives in php.ini:
``ini opcache.enable=1 opcache.memory_consumption=256 opcache.interned_strings_buffer=16 opcache.max_accelerated_files=20000 opcache.revalidate_freq=2 opcache.fast_shutdown=1 ``
memory_consumption: Allocate enough memory for your codebase. Start with 256MB and monitor usage.max_accelerated_files: Set to the number of PHP files in your project. Usefind . -type f -name "*.php" | wc -lto count.revalidate_freq: How often (seconds) to check file timestamps. In production, set to a higher value (e.g., 120) to reduce stat calls. During deployment, clear OPcache viaor a symlink swap.opcache_reset()fast_shutdown: Enables faster request shutdown sequence.
For deployment, avoid opcache.validate_timestamps=0 unless you manually clear cache after each deploy. Instead, use a versioned directory or a deployment script that calls .opcache_reset()
Monitor OPcache with :opcache_get_status()
``php $status = ``opcache_get_status(); echo "Memory used: " . $status['memory_usage']['used_memory'] . " bytes "; echo "Hits: " . $status['opcache_statistics']['hits'] . " "; echo "Misses: " . $status['opcache_statistics']['misses'] . " ";
Aim for a hit rate above 99%. If misses are high, increase memory or file count.
opcache_get_status() in your monitoring to ensure memory is not exhausted and hit rate stays above 99%.Redis Cache in Modern PHP: Users, Sessions, Full-Page
Redis is an in-memory data structure store that excels as a cache for PHP applications. Beyond simple key-value storage, Redis can handle user sessions, full-page caching, and complex data structures.
User Sessions: Store PHP sessions in Redis for fast, shared access across multiple servers:
``php // In php.ini session.save_handler = redis session.save_path = "tcp://127.0.0.1:6379?auth=yourpassword" ``
Or programmatically: ``php $redis = new ``Redis(); $redis->connect('127.0.0.1', 6379); session_set_save_handler( function ($savePath, $sessionName) use ($redis) { return true; }, function () use ($redis) { return true; }, function ($sessionId) use ($redis) { return $redis->get($sessionId); }, function ($sessionId, $data) use ($redis) { return $redis->set($sessionId, $data, 3600); }, function ($sessionId) use ($redis) { return $redis->del($sessionId); }, function ($maxLifetime) use ($redis) { return true; } ); session_start();
Full-Page Caching: Cache entire HTML output to reduce application load: ``php $cacheKey = 'page_' . md5($_SERVER['REQUEST_URI']); $cached = $redis->get($cacheKey); if ($cached) { echo $cached; exit; } ``ob_start(); // ... render page ... $html = ob_get_clean(); $redis->setex($cacheKey, 300, $html); echo $html;
Data Structures: Redis supports lists, sets, sorted sets, and hashes. For example, cache a user's profile as a hash: ``php $redis->hMSet("user:1000", [ 'name' => 'John Doe', 'email' => 'john@example.com', 'age' => 30 ]); $user = $redis->hGetAll("user:1000"); ``
Use Redis pipelining or transactions for batch operations. For high availability, configure Redis Sentinel or Cluster.
50s Response Time After Cache Stampede on Black Friday
- Always assume cache entries will expire under load. Use locking or probabilistic early recomputation (like 'likely to be stale' at 80% of TTL).
- A single stale cache entry is vastly better than a stampede. Serve stale + refresh async.
- Monitor cache hit ratios and stampede events. A drop from 99% to 95% can kill your app.
- Add capacity planning: if your cache key is accessed >1000 req/s, design for cache miss spikes.
apcu_cache_info(true) or redis-cli PINGstat -c %Y /path/to/cache/files | tail -5| File | Command / Code | Purpose |
|---|---|---|
| src | namespace TheCodeForge\Caching; | What Is Caching in PHP? |
| src | namespace TheCodeForge\Caching; | APCu |
| src | namespace TheCodeForge\Caching; | Redis |
| src | namespace TheCodeForge\Caching; | File Cache |
| src | namespace TheCodeForge\Caching; | HTTP Cache |
| src | namespace TheCodeForge\Caching; | Cache Invalidation |
| src | namespace TheCodeForge\Caching; | Cache Monitoring and Observability in Production |
| php.ini | [opcache] | OpCache |
| StampedeGuard.php | function getExpensiveData(Redis $redis, string $key, callable $loader, int $ttl ... | Cache Stampede Protection |
| config | return [ | Laravel Cache |
| opcache_monitor.php | function getOpcacheStatus() { | OPcache Configuration for Production Performance |
| redis_full_page_cache.php | $redis = new Redis(); | Redis Cache in Modern PHP |
Key takeaways
Interview Questions on This Topic
What are the differences between APCu and Redis when used as PHP caches? When would you choose one over the other?
Frequently Asked Questions
20+ years shipping production PHP systems at scale. Notes here come from systems that actually shipped.
That's Advanced PHP. Mark it forged?
8 min read · try the examples if you haven't