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
Triệu chứng
Phần tiêu đề “Triệu chứng”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.
Tái hiện
Phần tiêu đề “Tái hiệ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ả: 720 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.
Nguyên nhân gốc
Phần tiêu đề “Nguyên nhân gốc”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.
Cửa sổ 300 ms chứa bao nhiêu request
Phần tiêu đề “Cửa sổ 300 ms chứa bao nhiêu request”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ạnt = 0..300 ~30 request đến, tất cả thấy miss ███████████████████████████████ 30 query giống nhau xuống DBt = 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.
Cách phát hiện
Phần tiêu đề “Cách phát hiện”Đố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.
Khắc phục ngay
Phần tiêu đề “Khắc phục ngay”Bốn cách phòng ngừa
Phần tiêu đề “Bốn cách phòng ngừa”| 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.
Singleflight: 8 dòng
Phần tiêu đề “Singleflight: 8 dòng”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.
Lock có token và compare-and-delete
Phần tiêu đề “Lock có token và compare-and-delete”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ả: 1Bả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.
XFetch: làm mới sớm theo xác suất
Phần tiêu đề “XFetch: làm mới sớm theo xác suất”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()) >= expirydelta: 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ơnnow.
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ị.
Stale-while-revalidate
Phần tiêu đề “Stale-while-revalidate”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.
Checklist production
Phần tiêu đề “Checklist production”- Hot key: singleflight trong process, cộng XFetch hoặc stale-while-revalidate.
- TTL có jitter; key nạp hàng loạt không hết hạn cùng giây (xem expiration và eviction).
- 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.
- 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.
- 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.
- Metric: số loader đang chạy theo nhóm key, miss rate, query DB theo fingerprint.
- Nền tảng pattern gốc: cache-aside.
Tóm tắt
Phần tiêu đề “Tóm tắt”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?