An expensive cached value expires under load and every concurrent request misses simultaneously, so forty requests all run the same four-second query at once and the database falls over.
$value = Cache::get($key);
if ($value === null) {
$lock = Cache::lock("{$key}:build", 10);
if ($lock->get()) {
try {
$value = $this->build();
Cache::put($key, $value, 300);
} finally {
$lock->release();
}
} else {
$value = $this->stale($key) ?? $this->build(); // or wait briefly
}
}
What the losers of the lock should do is the design decision: serving a stale value is best where staleness is acceptable, and waiting is acceptable where it is not. Both beat all of them rebuilding. Storing the value with a longer real expiry than the logical one is what makes the stale fallback possible, and that two-tier arrangement is the part usually missing from implementations that still stampede occasionally.