Caching
The cache stampede, and what a lock prevents
The mistake
Cache::remember($key, $ttl, $callback) reads like it shields the database. Wrap
the expensive query in it, set a time to live, and the query only runs when the
cache is empty. That much is true. The wrong part is the word “empty”. People
picture one request finding the key gone, running the query, and filling it for
everyone else.
Under load it does not happen one request at a time. remember is get-or-compute
with nothing coordinating the callers. When the key expires, every request that
arrives before the first one has finished also sees a miss, and every one of them
runs the callback. A key with a hundred requests a second does not run the query
once when it expires. It runs it a hundred times, all at once, against a database
that was fine a moment ago. That is the cache stampede, and it hits hardest
exactly when traffic is highest.
The machine
The query count, the cache and the lock all run on the tested reducer, matching Cache::remember and Cache::lock.
Drive it
The panel boots mid-stampede: a cold key, five requests at once, naive remember.
The big number is queries run this cycle, and it already reads five.
- Press “Let the queries finish”. All five fill the cache, but the counter stays at five. Five identical queries really ran. The cache helped no one here.
- Press “Now try a lock”, then press Request five times. Same five requests, but the counter stops at one. One request queries; the other four block, then read the value it left behind.
- Press Expire, switch back to naive, and fire a burst again. The stampede is back. The lock was the only thing holding the line.
The mechanism
remember does exactly this, and no more:
$value = $this->get($key);
if (! is_null($value)) {
return $value; // warm: cheap, no query
}
$value = $callback(); // MISS: this line runs the query
$this->put($key, $value, $ttl);
return $value;
There is no lock, no queue, no “is someone already doing this”. Each request runs
the block on its own. While the first request sits inside $callback(), the key
is still empty, so the second, third and hundredth request reach the same line and
run the same query. The put at the end is the only coordination, and by then the
damage is done.
An atomic lock adds the missing coordination. On a miss, one request takes the
lock and runs the query; the rest call block() and wait for it. When the holder
finishes it has already filled the cache, so a waiter that wakes and re-checks the
key finds the value and returns it without querying. The re-check is the point: the
lock is not what serves the waiters, the now-warm cache is.
$value = Cache::get($key);
if (! is_null($value)) {
return $value;
}
return Cache::lock($key.':lock', 10)->block(5, function () use ($key, $ttl, $callback) {
// reached only by one request at a time. Re-check inside the lock:
// whoever got here first already filled the key, so this is a hit for the rest.
return Cache::remember($key, $ttl, $callback);
});
block(5, ...) waits up to five seconds to acquire the lock, then runs the closure
and releases it. The lock itself has a ten second time to live, so if the holder
crashes mid-query the lock expires on its own and the next request takes over,
rather than the whole system stalling behind a lock nobody holds.
In your code
Laravel 11.23 added a higher-level answer that uses a lock for you: Cache::flexible.
It keeps two windows, fresh and stale. Inside the fresh window it serves the value
outright. Once the value is stale but not yet gone, it serves the stale value
immediately and refreshes it in the background, under a lock, so one request does
the work and nobody waits.
// fresh for 10s; usable but refreshed in the background up to 20s
$stats = Cache::flexible('dashboard-stats', [10, 20], function () {
return Report::expensiveRollup();
});
Reach for a plain Cache::lock when a value has no usable stale form and callers
genuinely must wait for the real thing. Reach for flexible when a slightly old
value is fine to serve, which for most dashboards and counts it is. Either way the
rule is the same: the moment a hot key can expire under load, something has to stop
every caller from recomputing at once.
The fine print
- Atomic locks need a driver that supports them:
redis,memcached,dynamodb,database,arrayandfile. On a store without support,Cache::lockcannot give you this guarantee. block()throwsLockTimeoutExceptionif it cannot get the lock in time. A caller that waits has to handle the case where it never gets a turn, usually by serving a fallback rather than hanging.- A lock has an owner and its own time to live. Only the owner should release it, and the time to live is what stops a dead holder from blocking everyone forever.
- This page models requests as arriving in one burst and finishing together, so the count is easy to read. Real requests overlap in messier ways, but the shape holds: concurrent misses run concurrent queries unless something stops them.
- The stampede is only the gap when a key is cold. A warm cache asks the database nothing, which is the whole reason to cache. The lock is insurance for the moment the warmth runs out.
Further reading
- Laravel: atomic locks is the
manual for
Cache::lock,block, ownership and the lock time to live. - Laravel: cache helpers covers
remember,flexibleand the store methods the simulator stands in for. - Wikipedia: cache stampede is the general problem and the family of fixes, of which locking is one.
Spotted a problem, or have a way to make this clearer? Suggest an improvement.