Cache-aside (hay "lazy loading") là cách dùng cache phổ biến nhất: đọc cache trước, nếu không có (miss) thì truy vấn database rồi nạp vào cache cho lần sau. Đơn giản và hiệu quả — cho tới khi gặp một tình huống kinh điển làm sập hệ thống: cache stampede (còn gọi thundering herd). Một key nóng (được truy cập rất nhiều) hết hạn, và ngay lập tức hàng chục, hàng nghìn request đến cùng lúc đều thấy cache miss, đều cùng lao vào query database cái giống hệt nhau — database vốn được cache che chở bỗng nhận cả loạt truy vấn trùng lặp và có thể gục. Bài này (phần 7/12) đo thật hiện tượng này và cách chặn nó bằng một lock đơn giản.
Cache-aside và lỗ hổng stampede

Hình 1: Cache-aside đọc cache trước, miss thì query DB rồi nạp. Lỗ hổng: key nóng hết hạn → N request cùng miss cùng query DB (stampede). Chống bằng lock SET NX — chỉ một request query DB, số còn lại chờ hoặc dùng giá trị cũ.
Cache-aside bình thường:
v = GET(key)
if v is None: # cache MISS
v = query_DB() # chạm database (chậm)
SET(key, v, EX=60) # nạp vào cache có TTL
return v # HIT: trả ngay từ cache
Vấn đề: giữa lúc key vắng và lúc request đầu tiên nạp xong cache, mọi request khác đến cũng thấy miss và cũng query DB. N request → N truy vấn DB trùng lặp.
Đo thật: stampede và cách chặn
Mình mô phỏng trong redis-lab: "DB" là một thao tác chậm (sleep 100-200ms), và đếm số lần query DB thật sự xảy ra bằng một counter. Chạy 20 client đồng thời khi key vắng:

Hình 2: Đo thật. (1) Cache-aside: MISS 204ms (query DB) vs HIT 1ms (từ cache). (2) Stampede: 20 client đồng thời khi key vắng → 20/20 lần query DB. (3) Lock SET NX: chỉ 1/20 query DB, 19 request còn lại không chạm DB.
Kết quả thật:
- ① Cache-aside — HIT nhanh hơn MISS 200 lần: lần đầu (miss) phải query "DB" mất 204ms; lần sau (hit) lấy từ cache chỉ 1ms. Đây là lợi ích cốt lõi của cache — biến truy vấn chậm thành đọc tức thời.
- ② Stampede — 20 request → 20 query DB: khi key vắng, 20 client đồng thời đều miss cùng lúc, đều query DB. Counter = 20/20. Lý tưởng chỉ cần một truy vấn (rồi 19 cái kia dùng cache), nhưng thực tế là 20 truy vấn giống hệt. Ở quy mô nghìn request, 1000 truy vấn trùng lặp đồng thời là một cú đấm có thể làm sập database — đúng lúc tải cao nhất.
- ③ Lock SET NX — giảm còn 1 query DB: thêm một lock
SET lock:key 1 NX EX 5trước khi query. Chỉ request giành được lock (SET NX thành công) mới query DB và nạp cache; 19 request còn lại thấy lock đã bị giữ nên bỏ qua (chờ rồi đọc cache, hoặc dùng giá trị cũ). Counter = 1/20 — database được bảo vệ. Đây là mẫu single-flight kinh điển cho key nóng.
Đánh đổi cần cân nhắc
Lock gây độ trễ cho các request thua lock — cân nhắc "stale-while-revalidate". Với mẫu lock đơn giản, 19 request thua lock phải chờ request thắng nạp xong cache rồi mới có dữ liệu — tăng độ trễ cho chúng. Một biến thể tốt hơn cho key cực nóng: giữ giá trị cũ (stale) trong cache lâu hơn TTL "mới", và khi gần hết hạn, một request làm mới ở nền trong khi mọi request khác vẫn được phục vụ giá trị cũ ngay. Không ai phải chờ, DB vẫn chỉ nhận một truy vấn. Phức tạp hơn nhưng không có độ trễ chờ.
Hết hạn đồng loạt là một dạng stampede khác — rải TTL. Nếu bạn nạp nhiều key cùng lúc với cùng TTL (ví dụ warm cache lúc khởi động), chúng sẽ cùng hết hạn cùng lúc sau đó — gây một đợt stampede đồng loạt trên nhiều key. Chống bằng cách thêm jitter (độ lệch ngẫu nhiên) vào TTL: thay vì EX 60, dùng EX 60 + random(0..10). Nối với bài TTL/eviction: hết hạn đồng loạt cũng gây nhịp tải đột ngột.
Lock cũng có cạm bẫy riêng — bài sau sẽ đào sâu. Lock bằng SET NX cần có TTL (EX) để không kẹt vĩnh viễn nếu request giữ lock chết; nhưng TTL quá ngắn thì lock hết hạn trước khi query DB xong, hai request cùng query. Và nhả lock phải an toàn (chỉ chủ lock mới được nhả). Đây chính là chủ đề distributed lock ở bài redis-08 — cache stampede chỉ là một ứng dụng của lock, và lock làm đúng không hề đơn giản.
Ba ý mang về
- Cache-aside mạnh nhưng có lỗ hổng stampede. Đo thật: HIT 1ms vs MISS 204ms (lợi ích cache rõ ràng); nhưng khi key nóng hết hạn, 20 request đồng thời gây 20/20 lần query DB — ở quy mô lớn đủ sập database.
- Lock SET NX giảm stampede từ N xuống 1. Đo thật: cùng 20 request, với lock chỉ 1/20 query DB (single-flight). Chỉ một request nạp cache, số còn lại chờ hoặc dùng giá trị cũ — database được che chở ngay cả lúc key nóng hết hạn.
- Hoàn thiện bằng stale-while-revalidate và jitter TTL. Lock đơn giản làm request thua lock phải chờ — biến thể stale (phục vụ giá trị cũ khi làm mới nền) tránh chờ; rải TTL bằng jitter để tránh hết hạn đồng loạt. Và lock làm đúng là cả một chủ đề (bài 8).
Nguồn
- Redis — Cache-aside pattern & caching best practices: https://redis.io/docs/latest/develop/use/patterns/
- AWS — Caching strategies (lazy loading, write-through): https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/Strategies.html
- Wikipedia — Cache stampede: https://en.wikipedia.org/wiki/Cache_stampede
Phần sau ta đào sâu chính công cụ vừa dùng: distributed lock với SET NX PX — vì sao cần token và Lua để nhả lock an toàn, và những cạm bẫy khiến một lock tưởng chắc chắn vẫn hỏng.