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

Ảnh chụp đoạn mã nền tối minh hoạ cache-aside và cache stampede khi một key nóng hết hạn nghìn request đập thẳng vào DB, cache-aside là pattern cache phổ biến nhất nhưng khi key hết hạn nhiều request cùng miss cùng lúc có thể đánh sập database. Cache-aside đọc cache trước miss thì mới chạm DB v bằng GET key if v bằng nil cache MISS v bằng 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 nhanh. Vấn đề cache stampede thundering herd key nóng hết hạn lúc t N request đến cùng lúc đều thấy MISS req1 MISS query DB req2 MISS query DB cả N request cùng query DB một lần reqN MISS query DB DB nhận N truy vấn giống hệt quá tải có thể sập. Chống lock SET NX chỉ một request query DB if MISS if SET lock:key 1 NX EX 5 bằng OK chỉ 1 request giành được lock v bằng query_DB SET key v DEL lock nó query cộng nạp cache else chờ một chút rồi đọc lại cache số còn lại không chạm DB biến thể dùng giá trị cũ stale trong lúc 1 request làm mới nền

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:

Ảnh chụp bảng kết quả đo thật cache-aside stampede và lock chống stampede output thật redis:7 20 client đồng thời DB giả bằng sleep 100ms. Một cache-aside HIT nhanh hơn MISS 200 lần lần 1 MISS phải query DB giả 200ms 204 ms lần 2 HIT lấy từ cache 1 ms cache-aside biến một truy vấn DB chậm 204ms thành đọc cache tức thời 1ms cho các lần sau lợi ích cốt lõi của cache. Hai stampede 20 request đồng thời đập DB khi key vắng số lần query DB không lock 20 trên 20 key hết hạn chưa có cả 20 request cùng MISS cùng lúc cùng query DB lý tưởng là 1 thực tế 20 DB nhận 20 truy vấn giống hệt ở quy mô nghìn request đây là cú đấm có thể làm sập database. Ba lock SET NX chỉ 1 request chạm DB số lần query DB có lock SET NX 1 trên 20 chỉ request giành được lock SET NX mới query DB và nạp cache 19 request còn lại bỏ qua chờ rồi đọc cache hoặc dùng giá trị cũ số truy vấn DB giảm từ 20 xuống 1 DB được bảo vệ đây là mẫu single-flight kinh điển cho key nó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 5 trướ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ề

  1. 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.
  2. 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.
  3. 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

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.