Stale data và cache stampede
Bài trước bạn đã biết SET key value EX 300 và đặt TTL bằng tay trên Redis. Nhưng TTL không phải nút "an toàn": nó là lịch hẹn, và mọi key hẹn cùng một giờ sẽ cùng "nổ". Bài này nói về hai tai nạn hay gặp nhất khi cache chạy thật, cộng thêm một lỗi thứ ba mà cache trông vẫn "xanh" nhưng database vẫn chết.
Sau bài này bạn sẽ:
- Phân biệt được stale data vô hại và stale data là bug.
- Nhận ra cache stampede qua các dấu hiệu trên dashboard.
- Biết ba cách chặn: jitter TTL, lock, single flight.
- Biết cache penetration là gì và chặn bằng negative cache.
Dữ liệu cũ: khi nào nó là bug
Hãy nghĩ đến tủ lạnh. Bạn không cần biết hộp sữa được mua lúc mấy giờ, bạn chỉ cần biết nó còn hạn. Cache cũng vậy: câu hỏi không phải "làm sao cho dữ liệu luôn mới nhất", mà là dữ liệu này cũ bao lâu thì chấp nhận được?
- Số lượt xem bài viết cũ 10 phút: không ai thấy sai.
- Số dư ví cũ 10 phút: người dùng thấy sai ngay, và đó là bug.
Stale data (dữ liệu cũ) là khi cache trả về bản ghi không còn khớp với source of truth (thường là database). Nó chỉ trở thành bug khi có người dùng nhìn thấy, hoặc khi logic nghiệp vụ ra quyết định dựa trên nó.
Ví dụ có luồng: user đổi avatar. API ghi vào DB rồi trả về ngay, còn cache user:42:profile vẫn giữ bản cũ với TTL 600 giây. Nếu bạn xóa cache trước khi ghi DB thì một request đọc chen vào giữa sẽ nạp lại giá trị cũ và giữ nó suốt 10 phút. Thứ tự đúng trong cache-aside là: ghi DB trước, xóa cache sau.
Một cách nhìn thực dụng: gắn nhãn cho từng loại dữ liệu.
| Loại dữ liệu | Chấp nhận cũ bao lâu | Chiến lược |
|---|---|---|
| Bảng xếp hạng, feed | 30 đến 120 giây | TTL ngắn, không cần xóa tay |
| Profile, danh mục sản phẩm | 5 đến 15 phút | TTL + xóa khi ghi |
| Số dư, tồn kho | 0 giây | Không cache, hoặc cache rất ngắn kèm version |
Cache stampede: cú đập đồng loạt khi key hết hạn
Stampede (còn gọi là dogpile) là khi một key "nóng" vừa hết hạn và hàng nghìn request cùng miss, cùng chạy xuống database để lấy đúng một kết quả.
Ví dụ có số: key product:hot có TTL 300 giây, được nạp trong lúc deploy lúc 20:00. Lưu lượng 5.000 request/giây. Đúng 20:05, key hết hạn: 5.000 request đồng thời miss và cùng chạy SELECT. Database vốn chỉ chịu được 200 query/giây bị đập 25 lần sức chịu, timeout hàng loạt, client retry, tải càng tăng. Cache vẫn "hoạt động đúng", chỉ là nó hết hạn đúng một nhịp.
Dấu hiệu nhận biết trên dashboard: CPU database nhảy cóc đúng chu kỳ bằng TTL, latency tăng theo răng cưa rồi tự hết. Nếu bạn thấy pattern lặp đều mỗi 5 phút, gần như chắc chắn là stampede.
Rải TTL bằng jitter
Cách rẻ nhất, sửa một dòng: cộng thêm một khoảng ngẫu nhiên vào TTL để các key không cùng hết hạn một giây.
ttl = 300 + random(0, 60) # 300 đến 360 giây
SET product:hot "<json>" EX ttl
Với 10.000 key đặt trong cùng một phút, jitter biến một cú đập thành 10.000 lần hết hạn rải đều trong 60 giây. Đây là mặc định nên có cho mọi cache có TTL.
Lock và single flight
Jitter chỉ rải được các key khác nhau. Một key duy nhất vẫn có thể bị 5.000 request hỏi cùng lúc. Khi đó ta cần đảm bảo chỉ một request đi lấy dữ liệu, số còn lại chờ và dùng chung kết quả. Đó là ý tưởng của single flight, hiện thực bằng một lock trên Redis.
flowchart TD
Req["📥 Request đọc key"] --> Get{"🔍 Có trong cache?"}
Get -->|Có| Hit["⚡ Trả về ngay"]
Get -->|Không| Lock{"🔒 Giành được lock?"}
Lock -->|Không| Wait["⏳ Chờ 50ms rồi đọc lại cache"]
Wait --> Get
Lock -->|Có| DB["🗄️ Query database một lần"]
DB --> Set["💾 SET kèm TTL + jitter"]
Set --> Unlock["🔓 Xóa lock"]
Unlock --> Hit
Một lock tối thiểu trên Redis:
SET lock:product:hot <token> NX EX 10
NX nghĩa là chỉ set khi key chưa tồn tại, nên chỉ một request thắng. Hai điều bắt buộc: lock phải có TTL (nếu tiến trình chết, lock tự tan, không treo hệ thống), và khi xong phải xóa lock kèm kiểm tra <token> để không xóa nhầm lock của người khác.
Cache penetration: request xuyên thẳng xuống database
Lỗi thứ ba tinh vi hơn: key không tồn tại. Ai đó gọi GET user:999999 liên tục. Cache không bao giờ có key này, nên mọi request đều miss và đều chạy xuống database. Cache hit rate vẫn đẹp, database vẫn chết.
Cách chặn phổ biến nhất là negative cache: cache luôn cả kết quả rỗng với TTL ngắn.
GET user:999999 -> miss
SET user:999999 "__null__" EX 45
# 45 giây sau, request lặp lại chỉ chạm cache
Hai lưu ý: TTL của negative cache phải ngắn (30 đến 60 giây) để bản ghi vừa được tạo không bị chặn lâu, và khi tạo bản ghi thật thì phải xóa luôn entry __null__. Với hệ thống lớn, người ta còn dùng bloom filter để loại request cho id chắc chắn không tồn tại trước khi chạm Redis.
Điền nhanh để kiểm tra trí nhớ:
- Cache stampede xảy ra khi một key hết hạn và _____ cùng đọc xuống database.
- Cách rẻ nhất để rải thời điểm hết hạn là cộng thêm _____ ngẫu nhiên vào TTL.
- Kỹ thuật chỉ cho một request đi lấy dữ liệu, các request khác chờ và dùng chung kết quả, gọi là _____.
- Với key chắc chắn không tồn tại, ta cache _____ với TTL ngắn để chặn cache penetration.
- Stale data chỉ là bug khi _____ dữ liệu sai đó.
Những chỗ dễ sai khi chặn stale data và stampede
- Xóa cache trước khi ghi DB. Request đọc chen vào giữa sẽ nạp lại giá trị cũ. Ghi trước, xóa sau.
- Lock không có TTL. Tiến trình chết là lock treo vĩnh viễn, cả key bị khóa theo. Luôn
EX. - Chờ lock quá lâu. Request thứ hai chỉ nên chờ vài chục mili giây rồi đọc lại cache, đừng xếp hàng dài. Nếu vẫn miss, trả dữ liệu cũ (stale) còn hơn timeout.
- Đặt TTL tròn trịa cho mọi key.
EX 300ở khắp nơi là công thức tạo stampede theo chu kỳ. Luôn cộng jitter. - Quên xóa negative cache. Người dùng vừa tạo tài khoản đã gặp "không tìm thấy" trong 45 giây.
Quy tắc của người thích vẽ sơ đồ: trước khi thêm bất kỳ dòng cache nào, viết ra hai con số, dữ liệu này cũ bao lâu thì chấp nhận được, và database chịu được bao nhiêu query mỗi giây. Hai con số đó quyết định TTL và quyết định có cần lock hay không.
Ghi nhớ nhanh
- Stale data không xấu tự thân, nó chỉ xấu khi người dùng thấy. TTL là lời hứa về mức cũ chấp nhận được.
- Stampede là hệ quả của nhiều key hết hạn cùng nhịp: chặn bằng jitter, và bằng lock hoặc single flight cho từng key nóng.
- Cache penetration là miss vĩnh viễn cho key không tồn tại: chặn bằng negative cache TTL ngắn.
Bài tiếp theo sẽ đưa những khái niệm này vào thực tế: cách viết prompt yêu cầu AI thêm cache vào một hàm có sẵn sao cho vẫn giữ đúng logic nghiệp vụ, kèm checklist để review đoạn code AI sinh ra.
Front: Stale data trong cache là gì? Back: Dữ liệu trong cache không còn khớp với source of truth (thường là database).
Front: Câu hỏi đầu tiên cần trả lời trước khi cache một loại dữ liệu? Back: Dữ liệu này cũ bao lâu thì chấp nhận được?
Front: Cache stampede là gì? Back: Nhiều request cùng miss một key vừa hết hạn và cùng chạy xuống database cho đúng một kết quả.
Front: Vì sao stampede hay xuất hiện ngay sau deploy? Back: Nhiều key được SET trong cùng một phút nên hết hạn cùng một phút.
Front: Jitter TTL là gì? Back: Cộng thêm một khoảng ngẫu nhiên vào TTL để rải thời điểm hết hạn của các key.
Front: Single flight là gì? Back: Chỉ một request đi lấy dữ liệu, các request khác chờ và dùng chung kết quả đó.
Front: Lệnh Redis nào dùng để giành lock?
Back: SET lock:key
Front: Vì sao lock bắt buộc phải có TTL? Back: Nếu tiến trình giữ lock bị chết, lock tự tan thay vì treo key vĩnh viễn.
Front: Cache penetration là gì? Back: Request cho key không tồn tại nên luôn miss cache và luôn đập xuống database.
Front: Cách chặn cache penetration đơn giản nhất? Back: Negative cache: cache giá trị null với TTL ngắn (khoảng 30 đến 60 giây).
Front: Thứ tự đúng khi vừa ghi DB vừa cập nhật cache trong cache-aside? Back: Ghi database trước, xóa cache sau.
Front: Dấu hiệu stampede trên dashboard trông như thế nào? Back: Database spike đúng chu kỳ bằng TTL, latency tăng theo răng cưa rồi tự hết.
Front: Khi xóa negative cache entry? Back: Ngay khi bản ghi thật được tạo, để người dùng không gặp lỗi không tìm thấy.