Triệu chứng: database trở thành bottleneck
Hệ thống đang chậm. Bạn mở monitoring và thấy:
- query count tăng tuyến tính theo số user
- cùng một query chạy hàng trăm lần mỗi giây với kết quả không đổi
- database CPU liên tục ở mức 80–90%
- response time API tăng theo từng giờ cao điểm
Đây là dấu hiệu hệ thống đang đẩy mọi request xuống database, kể cả những data không hề thay đổi.
Cache giải quyết bằng cách giữ kết quả đã tính ở tầng nhanh hơn (memory), để lần sau không cần chạy lại query tốn kém đó.
Caching giải quyết bài toán nào
Cache không phải thuốc chữa bách bệnh. Nó chỉ có ích khi:
- Data ít thay đổi hoặc thay đổi có thể chấp nhận độ trễ vài giây đến vài phút
- Cùng data được đọc nhiều lần bởi nhiều user hoặc nhiều request
- Chi phí tính toán cao: query phức tạp, join nhiều bảng, aggregate lớn
- External API có rate limit hoặc latency cao
Cache không hợp khi data cần real-time tuyệt đối: số dư tài khoản, trạng thái đơn hàng đang xử lý, inventory theo từng giao dịch.
Cache-Aside (Lazy Loading)
Chiến lược phổ biến nhất. Application tự quản lý cache theo flow:
1. Đọc từ cache
2. Nếu có → trả về ngay (cache hit)
3. Nếu không có → query database → lưu vào cache → trả về
async function getUserById(id: string) {
const cacheKey = `user:${id}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const user = await db.users.findUnique({ where: { id } });
if (user) {
await redis.set(cacheKey, JSON.stringify(user), 'EX', 300); // TTL 5 phút
}
return user;
}
Ưu điểm
- Chỉ cache data thực sự được đọc — không lãng phí memory
- Cache miss không crash hệ thống, chỉ chậm hơn bình thường
- Dễ implement, không cần thay đổi database layer
Nhược điểm
- Cache miss đầu tiên luôn chậm vì phải đi xuống DB
- Thundering herd: khi cache expire, nhiều request đến cùng lúc cùng cache miss, tất cả đổ xuống DB cùng một thời điểm
- Data trong cache có thể stale nếu DB bị update ngoài luồng code chính
Xử lý thundering herd
Dùng lock khi populate cache để chỉ một request được phép query DB:
async function getUserById(id: string) {
const cacheKey = `user:${id}`;
const lockKey = `lock:${cacheKey}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const lock = await redis.set(lockKey, '1', 'NX', 'EX', 5);
if (!lock) {
// Có request khác đang populate, đợi rồi retry
await sleep(50);
return getUserById(id);
}
try {
const user = await db.users.findUnique({ where: { id } });
if (user) await redis.set(cacheKey, JSON.stringify(user), 'EX', 300);
return user;
} finally {
await redis.del(lockKey);
}
}
Write-Through
Khi write vào database, đồng thời update cache luôn:
Write request → write DB → write cache → trả về OK
async function updateUser(id: string, data: UpdateUserDto) {
const user = await db.users.update({ where: { id }, data });
const cacheKey = `user:${id}`;
await redis.set(cacheKey, JSON.stringify(user), 'EX', 300);
return user;
}
Ưu điểm
- Cache luôn có data mới nhất sau mỗi write — không có stale data
- Read sau write ngay lập tức luôn hit cache
Nhược điểm
- Write latency tăng vì phải ghi cả hai nơi
- Cache chứa data của mọi record kể cả những record hiếm khi đọc — tốn memory
- Nếu write DB thành công nhưng write cache thất bại, cần xử lý inconsistency
Dùng khi nào
Phù hợp với data vừa read-heavy vừa hay được update, và bạn cần đảm bảo cache không bao giờ stale. Ví dụ: profile user, setting hệ thống, config hay thay đổi.
Write-Behind (Write-Back)
Write vào cache trước, sau đó async flush xuống database:
Write request → write cache → trả về OK ngay
→ background job write DB
Ưu điểm
- Write latency cực thấp — chỉ tốn thời gian ghi cache
- Phù hợp với hệ thống cần throughput write cao: view count, like count, analytics event
Nhược điểm
- Risk mất data: nếu cache server chết trước khi flush xuống DB, data mất
- Phức tạp hơn để implement đúng
- Khó debug khi DB và cache không đồng bộ
Ví dụ phù hợp: đếm view bài viết
// Tăng counter trong Redis
async function incrementPostView(postId: string) {
await redis.incr(`post:${postId}:views`);
}
// Background job chạy mỗi 5 phút: flush về DB
async function flushViewCountsToDb() {
const keys = await redis.keys('post:*:views');
for (const key of keys) {
const postId = key.split(':')[1];
const views = parseInt(await redis.get(key) ?? '0');
await db.posts.update({
where: { id: postId },
data: { views: { increment: views } },
});
await redis.del(key);
}
}
Read-Through
Cache tự động fetch từ DB khi miss, thay vì để application xử lý. Thường được implement qua library hoặc caching layer (như Cacheable trong NestJS, hoặc cache provider trong ORMs).
// NestJS với @CacheTTL decorator
@Injectable()
export class UserService {
@Cacheable('user', { ttl: 300 })
async getUserById(id: string) {
return this.db.users.findUnique({ where: { id } });
}
}
Về logic giống Cache-Aside nhưng application code không cần tự viết cache hit/miss logic — library xử lý.
Cache Invalidation — bài toán khó nhất
"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton
Có 3 cách invalidate cache:
1. TTL-based (expire tự động)
Đơn giản nhất. Data tự expire sau thời gian định sẵn.
await redis.set(key, value, 'EX', 300); // expire sau 5 phút
Hợp khi: data có thể stale vài phút không ảnh hưởng nghiêm trọng (catalog sản phẩm, bài viết, config ít thay đổi).
Không hợp khi: data cần nhất quán ngay lập tức.
2. Event-based invalidation
Khi có write, xóa hoặc update cache key liên quan:
async function updateProductPrice(productId: string, price: number) {
await db.products.update({ where: { id: productId }, data: { price } });
// Xóa các cache key liên quan
await redis.del(`product:${productId}`);
await redis.del(`category:${product.categoryId}:products`); // cache list cũng bị stale
await redis.del(`homepage:featured`);
}
Điểm khó: phải biết hết những cache key nào bị ảnh hưởng bởi một write. Khi hệ thống lớn lên, danh sách này dài và dễ sót.
3. Tag-based invalidation
Gán tag cho cache key, invalidate theo tag:
// Lưu cache kèm tag
await cacheWithTag(`product:${id}`, data, ['products', `category:${categoryId}`]);
// Khi category thay đổi, invalidate tất cả key có tag đó
await invalidateByTag(`category:${categoryId}`);
Phức tạp hơn để tự implement nhưng một số cache library hỗ trợ sẵn (Next.js có revalidateTag, một số Redis wrapper có tags).
Chọn TTL bao lâu
Không có con số chung cho mọi trường hợp. Nguyên tắc:
| Loại data | TTL gợi ý | Lý do |
|---|---|---|
| Static config, feature flag | 5–15 phút | Ít thay đổi, có thể stale ngắn |
| Catalog sản phẩm, bài viết | 1–5 phút | Update không quá thường xuyên |
| Kết quả search, list bài | 30–60 giây | Có thể stale ngắn, query nặng |
| Profile user | 5 phút + invalidate on write | Thay đổi ít nhưng cần đúng khi đổi |
| Session, auth token | Theo thời gian session | Phải khớp với auth logic |
| Real-time data (giá, tồn kho) | Không cache hoặc TTL rất ngắn ≤5s | Độ trễ ảnh hưởng nghiệp vụ |
Các lỗi hay gặp
Cache toàn bộ response API
Caching cả response kể cả data user-specific dẫn đến user A thấy data của user B. Chỉ cache data không phụ thuộc user hoặc phải bao gồm user ID trong cache key.
Cache key không đủ unique
// Sai: hai query khác nhau dùng cùng key
await redis.set('products', JSON.stringify(products));
// Đúng: key phải phản ánh đủ tham số
const key = `products:category:${categoryId}:page:${page}:limit:${limit}`;
Không handle cache stampede khi restart
Khi deploy lại hoặc cache server restart, mọi key đều expire cùng lúc, toàn bộ traffic đổ vào DB. Giải pháp: dùng stale-while-revalidate pattern hoặc warm cache trước khi traffic vào.
Lưu object quá lớn vào cache
Redis phù hợp cho object nhỏ đến trung bình. Object vài MB nên được lưu ở object storage và cache chỉ giữ metadata hoặc signed URL.
Không monitor cache hit rate
Cache hit rate dưới 80% thường là dấu hiệu TTL quá ngắn, cache key sai, hoặc data thực sự không phù hợp để cache.
Checklist chốt bài
- Data này có thực sự được đọc nhiều lần với cùng tham số không
- Data này có thể chấp nhận stale bao lâu
- Cache key có đủ unique để tránh serve sai data không
- Khi write, đã invalidate đủ cache key liên quan chưa
- Đã có metric theo dõi hit rate chưa
- Đã nghĩ đến thundering herd khi cache mass expire chưa
- Object lưu vào cache có quá lớn không