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

Simulator · Cache stampede

The query count, the cache and the lock all run on the tested reducer, matching Cache::remember and Cache::lock.

Request 5 missed and is running the query. 5 running this cycle.

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, array and file. On a store without support, Cache::lock cannot give you this guarantee.
  • block() throws LockTimeoutException if 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

Spotted a problem, or have a way to make this clearer? Suggest an improvement.