Cache với Redis: Cache-Aside và 3 sự cố kinh điển
“There are only two hard things in Computer Science: cache invalidation and naming things.” – Phil Karlton
Nội dung bài viết
Hi các bạn 👋. Mình là Hùng Anh.
Khi database bắt đầu è cổ vì lượng đọc quá lớn, câu trả lời đầu tiên của gần như mọi backend engineer là: "đặt một lớp Redis trước nó". Hợp lý — một lần đọc Redis mất cỡ 0.1ms, trong khi một query MySQL có index tốt cũng phải vài ms đến vài chục ms. Chênh nhau cả trăm lần.
Nhưng cache không phải cứ cắm vào là xong. Có một pattern cần làm đúng, một câu hỏi khó chịu về tính nhất quán, và ba sự cố kinh điển — penetration, breakdown, avalanche — từng đánh sập không ít hệ thống ngay giờ cao điểm. Hôm nay mình đi qua từng thứ một, kèm cách phòng cụ thể.
Bắt đầu thôi.
Cache-Aside: pattern nền tảng
Cách phổ biến nhất để dùng Redis làm cache là Cache-Aside (hay Lazy Loading): ứng dụng đứng ra điều phối, cache và DB không biết gì về nhau.
Đọc:
- Hỏi Redis trước (
GET product:42). - Hit → trả luôn, xong.
- Miss → query DB → ghi kết quả vào Redis kèm TTL (
SET product:42 {...} EX 600) → trả về.
Ghi:
- Cập nhật DB trước.
- Xoá key trong cache (
DEL product:42) — chứ không phải update cache.
Đoạn pseudo-code cho dễ hình dung:
public Product getProduct(long id) {
String key = "product:" + id;
Product cached = redis.get(key);
if (cached != null) return cached; // hit
Product fromDb = db.findProductById(id); // miss → DB
redis.set(key, fromDb, Duration.ofMinutes(10));
return fromDb;
}
public void updateProduct(Product p) {
db.update(p); // 1. DB trước
redis.del("product:" + p.getId()); // 2. rồi xoá cache
}
Vì sao là "xoá cache" mà không phải "update cache"?
Hai lý do:
- Tránh ghi lãng phí: nhiều dữ liệu được update xong chẳng ai đọc lại ngay. Update cache là tốn công ghi một thứ có thể không ai cần; xoá đi để lần đọc kế tiếp tự nạp là "lười" một cách hiệu quả.
- Tránh race condition ghi đè nhau: hai request cùng update một sản phẩm, thứ tự ghi DB là B-sau-A nhưng thứ tự ghi cache lại có thể là A-sau-B — thế là cache giữ dữ liệu cũ vô thời hạn. Xoá key thì tình huống xấu nhất chỉ là một lần cache miss.
Còn thứ tự "DB trước, xoá cache sau" thì sao?
Làm ngược lại (xoá cache trước, ghi DB sau) có một kẽ hở rõ rệt: giữa lúc bạn xoá cache và lúc DB ghi xong, một request đọc khác ập vào — miss → đọc DB ra dữ liệu cũ → cất lại vào cache. Kết quả: dữ liệu cũ nằm lì trong cache đến hết TTL.
Thứ tự DB-trước-xoá-sau cũng không tuyệt đối an toàn (vẫn có kẽ hở lý thuyết cực hẹp khi một lần đọc miss kéo dài vắt qua một lần ghi), nhưng xác suất nhỏ hơn nhiều bậc và luôn có TTL làm lưới đỡ cuối cùng. Chấp nhận eventual consistency có thời hạn — đó là cái giá chuẩn của cache.
Ba sự cố kinh điển
Pattern đúng rồi vẫn chưa đủ. Ba tình huống sau đều có chung một kết cục — tải đáng lẽ cache chịu bỗng dồn hết xuống DB — nhưng nguyên nhân và cách chữa khác hẳn nhau:
1. Cache Penetration — hỏi thứ không tồn tại
Request hỏi một key không có trong cache lẫn DB (ví dụ user_id = -1). Miss → xuống DB → DB trả "không có" → không có gì để cache → request sau lại xuyên thẳng xuống DB. Nếu ai đó cố tình bắn hàng loạt ID rác, DB của bạn hứng đủ.
Cách chữa:
- Cache cả giá trị rỗng: DB trả "không có" thì vẫn
SET key "" EX 60— TTL ngắn thôi. Request rác sau đó chết ngay ở lớp cache. - Bloom filter: nạp toàn bộ ID hợp lệ vào một Bloom filter; request có ID mà filter khẳng định "chắc chắn không tồn tại" thì chặn từ cửa, khỏi hỏi cache lẫn DB. (Redis Stack có sẵn lệnh
BF.ADD/BF.EXISTS.) - Đừng quên lớp cơ bản nhất: validate đầu vào — ID âm, sai định dạng thì từ chối ngay từ API.
2. Cache Breakdown — một key nóng hết hạn
Trang chi tiết một sản phẩm đang hot: 5.000 request/giây, tất cả đều hit cache. Rồi TTL của đúng key đó hết hạn. Trong tích tắc, 5.000 request cùng miss và cùng đổ xuống DB chạy cùng một câu query — DB bị dập bởi một khoảnh khắc hết hạn của một key duy nhất.
Cách chữa:
- Mutex / single-flight: chỉ cho một request đi rebuild cache (dùng
SET rebuild:key 1 NX EX 10làm khoá), số còn lại đợi vài chục ms rồi đọc lại cache. Một query thay cho năm nghìn. - TTL logic (logical expiration): không đặt TTL thật trên Redis; lưu thời điểm "hết hạn logic" ngay trong value. Đọc thấy quá hạn thì vẫn trả dữ liệu cũ ngay, đồng thời cho một job nền rebuild — người dùng không bao giờ phải đợi DB.
- Với vài key cực nóng đã biết trước (trang chủ, sản phẩm flash-sale): warm-up trước và không cho hết hạn tự nhiên — làm mới chủ động bằng scheduler.
3. Cache Avalanche — cả bức tường sụp một lúc
Hai kịch bản:
- Hết hạn hàng loạt: bạn warm-up một triệu key lúc 00:00 với cùng
TTL 3600— đúng 01:00, cả triệu key chết trong cùng một giây, toàn bộ traffic xuyên xuống DB. - Redis sập: node cache chết, hiệu ứng y hệt nhưng còn đột ngột hơn.
Cách chữa:
- TTL + jitter: cộng thêm một lượng ngẫu nhiên vào mỗi TTL (
3600 + random(0, 300)) để thời điểm hết hạn rải đều thay vì dồn cục. - HA cho Redis: replica + Sentinel, hoặc Redis Cluster — cache là hạ tầng critical, không phải "thêm cho vui".
- Phòng thủ ở tầng ứng dụng: rate-limit / circuit breaker trước DB, và chuẩn bị sẵn chế độ degrade (trả dữ liệu bớt tươi, tắt bớt tính năng phụ) khi cache vỡ. Thà phục vụ chậm và thiếu một chút còn hơn sập cả hệ thống.
Để ý điểm chung: cả ba cách chữa đều theo triết lý "đừng để DB một mình đối mặt với cơn lũ" — hoặc chặn lũ từ xa (Bloom filter, jitter), hoặc thu hẹp dòng chảy (mutex, rate-limit), hoặc chấp nhận phục vụ dữ liệu cũ (logical TTL, degrade).
Kết luận
Cache-Aside chỉ có bốn bước, nhưng làm đúng nó là cả một chuỗi quyết định: xoá thay vì update, DB trước cache sau, TTL là lưới an toàn cuối cùng. Và trước khi đưa cache ra production, hãy tự hỏi đủ ba câu:
- Key không tồn tại thì sao? → cache rỗng / Bloom filter
- Key nóng nhất hết hạn thì sao? → mutex / logical TTL
- Cả loạt key cùng hết hạn (hoặc Redis sập) thì sao? → jitter / HA / degrade
Trả lời được ba câu đó bằng thiết kế cụ thể, lớp cache của bạn mới thực sự là tấm khiên — thay vì một quả bom hẹn giờ đặt trước database.
Hẹn gặp lại các bạn ở những bài viết sau. Nếu thấy bài viết hữu ích, đừng quên chia sẻ và để lại bình luận nhé 👇.
Tài liệu tham khảo
- Redis Docs — Client-side caching & caching patterns
- Redis Docs — SET (tuỳ chọn NX/EX dùng làm mutex)
- Redis Stack — Bloom filter (BF.ADD / BF.EXISTS)
- Đọc thêm: chuyên mục Redis của xiaolincoding.com và loạt bài caching trên blog.algomaster.io
Bài viết liên quan
Transaction Isolation Levels: 4 nấc thang cô lập trong MySQL
Hai transaction cùng đọc–ghi một dòng dữ liệu thì chuyện gì xảy ra? Tuỳ vào isolation level bạn chọn, cùng một câu SELECT có thể trả về những kết quả rất khác nhau. Bài này mình giải thích 3 hiện tượng đọc "bẩn", 4 mức cô lập của SQL, và cách InnoDB hiện thực chúng bằng MVCC — kèm ví dụ chạy được để bạn tự kiểm chứng.
Distributed Lock và Cách Triển Khai với Redis
Trong các hệ thống phân tán, việc đảm bảo tính nhất quán của dữ liệu (data consistency) và ngăn chặn tranh chấp tài nguyên (race condition) là một thách thức lớn, đặc biệt khi nhiều tiến trình hoặc service truy cập đồng thời vào các tài nguyên dùng chung. Distributed lock là giải pháp hiệu quả để xử lý vấn đề này. Bài viết này mình sẽ giúp bạn hiểu rõ distributed lock là gì, tại sao nó cần thiết, các phương pháp để thực hiện và cách triển khai nó với Redis nhé.

Sự khác biệt giữa count(*) và count(1) và cái nào hiệu quả hơn?
Khi chúng ta đếm các bản ghi trong bảng dữ liệu, chúng ta đã quen với việc sử dụng hàm count để đếm, nhưng có nhiều loại tham số có thể được truyền trong hàm count, chẳng hạn như count(1), count(*), count(field), ... Vậy hàm count nào sẽ đem lại hiệu suất tốt nhất?
