Từ "Bảo Bối Tăng Tốc" Đến "Kẻ Hủy Diệt": Khám Phá Và Ngăn Chặn 4 Sự Cố Cache Nghiêm Trọng


Trong thế giới của hệ thống phân tán, cache được coi là một công cụ mạnh mẽ để tăng cường hiệu suất, giảm tải cho cơ sở dữ liệu và cải thiện trải nghiệm người dùng. Tuy nhiên, nếu không được quản lý cẩn thận, cache có thể trở thành một điểm yếu nghiêm trọng, dẫn đến những sự cố không mong muốn, thậm chí gây sập hệ thống. Bài viết này sẽ phân tích 4 kịch bản "phản chủ" phổ biến của cache và cung cấp các chiến lược phòng ngừa hiệu quả, kèm theo ví dụ minh họa bằng Node.js.
1. Vấn Đề Thunder Herd Problem
Thunder Herd Problem xảy ra khi một lượng lớn khóa (key) trong cache hết hạn đồng thời. Điều này dẫn đến một làn sóng lớn các yêu cầu cache miss (không tìm thấy dữ liệu trong cache), buộc tất cả các yêu cầu này phải đồng loạt truy vấn xuống cơ sở dữ liệu. Cơ sở dữ liệu, vốn không được thiết kế để xử lý lượng tải đột biến như vậy, có thể nhanh chóng bị quá tải, dẫn đến hiện tượng treo hoặc sập.
Tình huống thường gặp
Thiết lập thời gian hết hạn (expiry time) cố định cho toàn bộ các khóa, ví dụ: tất cả các khóa đều hết hạn sau 30 phút. Khi đến thời điểm đó, một lượng lớn yêu cầu cache miss sẽ cùng lúc đổ về cơ sở dữ liệu.
Cách khắc phục
Đặt thời gian hết hạn ngẫu nhiên (Random Expiry): Thay vì đặt thời gian hết hạn cố định, hãy thêm một khoảng ngẫu nhiên vào thời gian hết hạn. Ví dụ, nếu thời gian hết hạn mong muốn là 30 phút, bạn có thể đặt ngẫu nhiên trong khoảng 25 đến 35 phút. Điều này giúp phân tán các yêu cầu làm mới cache theo thời gian, tránh tình trạng hết hạn đồng loạt.
Sử dụng cơ chế khóa (Locking) hoặc phân luồng (Throttling): Đảm bảo rằng chỉ có một yêu cầu duy nhất được phép truy vấn xuống cơ sở dữ liệu để làm mới một khóa cache. Các yêu cầu khác sẽ chờ đợi khóa đó được làm mới và sau đó lấy dữ liệu từ cache.
Ví dụ Node.js
const redis = require('redis');
const client = redis.createClient();
async function getProduct(productId) {
const cacheKey = `product:${productId}`;
let product = await client.get(cacheKey);
if (product) {
console.log(`Cache hit for product ${productId}`);
return JSON.parse(product);
}
console.log(`Cache miss for product ${productId}. Fetching from DB...`);
// Sử dụng lock để tránh Thunder Herd
const lockKey = `lock:product:${productId}`;
const lockAcquired = await client.set(lockKey, 'locked', 'NX', 'EX', 10); // NX: chỉ set nếu key không tồn tại, EX 10s: hết hạn sau 10s
if (lockAcquired) {
try {
// Simulate fetching from database
await new Promise(resolve => setTimeout(resolve, 200)); // Giả lập độ trễ DB
const productFromDB = { id: productId, name: `Product ${productId}`, price: Math.random() * 100 };
// Đặt thời gian hết hạn ngẫu nhiên
const randomExpiry = 300 + Math.floor(Math.random() * 60); // 300s (5 phút) +/- 60s
await client.set(cacheKey, JSON.stringify(productFromDB), 'EX', randomExpiry);
console.log(`Product ${productId} fetched from DB and cached with expiry ${randomExpiry}s.`);
return productFromDB;
} finally {
await client.del(lockKey); // Giải phóng lock
}
} else {
// Nếu không lấy được lock, chờ đợi trong một khoảng thời gian ngắn rồi thử lại
await new Promise(resolve => setTimeout(resolve, 50));
return getProduct(productId); // Thử lại
}
}
// Example usage:
(async () => {
await client.connect();
for (let i = 0; i < 5; i++) {
getProduct(1); // Call the same product multiple times to simulate concurrency
}
await client.quit();
})();
2. Cache Penetration
Cache Penetration xảy ra khi một yêu cầu truy vấn đến một khóa không tồn tại trong cache và cũng không có trong cơ sở dữ liệu. Nếu ứng dụng liên tục gửi các yêu cầu cho các khóa không tồn tại này, chúng sẽ luôn cache miss và buộc phải truy vấn xuống cơ sở dữ liệu, gây lãng phí tài nguyên và có thể dẫn đến tấn công từ chối dịch vụ (DoS) nếu kẻ tấn công cố tình gửi các khóa "rác" để đốt tài nguyên DB.
Tình huống thường gặp
Yêu cầu truy cập các tài nguyên không tồn tại như /user/9999999
hoặc /product/abcxyz123
.
Cách khắc phục
Cache giá trị rỗng (Cache Null Values): Nếu một truy vấn xuống cơ sở dữ liệu và không tìm thấy dữ liệu, hãy cache một giá trị rỗng (ví dụ:
null
hoặc một đối tượng rỗng) cho khóa đó với một thời gian hết hạn ngắn. Điều này sẽ ngăn chặn các yêu cầu tiếp theo truy vấn xuống DB cho cùng một khóa không tồn tại.Sử dụng Bloom Filter: Bloom Filter là một cấu trúc dữ liệu xác suất tiết kiệm không gian, có thể kiểm tra xem một phần tử có phải là thành viên của một tập hợp hay không. Nó có thể trả về "có thể tồn tại" hoặc "chắc chắn không tồn tại". Bằng cách sử dụng Bloom Filter trước khi truy vấn cache hoặc DB, bạn có thể lọc sớm các yêu cầu cho các khóa chắc chắn không tồn tại, giảm tải không cần thiết.
Ví dụ Node.js
const redis = require('redis');
const client = redis.createClient();
async function getUser(userId) {
const cacheKey = `user:${userId}`;
let user = await client.get(cacheKey);
if (user !== null) { // Kiểm tra cả giá trị null đã cache
console.log(`Cache hit for user ${userId}`);
return user === 'NULL_VALUE' ? null : JSON.parse(user);
}
console.log(`Cache miss for user ${userId}. Fetching from DB...`);
// Simulate fetching from database
await new Promise(resolve => setTimeout(resolve, 100)); // Giả lập độ trễ DB
const userFromDB = userId % 2 === 0 ? { id: userId, name: `User ${userId}` } : null; // Giả lập user tồn tại/không tồn tại
if (userFromDB) {
await client.set(cacheKey, JSON.stringify(userFromDB), 'EX', 300); // Cache 5 phút
console.log(`User ${userId} fetched from DB and cached.`);
return userFromDB;
} else {
await client.set(cacheKey, 'NULL_VALUE', 'EX', 60); // Cache giá trị null trong 60 giây
console.log(`User ${userId} not found in DB. Cached NULL_VALUE.`);
return null;
}
}
// Example usage:
(async () => {
await client.connect();
await getUser(100); // User tồn tại
await getUser(101); // User không tồn tại
await getUser(101); // Lần 2 gọi user không tồn tại, sẽ là cache hit cho NULL_VALUE
await client.quit();
})();
3. Cache Breakdown
Cache Breakdown xảy ra khi một hot key (khóa có lượt truy cập cực lớn) đột ngột hết hạn. Tương tự như Thunder Herd, tất cả các yêu cầu cho hot key này sẽ đồng loạt truy vấn xuống cơ sở dữ liệu, gây ra tải đột biến và có thể làm sập hệ thống.
Tình huống thường gặp
Trang chủ hoặc các sản phẩm nổi bật thường có hot key. Ví dụ: trang chủ gọi GET /featured-products
với hàng ngàn lượt truy cập mỗi giây. Khi khóa Redis cho featured-products
hết hạn, toàn bộ yêu cầu sẽ đổ vào cơ sở dữ liệu.
Giải pháp
Không đặt expiry cho hot key (hoặc expiry rất dài): Với các hot key thực sự quan trọng và ít thay đổi, có thể cân nhắc không đặt thời gian hết hạn, hoặc đặt thời gian hết hạn rất dài và quản lý việc làm mới cache bằng cách thủ công hoặc theo lịch trình.
Tự động làm mới định kỳ (Auto-refresh): Thay vì chờ đợi hot key hết hạn, hãy có một tiến trình nền (background thread) định kỳ làm mới dữ liệu cho hot key trước khi nó hết hạn. Điều này đảm bảo rằng hot key luôn có dữ liệu mới và không bao giờ "bốc hơi".
Sử dụng cơ chế cập nhật không đồng bộ (Asynchronous Update): Khi một hot key sắp hết hạn, thay vì xóa nó ngay lập tức, hãy giữ lại giá trị cũ trong khi một tiến trình nền làm mới dữ liệu mới. Sau khi dữ liệu mới được làm mới, mới thay thế giá trị cũ.
Ví dụ Node.js
const redis = require('redis');
const client = redis.createClient();
const HOT_KEY = 'featuredProducts';
async function fetchFeaturedProductsFromDB() {
console.log('Fetching featured products from DB...');
// Simulate fetching from database
await new Promise(resolve => setTimeout(resolve, 500));
return [{ id: 1, name: 'Product A' }, { id: 2, name: 'Product B' }];
}
async function refreshHotKey() {
const products = await fetchFeaturedProductsFromDB();
await client.set(HOT_KEY, JSON.stringify(products));
console.log(`Hot key '${HOT_KEY}' refreshed.`);
}
async function getFeaturedProducts() {
let products = await client.get(HOT_KEY);
if (products) {
console.log(`Cache hit for hot key '${HOT_KEY}'`);
return JSON.parse(products);
}
// This scenario should be rare if auto-refresh is working
console.log(`Hot key '${HOT_KEY}' missing or expired. Falling back to DB.`);
return fetchFeaturedProductsFromDB(); // If cache is truly empty, fetch directly
}
(async () => {
await client.connect();
// Initial load of hot key
await refreshHotKey();
// Simulate auto-refresh every 30 seconds (before potential expiry if any)
setInterval(refreshHotKey, 30 * 1000);
// Simulate high traffic
for (let i = 0; i < 5; i++) {
await getFeaturedProducts();
await new Promise(resolve => setTimeout(resolve, 100)); // Simulate concurrent requests
}
// In a real application, you'd manage client.quit() appropriately
// For this example, we'll keep it running to show interval
// await client.quit();
})();
4. Cache Crash
Cache Crash xảy ra khi máy chủ cache (ví dụ: Redis) bị sập hoặc mất kết nối. Khi đó, toàn bộ các yêu cầu sẽ không thể truy cập cache và bị đẩy thẳng xuống cơ sở dữ liệu. Cơ sở dữ liệu, vốn đã được "giải phóng" một phần tải bởi cache, sẽ nhanh chóng bị quá tải, dẫn đến tình trạng timeout và sập toàn bộ hệ thống.
Tình huống điển hình
Redis gặp sự cố về mạng, lỗi phần cứng, hoặc bị kill đột ngột. Ứng dụng không có cơ chế dự phòng và gửi toàn bộ truy vấn xuống DB.
Giải pháp
Sử dụng hệ thống cache có tính sẵn sàng cao (High Availability - HA): Triển khai Redis Cluster, Redis Sentinel hoặc các giải pháp HA khác. Điều này đảm bảo rằng ngay cả khi một node cache gặp sự cố, các node khác có thể tiếp quản, duy trì hoạt động liên tục của dịch vụ cache.
Thiết lập Circuit Breaker (Cầu dao ngắt mạch): Circuit Breaker là một mô hình thiết kế giúp ứng dụng phát hiện và cách ly các lỗi của dịch vụ phụ thuộc. Nếu các yêu cầu tới cache liên tục thất bại, circuit breaker sẽ "mở", chuyển hướng các yêu cầu không truy vấn cache nữa mà có thể:
Trả về dữ liệu mặc định (Fallback Data): Cung cấp một tập dữ liệu mặc định hoặc phiên bản cũ của dữ liệu.
Giới hạn số lượng yêu cầu (Rate Limiting): Hạn chế số lượng yêu cầu được phép đi xuống cơ sở dữ liệu.
Ghi log và cảnh báo: Thông báo ngay lập tức về sự cố cache để đội ngũ vận hành có thể can thiệp.
Dữ liệu dự phòng (Fallback Mechanism): Luôn có một kế hoạch dự phòng nếu cache không khả dụng, ví dụ, truy vấn trực tiếp cơ sở dữ liệu nhưng với các biện pháp bảo vệ (như giới hạn tốc độ truy vấn) để tránh làm quá tải DB.
Ví dụ Node.js (với một Circuit Breaker đơn giản)
const redis = require('redis');
const client = redis.createClient();
// Simple Circuit Breaker state
let circuitOpen = false;
let failureCount = 0;
const FAILURE_THRESHOLD = 3;
const COOLDOWN_PERIOD = 5000; // 5 seconds
async function safeGetFromCache(key) {
if (circuitOpen) {
console.warn(`Circuit is OPEN. Skipping cache for ${key}.`);
return null; // Don't even try to hit the cache
}
try {
const data = await client.get(key);
failureCount = 0; // Reset failure count on success
return data;
} catch (error) {
console.error(`Error connecting to Redis: ${error.message}`);
failureCount++;
if (failureCount >= FAILURE_THRESHOLD) {
circuitOpen = true;
console.error(`Circuit BREAKER OPENED! Will retry after ${COOLDOWN_PERIOD / 1000}s.`);
setTimeout(() => {
circuitOpen = false;
failureCount = 0; // Reset for next attempt
console.log('Circuit BREAKER CLOSED. Retrying cache access.');
}, COOLDOWN_PERIOD);
}
return null;
}
}
async function getProductWithCircuitBreaker(productId) {
const cacheKey = `product:${productId}`;
let product = await safeGetFromCache(cacheKey);
if (product) {
console.log(`Cache hit for product ${productId}`);
return JSON.parse(product);
}
console.log(`Cache miss for product ${productId} or circuit open. Fetching from DB...`);
// Simulate fetching from database
await new Promise(resolve => setTimeout(resolve, 200));
const productFromDB = { id: productId, name: `Product ${productId}`, price: Math.random() * 100 };
if (!circuitOpen) { // Only attempt to set cache if circuit is closed
try {
await client.set(cacheKey, JSON.stringify(productFromDB), 'EX', 300);
console.log(`Product ${productId} fetched from DB and cached.`);
} catch (error) {
console.error(`Could not set cache for ${productId}: ${error.message}`);
}
} else {
console.log(`Skipping cache set for ${productId} due to open circuit.`);
}
return productFromDB;
}
// Example usage:
(async () => {
// Make sure Redis is running for initial successful calls
await client.connect();
console.log('--- Simulating successful Redis calls ---');
await getProductWithCircuitBreaker(1);
await getProductWithCircuitBreaker(2);
// Disconnect Redis client to simulate a crash/unavailability
console.log('\n--- Simulating Redis Crash (disconnecting client) ---');
await client.quit(); // Simulate Redis going down
// After client.quit(), subsequent calls to client.get will throw errors
await getProductWithCircuitBreaker(3); // First failure
await getProductWithCircuitBreaker(4); // Second failure
await getProductWithCircuitBreaker(5); // Third failure -> Circuit opens
await getProductWithCircuitBreaker(6); // Circuit is open, skips cache
// Wait for cooldown period (or manually restart Redis and client for real test)
await new Promise(resolve => setTimeout(resolve, COOLDOWN_PERIOD + 1000));
console('\n--- After Cooldown Period ---');
// In a real app, you would re-connect the client if Redis came back up.
// For this example, we'll reconnect here to show circuit closing.
const newClient = redis.createClient();
await newClient.connect();
await getProductWithCircuitBreaker(7); // Should try cache again
await newClient.quit();
})();
Tổng Kết Các Sự Cố Cache Và Giải Pháp
Sự cố | Nguyên nhân chính | Giải pháp then chốt |
Thunder Herd | Hết hạn khóa hàng loạt | Đặt thời gian hết hạn ngẫu nhiên, sử dụng cơ chế khóa/phân luồng |
Cache Penetration | Truy vấn khóa không tồn tại | Cache giá trị rỗng (null), sử dụng Bloom Filter |
Cache Breakdown | Khóa nóng hết hạn đột ngột | Không đặt expiry cho hot key, tự động làm mới định kỳ, cập nhật không đồng bộ |
Cache Crash | Máy chủ cache bị sập, hệ thống mất cache | Redis HA (Cluster/Sentinel), Circuit Breaker, cơ chế dự phòng |
Lời kết
Cache, dù là một công cụ mạnh mẽ, nhưng cũng tiềm ẩn rủi ro nếu không được quản lý đúng cách. Hiểu rõ các kịch bản "phản chủ" của cache và áp dụng các giải pháp phòng ngừa là yếu tố then chốt để xây dựng một hệ thống bền vững và có khả năng phục hồi cao.
Hãy thường xuyên đánh giá lại chiến lược cache của bạn bằng cách tự đặt ra các câu hỏi sau:
Các khóa trong cache của bạn đã có thời gian hết hạn ngẫu nhiên chưa?
Bạn đã có cơ chế xử lý các truy vấn cho các khóa không tồn tại chưa?
Các khóa nóng (hot key) có được bảo vệ khỏi tình trạng hết hạn đột ngột không?
Bạn đã có kế hoạch dự phòng (plan B) nếu hệ thống cache gặp sự cố chưa?
Việc chủ động ứng phó với những thách thức này sẽ giúp hệ thống của bạn hoạt động mượt mà và ổn định, ngay cả dưới áp lực tải lớn.
Subscribe to my newsletter
Read articles from Eminel directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Eminel
Eminel
Hello, my name is Eminel a software engineer specializing in scalable software architecture, microservices, and AI-powered platforms. Passionate about mentoring, performance optimization, and building solutions that drive business success.