Tưởng tượng một kịch bản rất đỗi quen thuộc trên production: hệ thống của bạn đang phục vụ lượng traffic lớn một cách mượt mà. Redis cache giữ các dữ liệu "nóng" như danh sách sản phẩm trang chủ hay các bài viết trending, giảm tải cho PostgreSQL/MySQL từ hàng chục nghìn query/giây xuống chỉ còn vài query. Mọi thứ hoạt động nhanh như chớp.
Thế nhưng, đúng 14:00:00, key cache đó hết hạn (TTL expire).
Chỉ trong một phần ngàn giây, 2.000 request từ người dùng ập vào API. Tất cả cùng kiểm tra Redis, nhận về nil, và ngay lập tức đồng loạt chọc thẳng xuống database để tính toán lại đúng tập dữ liệu đó. Kết quả? Database connection pool hết sạch trong chớp mắt, CPU vọt lên 100%, timeout lan truyền khắp các microservice và toàn bộ ứng dụng lăn đùng ra chết.
Chào mừng bạn đến với sự cố Cache Stampede (hay còn gọi là Thundering Herd problem). Trong bài viết này, hãy cùng Nguyên Tech tìm hiểu nguyên nhân cốt lõi và 3 giải pháp thực chiến để đè bẹp sự cố này trên production.
Bẫy Caching Ngây Thơ (Naive Caching)
Hầu hết lập trình viên khi mới viết cache đều áp dụng pattern "Read-Through" đơn giản như sau:
async function getTrendingProducts(): Promise<Product[]> {
const cacheKey = "products:trending";
const cachedData = await redis.get(cacheKey);
if (cachedData) {
return JSON.parse(cachedData);
}
// CACHE MISS: Truy vấn DB và ghi vào Redis
const products = await db.products.findMany({ where: { isTrending: true } });
await redis.set(cacheKey, JSON.stringify(products), "EX", 300); // Cache 5 phút
return products;
}
Đoạn code trên chạy cực kỳ hoàn hảo ở môi trường local hoặc khi traffic thấp. Nhưng ở môi trường production với tải cao, khi products:trending vừa hết hạn, hàng trăm request sẽ nhảy vào thực thi db.products.findMany() cùng một lúc trước khi request đầu tiên kịp tính xong để ghi lại vào Redis.
Nếu câu query DB mất 300ms, toàn bộ request đến trong 300ms đó sẽ biến thành những cú "đấm" trực tiếp vào database mà không hề có bất kỳ lớp đệm nào đỡ lại.
Giải pháp 1: Distributed Mutex Lock (Single Flight)

Cách tư duy trực diện nhất là đảm bảo chỉ duy nhất 1 process được phép query DB để tính toán lại cache, tất cả các request khác đến sau phải chờ hoặc nhận dữ liệu cũ.
Trong Go hay Node.js, kỹ thuật này thường được gọi là "SingleFlight". Với Redis, chúng ta có thể dựng cơ chế khóa nguyên tử (atomic lock) bằng lệnh SET key value NX PX ttl:
async function getTrendingProductsLocked(): Promise<Product[]> {
const cacheKey = "products:trending";
const lockKey = "lock:products:trending";
// 1. Đọc cache trước
const cachedData = await redis.get(cacheKey);
if (cachedData) return JSON.parse(cachedData);
// 2. Thử lấy lock (khóa tối đa 5 giây)
const acquired = await redis.set(lockKey, "1", "NX", "PX", 5000);
if (!acquired) {
// 3. Không lấy được lock -> Chờ 100ms rồi thử đọc lại cache
await new Promise((resolve) => setTimeout(resolve, 100));
return getTrendingProductsLocked();
}
try {
// 4. Đã lấy được lock: Chúng ta là request duy nhất được query DB
const products = await db.products.findMany({ where: { isTrending: true } });
await redis.set(cacheKey, JSON.stringify(products), "EX", 300);
return products;
} finally {
// 5. Luôn giải phóng lock khi hoàn thành
await redis.del(lockKey);
}
}
Ưu và nhược điểm
- Ưu điểm: Đảm bảo 100% chỉ có 1 câu query DB chạy khi bị cache miss.
- Nhược điểm: Các request phải chờ sẽ bị tăng độ trễ (latency). Bạn cũng cần cài đặt TTL cho lock thật cẩn thận để tránh deadlock nếu tiến trình lỡ bị crash.
Giải pháp 2: Stale-While-Revalidate (SWR) Với Soft Expiration

Thay vì để key hết hạn hoàn toàn trong Redis (hard expiration), chúng ta lưu một struct chứa dữ liệu kèm theo mốc thời gian soft_expire_at.
Khi có request đọc cache:
- Nếu
now < soft_expire_at: Trả về dữ liệu ngay lập tức (dữ liệu còn mới). - Nếu
now >= soft_expire_at: Vẫn trả về dữ liệu cũ ngay lập tức cho user, nhưng đồng thời kích hoạt một background job bất đồng bộ để query DB và cập nhật lại Redis.
interface CachePayload<T> {
data: T;
softExpireAt: number;
}
async function getTrendingProductsSWR(): Promise<Product[]> {
const cacheKey = "products:trending";
const raw = await redis.get(cacheKey);
if (raw) {
const payload: CachePayload<Product[]> = JSON.parse(raw);
const isStale = Date.now() > payload.softExpireAt;
if (isStale) {
// Kích hoạt làm mới cache ở background (không block request)
refreshCacheInBackground(cacheKey).catch(console.error);
}
// Trả về dữ liệu ngay lập tức, chấp nhận cũ hơn một chút!
return payload.data;
}
// Trường hợp Cold Start (chưa có cache bao giờ)
return rebuildCache(cacheKey);
}
Ưu và nhược điểm
- Ưu điểm: Latency bằng 0 đối với người dùng cuối — response cực nhanh vì luôn đọc từ Redis.
- Nhược điểm: Chấp nhận dữ liệu có thể hơi cũ (stale) trong một khoảng thời gian ngắn khi hệ thống đang làm mới.
Giải pháp 3: Probabilistic Early Expiration (XFetch)
Nếu hệ thống không cho phép trả về dữ liệu cũ và bạn cũng không muốn quản lý background worker phức tạp, hãy tham khảo thuật toán Probabilistic Early Expiration (XFetch).
Ý tưởng cốt lõi: Thay vì đợi TTL về đúng 0 mới tính lại cache, các request sẽ ngẫu nhiên quyết định tính toán lại cache trước khi nó thực sự hết hạn. Key càng gần mốc hết hạn và thời gian tính toán query trước đó càng lâu, xác suất request đứng ra làm mới cache càng cao.
Công thức quyết định đơn giản như sau:
now - (delta * beta * log(rand())) > expiry
Trong đó:
delta: Thời gian (tính bằng ms) cần thiết để query/tính toán ra dữ liệu ở lần trước.beta: Hệ số điều chỉnh (thường đặt là1.0).rand(): Số thực ngẫu nhiên từ 0 đến 1.
function shouldRecompute(expiryTimestamp: number, delta: number, beta = 1.0): boolean {
const now = Date.now();
const randomFactor = -Math.log(Math.random());
return now - delta * beta * randomFactor > expiryTimestamp;
}
Nhờ yếu tố ngẫu nhiên rand(), chỉ có đúng 1 hoặc 2 request chọn đứng ra làm mới cache trước thời điểm expire thực sự một khoảng ngắn, giúp cache luôn tươi mới mà không bao giờ gây ra hiện tượng thundering herd.
Lựa Chọn Kỹ Thuật Nào Cho Dự Án Của Bạn?
Dưới đây là tóm tắt gợi ý thực chiến từ kinh nghiệm của Nguyên:
- Trang danh mục sản phẩm / Feed bài viết: Dùng Stale-While-Revalidate (SWR). Người dùng thà xem danh sách chậm cập nhật 5 giây nhưng load trong 10ms, còn hơn phải đợi 2 giây để tải dữ liệu mới nhất.
- Dữ liệu tài chính / Tồn kho hàng hóa: Dùng Distributed Mutex Lock. Bạn không thể chấp nhận hiển thị sai số dư hay lệch kho, nên việc bắt các request chờ một chút là hoàn toàn xứng đáng.
- API Gateway tải cao / Microservices: Dùng Probabilistic Early Expiration (XFetch). Không cần quản lý lock, không cần background worker mà vẫn giữ hệ thống ổn định tuyệt đối.
Lời Kết
Cache stampede là một trong những loại bug "ngầm" rất nguy hiểm: nó im lặng ở môi trường staging nhưng sẽ đánh sập database ngay khi sản phẩm đón cơn bão traffic thật. Bằng cách nâng cấp đoạn code naive cache bằng Lock, SWR hoặc XFetch, hệ thống backend của bạn sẽ vững như bàn thạch trước mọi đợt spike tải.
Anh em đang dùng phương pháp caching nào trong dự án của mình? Hãy chia sẻ bên dưới phần bình luận nhé!

Phản hồi
Đang tải bình luận…