Bỏ qua để đến nội dung
Knowledge Hub

Cache stampede: nhiều request cùng nạp lại một key Trung cấp

Thời gian đọc ước tính: 9 phút

Cần đọc trước

Latency endpoint và số query database tăng theo chu kỳ, trong khi request rate gần như không đổi. Nếu chu kỳ trùng TTL của một hot key (ví dụ 5 phút), nhiều request đang cùng nạp lại key đó.

Cache miss không phải stampede. Một request miss rồi nạp lại là bình thường. Stampede là khi nhiều request cùng miss trước khi request đầu nạp xong, nên công việc giống nhau chạy nhiều lần.

examples/src/cache-stampede/naive.ts: cache-aside không gộp request. Query DB giả lập mất 300 ms; chạy 20 request đồng thời khi cache trống.

async function naiveCacheAside(key: string): Promise<string> {
const cached = await redis.get(key);
if (cached) return cached;
const value = await expensiveQuery(key); // giả lập DB, mất 300ms
await redis.set(key, value, 'EX', 5);
return value;
}
await Promise.all(Array.from({ length: 20 }, () => naiveCacheAside(key)));

Output thực tế:

20 request đồng thời, cache đang trống (cache miss) — cache-aside không có khoá:
[DB] Bắt đầu query cho "stampede:naive" (lần gọi DB thứ 1)
[DB] Bắt đầu query cho "stampede:naive" (lần gọi DB thứ 2)
... (18 dòng "Bắt đầu query" tương tự, gần như cùng lúc) ...
[DB] Bắt đầu query cho "stampede:naive" (lần gọi DB thứ 20)
[DB] Xong query lần thứ 1
... (19 dòng "Xong query" tương tự) ...
Số lần DB thực sự bị gọi: 20 (mong đợi trong thực tế: chỉ 1 lần)
Số giá trị khác nhau trong 20 kết quả: 7

20 request tạo 20 lần query cho một key. Cả 20 đã qua điều kiện if (cached) trước khi bất kỳ SET nào xảy ra, nên SET đầu tiên không kéo 19 request kia về nhánh hit.

Cache stampede khi nhiều request cùng miss một key
Cache stampede khi nhiều request cùng miss một keyBước 1/4: Ba request vào ứng dụng cùng đọc một key đã hết hạn. Cả ba đều cache miss.Request 1Request 2Request 3CacheDatabase
Request 1 · Request 2 · Request 3 · Cache
Bước 1/4

Ba request vào ứng dụng cùng đọc một key đã hết hạn. Cả ba đều cache miss.

Ba tình huống hay gặp:

  • Hot key có TTL cố định hết hạn: mọi request đang chạy đúng lúc đó đều miss.
  • Cache restart hoặc flush: mọi key mất cùng lúc.
  • Nhiều instance khởi động cùng lúc (deploy, autoscale) và cùng làm nóng cache.

Số request nạp trùng xấp xỉ request rate × thời gian nạp. Key nhận 100 request/giây và lần nạp mất 0,3 giây:

t = 0 ms key hết hạn
t = 0..300 ~30 request đến, tất cả thấy miss
███████████████████████████████ 30 query giống nhau xuống DB
t = 300 ms request đầu SET xong; từ đây là hit

Đây là phép ước lượng, không phải đo: số thực tế còn phụ thuộc cách request đến và connection pool. Query càng chậm, cửa sổ càng rộng, và vì DB chậm đi khi quá tải, cửa sổ tự giãn thêm.

Tăng TTL từ 5 lên 30 phút làm sự kiện thưa hơn nhưng không loại bỏ nó. Jitter TTL giãn thời điểm hết hạn của nhiều key; nó không gộp các request nạp một key.

Đối chiếu trong cùng cửa sổ thời gian: cache miss rate, query count theo fingerprint, tải connection pool. Nhiều request cùng miss một key rồi sinh query giống nhau là bằng chứng trực tiếp. Loại trừ batch job và cache warming trước. Thêm metric “số loader đang chạy theo nhóm key” để phân biệt stampede với traffic thật sự tăng.

Lock theo key (Redis) Singleflight (trong process) XFetch (làm mới sớm) Stale-while-revalidate
Phạm vi Giữa nhiều instance Một process Mỗi request tự quyết Một loader nền
Request còn lại Chờ có deadline Chờ chung một Promise Vẫn đọc bản cache, không chờ Nhận bản cũ ngay
Dữ liệu cũ Không Không Không (làm mới trước hạn) Có, tạm thời
Latency khi hết hạn Cao với request chờ Cao với request chờ Gần như không đổi Không đổi
Độ phức tạp Cao (token, lease, deadline) Thấp Thấp, cần đo thời gian nạp Trung bình

Mặc định: singleflight cho mỗi instance, cộng XFetch hoặc stale-while-revalidate cho hot key. Chỉ dùng lock phân tán khi chi phí nạp đủ lớn để một lần nạp mỗi instance vẫn quá nhiều.

const inflight = new Map<string, Promise<string>>();
function load(key: string, loader: () => Promise<string>): Promise<string> {
let p = inflight.get(key);
if (!p) {
p = loader().finally(() => inflight.delete(key));
inflight.set(key, p);
}
return p; // mọi request cùng key nhận chung một Promise
}

Mỗi process chỉ có một lần nạp cho mỗi key. Với 10 instance, DB vẫn nhận tối đa 10 query thay vì hàng trăm.

Demo with-lock.ts trong repo giảm 20 query xuống 1:

20 request đồng thời, cache đang trống (cache miss) — cache-aside có khoá SET NX PX:
[DB] Bắt đầu query cho "stampede:with-lock" (lần gọi DB thứ 1)
[DB] Xong query lần thứ 1
Số lần DB thực sự bị gọi: 1 (mong đợi: đúng 1 lần)
Số giá trị khác nhau trong 20 kết quả: 1

Bản demo dùng token cố định, DEL không kiểm tra chủ sở hữu và vòng chờ vô hạn, nên không dùng cho production. Bản an toàn hơn (minh hoạ, chưa chạy trong repo):

const RELEASE = `if redis.call('get', KEYS[1]) == ARGV[1]
then return redis.call('del', KEYS[1]) else return 0 end`;
async function getOrLoad(key: string, loader: () => Promise<string>) {
const hit = await redis.get(key);
if (hit) return hit;
const lockKey = `lock:${key}`;
const token = randomUUID();
if ((await redis.set(lockKey, token, 'PX', 5000, 'NX')) === 'OK') {
try {
const again = await redis.get(key); // đọc lại sau khi có lock
if (again) return again;
const value = await loader();
await redis.set(key, value, 'EX', 300 + Math.floor(Math.random() * 60));
return value;
} finally {
await redis.eval(RELEASE, 1, lockKey, token); // chỉ xoá nếu còn là lock của mình
}
}
const deadline = Date.now() + 2000; // chờ có deadline
while (Date.now() < deadline) {
await sleep(30 + Math.random() * 20);
const v = await redis.get(key);
if (v) return v;
}
throw new Error('cache busy'); // hoặc trả bản cũ nếu có
}

Vì sao phải so token khi nhả lock: A giữ lock 2 giây nhưng query mất 3 giây; lock hết hạn, B lấy lock mới; nếu A chạy DEL vô điều kiện, A xoá lock của B. Lease có TTL không bảo đảm chỉ một loader chạy trong mọi tình huống; xem hướng dẫn lock của Redis.

Bài báo của Vattani và cộng sự (PVLDB 2015) cho mỗi request một xác suất tự làm mới tăng dần khi key gần hết hạn. Request làm mới khi:

now - delta * beta * ln(rand()) >= expiry
  • delta: thời gian lần nạp gần nhất mất bao lâu (lưu cùng giá trị cache).
  • beta: hệ số, mặc định 1.0; lớn hơn thì làm mới sớm hơn.
  • rand(): số ngẫu nhiên trong (0, 1]; ln(rand()) âm nên vế trái luôn lớn hơn now.

Nạp càng chậm (delta lớn) thì làm mới càng sớm; chỉ một request thường “trúng” trước hạn nên hiếm khi có đợt nạp trùng. Nó không cần lock, đổi lại phải lưu delta và expiry cùng giá trị.

Khi hết hạn, trả ngay giá trị cũ và kích một request nền duy nhất làm mới. Hợp khi dữ liệu hơi cũ vẫn chấp nhận được và latency phải ổn định.

  1. Hot key: singleflight trong process, cộng XFetch hoặc stale-while-revalidate.
  2. TTL có jitter; key nạp hàng loạt không hết hạn cùng giây (xem expiration và eviction).
  3. Nếu dùng lock: token ngẫu nhiên, nhả bằng compare-and-delete, đọc lại cache sau khi có lock, request chờ có deadline.
  4. Loader có timeout riêng và đường xử lý khi lỗi (bản cũ hoặc lỗi nhanh), không chờ vô hạn.
  5. Sau flush/restart/deploy: làm nóng key quan trọng có kiểm soát, không để mọi instance cùng làm nóng.
  6. Metric: số loader đang chạy theo nhóm key, miss rate, query DB theo fingerprint.
  7. Nền tảng pattern gốc: cache-aside.

Tự kiểm tra: (1) Vì sao tăng TTL không chữa được stampede? (2) Lock hết hạn giữa chừng gây lỗi gì nếu DEL vô điều kiện? (3) XFetch cần lưu thêm gì cùng giá trị cache?