Cache penetration và avalanche: khi cache không còn che được database Trung cấp
Thời gian đọc ước tính: 9 phút
Cần đọc trước
Triệu chứng
Phần tiêu đề “Triệu chứng”API GET /products/{id} dùng cache-aside trên Redis, nguồn chính là PostgreSQL. Ba sự cố cho cùng cảm giác “có cache mà DB vẫn quá tải”:
| Sự cố | Hit rate | DB QPS | Dấu hiệu kèm |
|---|---|---|---|
| Bot gọi id ngẫu nhiên không tồn tại | Giảm, kéo dài | Tăng theo traffic bot | Query theo id trả 0 row |
| Job warm 50.000 key lúc 00:00, TTL 1 giờ | Rơi đúng 01:00, 02:00… | Đỉnh lặp theo chu kỳ TTL | expired_keys vọt cùng phút |
| Redis restart | Gần 0, hồi dần | Vọt rồi giảm dần | uptime_in_seconds reset |
Hit rate = keyspace_hits / (keyspace_hits + keyspace_misses) (INFO stats), tính theo delta mỗi phút.
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| Stampede | Penetration | Avalanche | |
|---|---|---|---|
| Key | Một hot key | Key không có trong DB | Rất nhiều key |
| Vì sao miss | Vừa hết hạn, nhiều request cùng nạp | Không có gì để cache | Hết hạn cùng lúc hoặc cache mất sạch |
| Thuốc chính | Singleflight, lock, XFetch | Validate, cache rỗng, bloom filter, rate limit | TTL jitter, warm rải, HA, giới hạn tải DB |
| Tình huống | Chọn | Điều kiện |
|---|---|---|
| Id sai định dạng, ngoài khoảng | Validate ở biên, trả 400/404 | Có quy tắc id rõ |
| Cùng id không tồn tại bị gọi lặp | Cache rỗng, TTL ngắn | DEL key rỗng khi tạo row |
| Id ngẫu nhiên, không lặp | Bloom filter + rate limit | Cập nhật filter khi tạo row |
| Nhiều key nạp cùng lúc | TTL jitter, warm rải | Gần như luôn áp dụng |
| Redis mất toàn bộ dữ liệu | Replica + failover, giới hạn concurrency xuống DB | DB phải sống khi cache trống |
Tái hiện
Phần tiêu đề “Tái hiện”Demo chạy trên Redis 7.4.9 và PostgreSQL 17 của examples/docker-compose.yml; bảng demo_cache_penetration.products có id 1..10000.
npx ts-node src/cache-penetration-avalanche/penetration.ts # chạy trong examples/npx ts-node src/cache-penetration-avalanche/avalanche.tsPenetration: 1.000 request cho id không tồn tại
Phần tiêu đề “Penetration: 1.000 request cho id không tồn tại”Demo đếm số query thật gửi xuống PostgreSQL:
A. 1.000 request, 1.000 id ngẫu nhiên khác nhau (bot): plain DB query: 1000 key rỗng trong Redis: 0 negative DB query: 1000 key rỗng trong Redis: 1000 bloom DB query: 19 key rỗng trong Redis: 0 bloom+negative DB query: 19 key rỗng trong Redis: 19
B. 1.000 request, 50 id không tồn tại, mỗi id lặp 20 lần: plain DB query: 1000 key rỗng trong Redis: 0 negative DB query: 50 key rỗng trong Redis: 50 bloom DB query: 20 key rỗng trong Redis: 0 bloom+negative DB query: 1 key rỗng trong Redis: 1
Bloom filter (95851 bit, k=7, 11982 byte trong Redis): sai âm trên 10000 id có thật: 0 sai dương trên 100000 id không có: 958 (0.96%)- Cache rỗng vô dụng với id ngẫu nhiên: vẫn 1.000 query, thêm 1.000 key rác. Nó chỉ có ích khi id lặp lại (B: 1.000 → 50).
- Phần bloom filter để lọt là sai dương tính (19/1.000, mẫu nhỏ). Kết hợp cả hai thì id sai dương gọi lặp chỉ chạm DB một lần.
Avalanche: 50.000 key, TTL cố định và có jitter
Phần tiêu đề “Avalanche: 50.000 key, TTL cố định và có jitter”TTL rút ngắn: 10 giây thay cho 1 giờ (tỉ lệ 1:360), jitter 0..5 giây tương ứng 0..30 phút. Mỗi giây demo EXISTS mọi key, đếm key biến mất:
TTL cố định 10 s: warm 50000 key mất 109 ms giây 10–11: 50000 key hết hạn ##################################################
TTL 10 s + jitter ngẫu nhiên 0..5 s: warm 50000 key mất 113 ms giây 9–10: 12 key hết hạn # giây 10–11: 9692 key hết hạn ########## giây 11–12: 10046 key hết hạn ########### giây 12–13: 9991 key hết hạn ########## giây 13–14: 9877 key hết hạn ########## giây 14–15: 10013 key hết hạn ########### giây 15–16: 369 key hết hạn #Số từng giây đổi giữa các lần chạy; hình dạng ổn định: một cột 50.000 so với năm cột ~10.000.
Nguyên nhân gốc
Phần tiêu đề “Nguyên nhân gốc”Penetration: cache-aside chỉ lưu thứ đọc được từ DB. Id không tồn tại không có gì để lưu, nên mọi request đi tiếp xuống DB.
Avalanche: key ghi trong cùng cửa sổ ngắn với cùng TTL thì hết hạn trong cùng cửa sổ. Lần nạp lại cũng dồn cùng lúc, nên đỉnh lặp lại mỗi chu kỳ.
Job ghi 50.000 key trong khoảng một phút, mọi key TTL 3600 s, nên hạn dồn quanh 01:00.
| Thời điểm | Redis A (TTL cố định) | Redis B (TTL + jitter) |
|---|---|---|
| 00:00–00:01 | 50.000 key, hạn 01:00–01:01 | 50.000 key, hạn rải 01:00–01:31 |
| 01:00–01:01 | Toàn bộ hết hạn, ~830 key/s phải nạp | ~1/30 số key hết hạn |
| 01:01–01:31 | Đã nạp lại, hạn mới vẫn trùng nhau | ~28 key/s phải nạp |
Số key/s là ước lượng (50.000/60 và 50.000/1.800), không phải đo.
Redis restart là biến thể nặng nhất: toàn bộ keyspace mất khi tắt persistence hoặc failover sang node trống (persistence RDB/AOF).
Cách phát hiện
Phần tiêu đề “Cách phát hiện”redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses|expired_keys"redis-cli INFO server | grep uptime_in_secondsredis-cli DBSIZE- Penetration: miss cao kéo dài; nhiều id khác nhau từ ít IP.
- Có cache rỗng: hit rate lại trông tốt, vì Redis tính
GETkey rỗng là hit. Cần metric riêng “not found từ cache”. - Avalanche: delta
expired_keyscó đỉnh nhọn đúng chu kỳ TTL, trùng đỉnh DB QPS. - Restart:
uptime_in_secondsnhỏ,DBSIZEthấp bất thường.
Khắc phục ngay
Phần tiêu đề “Khắc phục ngay”Phòng ngừa lâu dài
Phần tiêu đề “Phòng ngừa lâu dài”Đang tải diagram…
Penetration
Phần tiêu đề “Penetration”- Validate ở biên: id là số dương trong khoảng hợp lý hoặc đúng UUID. Không chặn được id đúng định dạng nhưng không có thật.
- Rate limit theo IP hoặc API key (rate limiting): lớp duy nhất giới hạn tổng lượng request của bot.
- Cache giá trị rỗng (negative caching), TTL ngắn như 60 giây. Rủi ro: id rác làm đầy bộ nhớ, và key rỗng che row mới tạo.
- Bloom filter chứa mọi id có thật. Trả “không” là chắc chắn không có; trả “có” thì có thể sai theo tỉ lệ đã chọn.
Demo tái hiện rủi ro key rỗng che row mới:
Tạo product 10001 sau khi id này đã được cache rỗng: đọc lại, chưa DEL key : null đọc lại, sau DEL key : { id: '10001', name: 'product mới' }Tạo product nhưng quên key rỗng
Hay gặp
await db.insertProduct(p); // key rỗng product:{id} còn tới hết TTLNên viết
await db.insertProduct(p); // 1. commitawait redis.del(`product:${p.id}`); // 2. xoá key rỗngawait bloomAdd(p.id); // 3. thêm vào bloom filterVì sao
Ghi DB trước, rồi mới xoá cache và cập nhật filter.
Vận hành bloom filter:
- Kích thước theo Redis:
m = n × −ln(p) / ln(2)²bit,k = ceil(−ln(p) / ln 2). Với n = 10.000, p = 1%: 95.851 bit (~12 KB), k = 7. - Redis Open Source 8 có sẵn
BF.ADD,BF.EXISTS; trước đó cần Redis Stack. Redis 7.4 OSS không có: container demo trảERR unknown command 'BF.ADD', nên demo tự viết bằngSETBIT/GETBIT. - Không xoá được phần tử: product đã xoá vẫn “có thể có”. Rebuild định kỳ từ DB.
- Nạp đủ id trước khi bật chặn; thiếu id nào là 404 sai cho id đó.
Avalanche
Phần tiêu đề “Avalanche”- TTL jitter:
ttl = base + random(0, spread). N key ghi cùng lúc hết hạn trung bìnhN / spreadkey mỗi giây. Với 3600 + random(0, 1800): khoảng 28 key/s cho 50.000 key. - Warm rải đều: chia batch có nghỉ, hoặc chỉ warm top key.
- Redis HA: replica + Sentinel hoặc Redis Cluster để failover sang node đã có dữ liệu (replication bất đồng bộ, có thể mất ghi gần nhất).
- Giới hạn concurrency xuống DB: semaphore cho loader, circuit breaker mở khi DB timeout tăng; phần vượt nhận bản cũ hoặc lỗi nhanh.
- Stale data dự phòng: bản cũ ở cache local hoặc key
stale:TTL dài hơn, dùng khi DB bị giới hạn. - Hot key đơn lẻ vẫn cần singleflight hoặc lock: jitter không gộp request của một key (stampede).
Checklist production
Phần tiêu đề “Checklist production”- Validate id trước khi chạm cache hay DB; rate limit endpoint đọc.
- Cache rỗng có TTL ngắn; luồng tạo row
DELkey rỗng. - Bloom filter (nếu dùng):
n,pcó dư, cập nhật khi tạo row, có job rebuild. - Mọi TTL có jitter; job warm chia batch.
- Redis có replica, failover; đã đo DB QPS khi cache trống ở staging.
- Loader có giới hạn concurrency, timeout, fallback.
- Dashboard: hit rate, delta
expired_keys, DB QPS, not-found từ cache.
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Vì sao cache rỗng giảm query ở kịch bản B nhưng không giảm ở A?
- Bloom filter thiếu một id có thật thì người dùng thấy gì?
- Với
ttl = 3600 + random(0, 1800), 50.000 key ghi cùng lúc, mỗi giây bao nhiêu key hết hạn? - Vì sao hit rate có thể trông tốt khi penetration đang xảy ra?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- Redis — Bloom filter: công thức kích thước, sai dương.
- Redis Open Source 8.0 release notes: Bloom filter có sẵn từ 8.0.
- Redis — Sentinel: failover.
- AWS Builders’ Library — Caching challenges: negative caching, cache lạnh.