PHP Cache Stampede — Why 500 Requests Killed Our Database
One expired cache key triggered 500 database queries, spiking response times from 200ms to 50s.
- 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.
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.
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.
Key takeaways
Common mistakes to avoid
8 patternsUsing APCu on multi-server deployments without coordination
Caching everything with a long TTL and no invalidation
Not handling cache stampede when a hot key expires
Forgetting to clearstatcache() when polling file cache
clearstatcache() before checking file existence or modification time in a polling loop.Using HTTP Cache-Control: public for user-specific content
private directive for per-user responses. Never use public unless the response is identical for all users.Not checking if APCu extension is loaded before calling apcu_fetch()
apcu_fetch()' when the extension is missing, bringing down the entire page.Using Redis without persistence and losing cache on restart
Not setting an eviction policy in Redis
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
That's Advanced PHP. Mark it forged?
5 min read · try the examples if you haven't