Bộ nhớ đệm (Cache) là tấm lá chắn giúp cơ sở dữ liệu chịu được hàng chục ngàn lượt truy cập mỗi giây. Nhưng chuyện gì xảy ra vào đúng mili-giây mà một key dữ liệu quan trọng (như thông tin đợt flash sale hoặc bảng xếp hạng trang chủ) hết hạn (TTL Expired)?
Khi key biến mất, hàng ngàn request cùng lúc nhận về Cache Miss. Toàn bộ lượng truy cập này đồng loạt tràn thẳng xuống Database để truy vấn và tính toán lại, khiến CPU database chạm mốc 100% và sập nguồn ngay tức khắc. Hiện tượng này gọi là Cache Stampede (hoặc Thundering Herd Problem).
Dưới đây là 3 kỹ thuật phòng thủ thực chiến giúp hệ thống làm mới bộ đệm an toàn mà không bao giờ làm rung chuyển database.
1. Vì sao Cache Aside truyền thống lại mong manh?
Mô hình Cache-Aside thông thường được viết rất quen thuộc:
let data = await redis.get(key);
if (!data) {
// Điểm chết: 1.000 request cùng nhảy vào đây khi data hết hạn!
data = await fetchFromDatabase(key);
await redis.set(key, data, "EX", 300);
}
return data;
Khi traffic thấp, đoạn code này chạy hoàn hảo. Nhưng khi tải cao, trong khoảng 200–500ms mà database đang cặm cụi tính toán câu lệnh phức tạp cho request đầu tiên, hàng trăm request tiếp theo cũng thấy cache trống và tiếp tục ném truy vấn xuống đĩa cứng.
Hệ quả là database bị nghẽn I/O, kéo sập toàn bộ các dịch vụ phụ thuộc.
2. Kỹ thuật 1: Khóa phân tán (Mutex Lock / Single-Flight Pattern)
Ý tưởng cốt lõi: Khi cache hết hạn, chỉ cho phép đúng 1 request đầu tiên được quyền truy vấn database để tính toán lại. Toàn bộ các request còn lại phải xếp hàng chờ đợi hoặc đọc giá trị cũ.
Triển khai bằng lệnh SET NX trên Redis:
async function getWithMutex(key: string, ttlSeconds: number) {
let value = await redis.get(key);
if (value) return JSON.parse(value);
const lockKey = `lock:${key}`;
// Cố gắng chiếm lock trong tối đa 10 giây
const acquiredLock = await redis.set(lockKey, "LOCKED", "NX", "EX", 10);
if (acquiredLock) {
try {
// Duy nhất request này được chạm vào Database
const freshData = await fetchFromDatabase(key);
await redis.set(key, JSON.stringify(freshData), "EX", ttlSeconds);
return freshData;
} finally {
await redis.del(lockKey);
}
} else {
// Các request đến sau: Tạm dừng 50ms rồi thử đọc lại từ Redis
await new Promise((resolve) => setTimeout(resolve, 50));
return getWithMutex(key, ttlSeconds);
}
}
- Ưu điểm: Bảo vệ database tuyệt đối khỏi tải đột biến.
- Hạn chế: Các request đến sau phải chịu thêm độ trễ chờ đợi (latency penalty).
3. Kỹ thuật 2: Thuật toán XFetch (Làm mới xác suất trước khi hết hạn)
Thay vì thụ động đợi key hết hạn rồi mới vội vã tính lại, thuật toán XFetch (Probabilistic Early Expiration) áp dụng toán học xác suất để một request ngẫu nhiên âm thầm tính toán lại dữ liệu ngay trước khi key kịp chết:
Công thức quyết định tính lại dữ liệu:

Trong đó:
- Delta: Thời gian (giây) cần thiết để database tính toán xong dữ liệu.
- Beta: Hệ số quyết liệt (thường chọn beta > 0, mặc định là 1.0).
- random(): Một số ngẫu nhiên trong khoảng (0, 1].
interface CachePayload<T> {
data: T;
delta: number; // Thời gian tính toán ở lần trước (giây)
expireAt: number; // Mốc thời gian hết hạn tuyệt đối
}
async function getWithXFetch<T>(key: string, ttlSeconds: number, computeFn: () => Promise<T>): Promise<T> {
const cached = await redis.get(key);
const now = Date.now() / 1000;
if (cached) {
const payload: CachePayload<T> = JSON.parse(cached);
const ttlRemaining = payload.expireAt - now;
// Tính toán xác suất kích hoạt làm mới sớm
const beta = 1.0;
const shouldRefreshEarly = -(payload.delta * beta * Math.log(Math.random())) > ttlRemaining;
if (!shouldRefreshEarly) {
return payload.data;
}
}
// Kích hoạt tính toán lại dữ liệu
const startTime = Date.now();
const freshData = await computeFn();
const duration = (Date.now() - startTime) / 1000;
const payload: CachePayload<T> = {
data: freshData,
delta: duration,
expireAt: now + ttlSeconds,
};
await redis.set(key, JSON.stringify(payload), "EX", ttlSeconds * 2);
return freshData;
}
Nhờ xác suất này, khi key càng gần thời điểm hết hạn, khả năng một request kích hoạt tính ngầm càng cao. Key luôn được làm mới liên tục mà người dùng không bao giờ phải chịu độ trễ của việc chờ tính toán.
4. Kỹ thuật 3: Phân rã thời gian sống (TTL Jittering)
Một nguyên nhân phổ biến khác gây ra Cache Stampede là khi hệ thống khởi động hoặc chạy migration: hàng triệu sản phẩm được nạp vào cache cùng một thời điểm với cùng một giá trị TTL (ví dụ: EX: 3600).
Đúng 1 tiếng sau, toàn bộ hàng triệu key đó sẽ đồng loạt biến mất trong cùng một giây, tạo ra một đợt sóng thần đè bẹp hệ thống.
Giải pháp: Thêm độ lệch ngẫu nhiên (Jitter) vào thời gian sống của từng key:
function calculateJitteredTTL(baseTTL: number, variancePercentage = 0.2): number {
const min = baseTTL * (1 - variancePercentage);
const max = baseTTL * (1 + variancePercentage);
return Math.floor(Math.random() * (max - min + 1)) + min;
}
// Thay vì set cứng 3600s, TTL sẽ rải đều ngẫu nhiên từ 2880s đến 4320s
const safeTTL = calculateJitteredTTL(3600);
await redis.set(key, data, "EX", safeTTL);
5. Ba nguyên tắc sống còn khi vận hành Cache chịu tải cao
- Luôn có Fallback về Stale Data (Dữ liệu cũ): Trong các trang tin tức hoặc danh mục sản phẩm, hiển thị dữ liệu trễ 5–10 giây cho người dùng vẫn tốt hơn gấp ngàn lần việc trả về trang lỗi 500.
- Theo dõi tỷ lệ Cache Hit/Miss theo thời gian thực: Đặt cảnh báo nếu tỷ lệ Cache Hit tổng thể tụt xuống dưới 85%, vì đây là dấu hiệu báo trước database sắp bị quá tải.
- Giới hạn kích thước payload: Không lưu các object nặng hàng chục MB vào một key Redis duy nhất, vì thao tác giải mã (unserialize) ở tầng ứng dụng sẽ làm nghẽn Event Loop của Node.js.
Lời kết
Xây dựng hệ thống bộ đệm tốc độ cao không chỉ đơn thuần là gọi các lệnh GET và SET. Hiểu rõ hành vi của hệ thống ở những điểm ranh giới hết hạn và thiết lập các chốt chặn Mutex hay XFetch chính là yếu tố cốt lõi giúp ứng dụng của bạn đứng vững trước những đợt bùng nổ truy cập bất ngờ.


