A cache stampede, and the lock that prevents it

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.