Query bảng xếp hạng mất 4 giây, nên được cache 5 phút bằng Cache::remember(). Phần lớn thời gian trang render trong 30 ms. Cứ mỗi 5 phút, đúng lúc key hết hạn, CPU database lên 100%, p99 latency vọt lên 20 giây và vài request bị lỗi. Trong 4 giây tính lại đó, request nào tới cũng gặp cache miss và chạy lại đúng query 4 giây đó. Ở 300 request mỗi giây là 1.200 query giống hệt nhau giành tài nguyên của nhau, cái nào cũng chậm hơn vì mấy cái kia.
Đó là cache stampede, còn gọi là dogpile hay thundering herd. Cache chạy hoàn hảo lúc bình thường, rồi tạo ra một đợt tải dồn dập đúng lúc nó hết hạn. Có bốn cách phòng thủ chuẩn: lock để chỉ một bên tính lại, hết hạn sớm theo xác suất (XFetch) để việc tính lại diễn ra trước khi hết hạn, stale-while-revalidate để user không bao giờ phải chờ, và jitter TTL để hàng nghìn key không hết hạn cùng lúc. Laravel có sẵn hai trong số đó.
Đám đông từ đâu ra
namespace App\Http\Controllers;
use App\Services\Leaderboard;
use Illuminate\Support\Facades\Cache;
final class LeaderboardController
{
public function __invoke(Leaderboard $board): array
{
// 4 second query, cached for 5 minutes. Every 5 minutes, every request
// that arrives during those 4 seconds runs the query too.
return Cache::remember('leaderboard:global', 300, fn () => $board->compute());
}
}
Cache::remember() nghĩa là "get, miss thì tính rồi put". Giữa các process không có phối hợp gì hết. Tính càng tốn và traffic càng cao thì càng nhiều request rơi vô khoảng miss: số lần tính trùng ước chừng bằng request rate nhân thời gian tính. Query 4 giây ở 300 rps là khoảng 1.200 bản. Query 50 ms ở 20 rps là một bản, nên stampede chỉ lộ ra ở key nóng và tốn kém, và rất dễ lọt qua staging.
Chuyện y vậy xảy ra ở quy mô lớn hơn sau khi flush cache, Redis failover, hay deploy đổi prefix của key: key nào cũng lạnh cùng lúc.
Bốn cách phòng thủ nhìn tổng quan
- Lock khi miss: bên đầu tiên lấy lock và tính lại; mấy bên khác chờ hoặc nhận giá trị dự phòng. Đảm bảo mỗi lần hết hạn chỉ tính một lần, đổi lại là phải chờ.
- Hết hạn sớm theo xác suất: mỗi lần đọc, với xác suất tăng dần khi gần hết hạn, request tự quyết định tính lại sớm. Thường chỉ một request refresh key trong khi mọi người khác vẫn hit. Không cần lock.
- Stale-while-revalidate: tiếp tục trả giá trị cũ sau khi hết thời gian tươi, trong lúc một lần refresh duy nhất chạy ở nền. User không bao giờ phải chờ.
- Jitter TTL: random thời điểm hết hạn để mấy key được ghi cùng lúc không hết hạn cùng lúc.
Cách 1: Lock kèm double-check
Atomic lock của Laravel chạy trên Redis, Memcached, DynamoDB, database và array store. block() chờ tối đa N giây để lấy lock và throw LockTimeoutException nếu không lấy được.
namespace App\Cache;
use Closure;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
final class LockedRemember
{
/**
* Only one process recomputes a missing key; the others wait briefly
* and read the value the winner stored.
*/
public static function get(string $key, int $ttl, Closure $compute, int $waitSeconds = 5): mixed
{
$value = Cache::get($key);
if ($value !== null) {
return $value;
}
try {
return Cache::lock("lock:{$key}", 30)->block($waitSeconds, function () use ($key, $ttl, $compute) {
// Double-check: the previous lock holder probably filled it already.
$value = Cache::get($key);
if ($value !== null) {
return $value;
}
$value = $compute();
Cache::put($key, $value, $ttl);
return $value;
});
} catch (LockTimeoutException) {
// The winner is too slow. Degrade instead of piling onto the database.
return Cache::get($key) ?? throw new \RuntimeException("{$key} unavailable, try again");
}
}
}
Có hai chi tiết quyết định pattern này sống hay chết. Double-check bên trong lock: tới lúc process đang chờ lấy được lock thì bên thắng đã lưu giá trị rồi, nên bên chờ phải đọc lại chứ đừng tính lại. Và TTL của lock (ở đây 30 giây) phải dài hơn thời gian tính lâu nhất, không thì lock hết hạn giữa chừng và process thứ hai nhảy vô. Nếu thời gian tính khó đoán, xài release có kiểm tra chủ sở hữu (release() của Laravel chỉ nhả lock mình đang giữ) và TTL rộng rãi.
Điểm yếu là phải chờ. Với 1.200 request đang chờ, mình đổi một stampede ở database lấy một đống worker PHP-FPM bị kẹt chờ lock. Vậy là đỡ hơn, nhưng với key thật sự nóng thì mình muốn không ai phải chờ hết.
Cách 2: Hết hạn sớm theo xác suất (XFetch)
XFetch, từ paper VLDB 2015 Optimal Probabilistic Cache Stampede Prevention của Vattani, Chierichetti và Lowenstein, lưu thêm hai con số cùng giá trị: delta là lần tính trước tốn bao lâu, và expiry. Mỗi lần đọc, nó tính lại khi
now - delta * beta * ln(random()) >= expiry with random() in (0, 1]
-ln(random()) là số có phân phối mũ với trung bình 1, nên mỗi request giả bộ đồng hồ trễ hơn thực tế một chút. Còn xa hạn thì hầu như không ai vượt ngưỡng. Càng gần hạn, xác suất tăng lên êm ái, và tăng sớm hơn với key tốn công tính (delta lớn), đúng chỗ mình cần đi trước. beta chỉnh độ hăng: 1.0 là giá trị paper khuyên dùng, lớn hơn 1 thì refresh sớm hơn.
namespace App\Cache;
use Closure;
use Illuminate\Contracts\Cache\Repository;
/**
* Probabilistic early expiration (XFetch, Vattani et al., VLDB 2015).
* Each entry stores the value, how long it took to compute (delta) and its logical expiry.
*/
final class XFetch
{
public function __construct(
private Repository $cache,
private float $beta = 1.0,
private ?Closure $clock = null,
private ?Closure $random = null,
) {
$this->clock ??= fn (): float => microtime(true);
$this->random ??= fn (): float => mt_rand(1, mt_getrandmax()) / mt_getrandmax(); // (0, 1]
}
public function remember(string $key, int $ttl, Closure $compute): mixed
{
$entry = $this->cache->get($key);
$now = ($this->clock)();
if (is_array($entry)) {
[$value, $delta, $expiry] = $entry;
// log(random) <= 0, so this subtracts a random head start that grows
// with compute time (delta) and with beta.
if ($now - $delta * $this->beta * log(($this->random)()) < $expiry) {
return $value;
}
}
$start = ($this->clock)();
$value = $compute();
$delta = ($this->clock)() - $start;
// Keep the physical TTL a bit longer than the logical one so late
// readers still find the old value while someone recomputes.
$this->cache->put($key, [$value, $delta, $start + $delta + $ttl], $ttl + (int) ceil($delta) + 60);
return $value;
}
}
Không lock, không chờ, không worker chạy nền. Giữ TTL vật lý dài hơn expiry logic để giá trị vẫn đọc được trong lúc một request đang tính lại.
Cách 3: Stale-while-revalidate với Cache::flexible
Từ Laravel 11, Cache::flexible() cài sẵn stale-while-revalidate. Mình đưa nó hai khoảng thời gian: giá trị tươi trong bao lâu, và giá trị cũ còn được trả trong bao lâu.
use App\Services\Leaderboard;
use Illuminate\Support\Facades\Cache;
// Fresh for 60s. Between 60s and 300s the stale value is returned immediately
// and ONE refresh runs after the response is sent. After 300s it is a normal miss.
$board = Cache::flexible('leaderboard:global', [60, 300], function () {
return app(Leaderboard::class)->compute();
});
Trong khoảng tươi thì là hit bình thường. Trong khoảng cũ, bên gọi nhận giá trị cũ ngay, và Laravel lên lịch refresh bằng defer(), nên nó chạy sau khi response đã gửi cho user, có lock bảo vệ để chỉ một process refresh. Quá khoảng cũ thì nó chạy y như remember(). Chọn khoảng cũ dựa trên việc dữ liệu được phép sai tới đâu, đừng dựa vô hiệu năng: bảng xếp hạng cũ 5 phút không sao, số dư tài khoản thì không được cũ chút nào.
Lưu ý là việc được defer chạy trong chính worker PHP-FPM đó sau response, nên nó vẫn chiếm worker. Giữ phần refresh đủ nhanh, hoặc với mấy lần tính rất chậm thì dispatch một queued job từ closure.
Cách 4: Jitter TTL
Lock và XFetch bảo vệ một key nóng. Jitter bảo vệ mình khỏi hàng nghìn key nóng vừa vừa được ghi cùng một lúc, thường là do cache warmer hoặc do deploy:
use Illuminate\Support\Facades\Cache;
// 10,000 product pages warmed by the same deploy expire at the same second
// unless you spread the TTLs out.
function ttlWithJitter(int $base, float $spread = 0.1): int
{
$delta = (int) round($base * $spread);
return $base + random_int(-$delta, $delta);
}
Cache::put('product:42', ['id' => 42], ttlWithJitter(3600));
Lệch mười phần trăm thường là đủ. Kết hợp nó với bất kỳ cách nào trong ba cách kia.
Kiểm thử chống stampede
Chìa khóa để test XFetch là inject clock và nguồn random, để mấy lần bốc "xui" với "hên" trở nên tất định. Giá trị random exp(-5) cho khoảng đi trước đúng bằng 5 * delta. Test lock chạy trên array store, store này hỗ trợ atomic lock: một test kiểm tra giá trị chỉ tính một lần rồi xài lại, test kia giữ lock như thể có process khác đang tính dở và assert rằng bên gọi bỏ cuộc sau khi chờ chứ không chạy query.
// tests/Unit/XFetchTest.php
use App\Cache\LockedRemember;
use App\Cache\XFetch;
use Illuminate\Cache\ArrayStore;
use Illuminate\Cache\Repository;
use Illuminate\Support\Facades\Cache;
beforeEach(function () {
$this->now = 1000.0;
$this->rand = 1.0;
$this->calls = 0;
$this->cache = new XFetch(
new Repository(new ArrayStore()),
beta: 1.0,
clock: fn () => $this->now,
random: fn () => $this->rand,
);
$this->compute = function () {
$this->calls++;
$this->now += 2.0; // the expensive query takes 2 seconds
return "v{$this->calls}";
};
});
it('computes once on a miss and then serves from cache', function () {
expect($this->cache->remember('k', 60, $this->compute))->toBe('v1');
$this->now += 10;
expect($this->cache->remember('k', 60, $this->compute))->toBe('v1')
->and($this->calls)->toBe(1);
});
it('recomputes early when the random draw is unlucky', function () {
$this->cache->remember('k', 60, $this->compute); // expiry = 1062
$this->now = 1055.0;
$this->rand = exp(-5); // head start = 2 * 5 = 10s
expect($this->cache->remember('k', 60, $this->compute))->toBe('v2');
});
it('never recomputes early with a lucky draw', function () {
$this->cache->remember('k', 60, $this->compute);
$this->now = 1061.0;
$this->rand = 1.0; // log(1) = 0, no head start
expect($this->cache->remember('k', 60, $this->compute))->toBe('v1');
});
it('computes once and reuses the stored value', function () {
$runs = 0;
$compute = function () use (&$runs) {
$runs++;
return 'fresh';
};
foreach (range(1, 20) as $_) {
expect(LockedRemember::get('report', 60, $compute))->toBe('fresh');
}
expect($runs)->toBe(1);
});
it('does not compute while another process holds the lock', function () {
$runs = 0;
Cache::lock('lock:report', 30)->get(); // simulate the winner, still computing
expect(fn () => LockedRemember::get('report', 60, function () use (&$runs) {
$runs++;
return 'fresh';
}, waitSeconds: 1))->toThrow(RuntimeException::class);
expect($runs)->toBe(0);
});
Mấy test này chứng minh logic. Muốn tự tin dưới tải đồng thời thật, chạy một load test ngắn (k6, ab hoặc hey) vô endpoint staging với TTL rất nhỏ rồi đếm số lần phần tính toán chạy, ví dụ bằng INCR của Redis bên trong closure.
Những lỗi hay gặp
- Không double-check sau khi lấy lock. Mỗi bên chờ lần lượt tính lại: mình chỉ xếp hàng cái stampede chứ không ngăn được nó.
- TTL của lock ngắn hơn thời gian tính. Lock hết hạn, process thứ hai vô, rồi process đầu nhả một lock không còn là của nó (Laravel kiểm tra chủ sở hữu nên chặn được lần nhả đó, nhưng việc tính trùng đã xảy ra rồi).
- Cache
nullhoặcfalsebằngremember(). Phép tính trả vềnullhợp lệ sẽ không bao giờ được cache, request nào cũng miss. Bọc nó lại, ví dụ cache một mảng rỗng hay một object đánh dấu. - Xài
random()có thể trả về 0.ln(0)là âm vô cực, ép tính lại ở mọi lần đọc. Lấy giá trị trong khoảng (0, 1]. - Cho phép đọc cũ với dữ liệu buộc phải đúng. Stale-while-revalidate là quyết định sản phẩm. Giá lúc checkout, quyền truy cập và số dư cần invalidate khi ghi, không phải đọc dữ liệu cũ.
- Quên chuyện khởi động lạnh. Cả bốn cách đều giả định đa số key đang ấm. Sau khi flush toàn bộ, làm ấm mấy key nóng nhất trước khi chuyển traffic, hoặc rate-limit đường tính lại.
- Giữ lock trong lúc gọi dịch vụ ngoài chậm. Nếu phần tính có gọi API bên thứ ba, timeout ở đó sẽ chặn mọi bên đang chờ. Đặt timeout HTTP tường minh, ngắn hơn TTL của lock.
Khi nào xài cách nào, khi nào không
- Traffic thấp hoặc tính rẻ:
remember()thường là đủ. Đừng thêm lock cho query 20 ms bị gọi hai lần mỗi phút. - Key nóng, chấp nhận dữ liệu cũ:
Cache::flexible(). Đây là cách ăn liền dễ nhất trong app Laravel và user không bao giờ phải chờ. - Key nóng, tính tốn kém, không có chỗ chạy nền (CLI, worker chạy lâu, framework khác): XFetch.
- Tuyệt đối không được tính hai lần (gọi API trả phí, upstream bị rate-limit): lock kèm double-check, có thể kết hợp với flexible.
- Nhiều key được ghi cùng lúc: thêm jitter, bất kể đã chọn cách nào.
Đừng xài mấy mẹo cache để che một query đáng lẽ phải sửa. Nếu bảng xếp hạng tốn 4 giây, một bảng tổng hợp được cập nhật dần có khi bỏ được hết mấy chuyện này.
Thử thách
Luyện tập ngay điều vừa học. Viết lời giải, mở gợi ý nếu bí.
Đề bài
Cài đặt cơ chế hết hạn sớm theo xác suất (XFetch). Trước tiên viết shouldRecompute(now, expiry, delta, beta, random) trả về true khi now - delta * beta * ln(random) >= expiry, với ln là logarit tự nhiên và random nằm trong (0, 1]. Sau đó cài get(key, ttl, compute) cho XFetchCache, constructor nhận một hàm clock inject vô, một hàm random inject vô và beta (mặc định 1.0). Nếu key đã có entry và shouldRecompute(clock(), expiry của entry, delta của entry, beta, random()) là false thì trả về giá trị đang cache. Ngược lại thì tính lại: đọc start = clock(), gọi compute(), đọc end = clock(), lưu giá trị cùng delta = end - start và expiry = end + ttl, rồi trả về giá trị. Chỉ gọi random() khi đã có entry (không bao giờ gọi khi miss), và lưu entry riêng theo từng key.
Code khởi tạo
/**
* XFetch decision: recompute when now - delta * beta * ln(random) >= expiry.
* $random is in (0, 1].
*/
function shouldRecompute(float $now, float $expiry, float $delta, float $beta, float $random): bool
{
// TODO: implement the XFetch formula (log() is the natural logarithm)
throw new LogicException('TODO');
}
final class XFetchCache
{
/** @var array<string, array{value: mixed, delta: float, expiry: float}> */
private array $entries = [];
public function __construct(
private Closure $clock,
private Closure $random,
private float $beta = 1.0,
) {}
public function get(string $key, float $ttl, Closure $compute): mixed
{
// TODO: if an entry exists and shouldRecompute(...) is false, return its value
// (call $this->random only when an entry exists)
// TODO: otherwise: start = clock(), value = compute(), delta = clock() - start,
// store value, delta and expiry = clock() + ttl, return value
throw new LogicException('TODO');
}
}
/**
* XFetch decision: recompute when now - delta * beta * ln(random) >= expiry.
* random is in (0, 1].
*/
function shouldRecompute(now, expiry, delta, beta, random) {
// TODO: implement the XFetch formula (Math.log is the natural logarithm)
throw new Error('TODO');
}
class XFetchCache {
constructor(clock, random, beta = 1.0) {
this.clock = clock;
this.random = random;
this.beta = beta;
this.entries = new Map();
}
get(key, ttl, compute) {
// TODO: if an entry exists and shouldRecompute(...) is false, return its value
// (call this.random only when an entry exists)
// TODO: otherwise: start = clock(), value = compute(), delta = clock() - start,
// store value, delta and expiry = clock() + ttl, return value
throw new Error('TODO');
}
}
Lời giải của bạn
Gợi ý
Đã mở hết gợi ýln(random) bằng 0 hoặc âm, nên trừ đi delta * beta * ln(random) là đẩy 'now' tới trước
Đọc clock trước và sau compute(): hiệu của hai lần đọc là delta, còn expiry tính từ thời điểm kết thúc
Thoát sớm khi chưa có entry trước khi gọi random() để không tốn nguồn random lúc miss
/**
* XFetch decision: recompute when now - delta * beta * ln(random) >= expiry.
* $random is in (0, 1].
*/
function shouldRecompute(float $now, float $expiry, float $delta, float $beta, float $random): bool
{
return $now - $delta * $beta * log($random) >= $expiry;
}
final class XFetchCache
{
/** @var array<string, array{value: mixed, delta: float, expiry: float}> */
private array $entries = [];
public function __construct(
private Closure $clock,
private Closure $random,
private float $beta = 1.0,
) {}
public function get(string $key, float $ttl, Closure $compute): mixed
{
$entry = $this->entries[$key] ?? null;
if ($entry !== null && !shouldRecompute(
($this->clock)(),
$entry['expiry'],
$entry['delta'],
$this->beta,
($this->random)(),
)) {
return $entry['value'];
}
$start = ($this->clock)();
$value = $compute();
$end = ($this->clock)();
$this->entries[$key] = [
'value' => $value,
'delta' => $end - $start,
'expiry' => $end + $ttl,
];
return $value;
}
}
/**
* XFetch decision: recompute when now - delta * beta * ln(random) >= expiry.
* random is in (0, 1].
*/
function shouldRecompute(now, expiry, delta, beta, random) {
return now - delta * beta * Math.log(random) >= expiry;
}
class XFetchCache {
constructor(clock, random, beta = 1.0) {
this.clock = clock;
this.random = random;
this.beta = beta;
this.entries = new Map();
}
get(key, ttl, compute) {
const entry = this.entries.get(key);
if (entry && !shouldRecompute(this.clock(), entry.expiry, entry.delta, this.beta, this.random())) {
return entry.value;
}
const start = this.clock();
const value = compute();
const end = this.clock();
this.entries.set(key, { value, delta: end - start, expiry: end + ttl });
return value;
}
}

