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.
Each 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 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. The
alternative would be 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. 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. 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.