Bỏ qua để đến nội dung
Knowledge Hub

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

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.

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

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.

Terminal window
npx ts-node src/cache-penetration-avalanche/penetration.ts # chạy trong examples/
npx ts-node src/cache-penetration-avalanche/avalanche.ts

Penetration: 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.

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ỳ.

Cùng job warm 00:00: TTL cố định dồn 50.000 lần hết hạn vào một phút, TTL có jitter rải trong 30 phút
Cùng job warm 00:00: TTL cố định dồn 50.000 lần hết hạn vào một phút, TTL có jitter rải trong 30 phútBước 1/5: 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.00:00 → 01:00 → 01:10 → 01:20 → 01:30SET 50k keytrong ~1 phút50k keycùng hạn 01:00Job warmchạy 00:00Redis ATTL cố định 3600 sPostgreSQL Anạp lại khi missRedis BTTL 3600 + 0..1800 sPostgreSQL Bnạp lại khi miss
Bước 1/5

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).

Terminal window
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses|expired_keys"
redis-cli INFO server | grep uptime_in_seconds
redis-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 GET key rỗng là hit. Cần metric riêng “not found từ cache”.
  • Avalanche: delta expired_keys có đỉnh nhọn đúng chu kỳ TTL, trùng đỉnh DB QPS.
  • Restart: uptime_in_seconds nhỏ, DBSIZE thấp bất thường.

Đang tải diagram…

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

Nên viết

await db.insertProduct(p); // 1. commit
await redis.del(`product:${p.id}`); // 2. xoá key rỗng
await bloomAdd(p.id); // 3. thêm vào bloom filter

Vì 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ằng SETBIT/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 đó.
  • TTL jitter: ttl = base + random(0, spread). N key ghi cùng lúc hết hạn trung bình N / spread key 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).
  1. Validate id trước khi chạm cache hay DB; rate limit endpoint đọc.
  2. Cache rỗng có TTL ngắn; luồng tạo row DEL key rỗng.
  3. Bloom filter (nếu dùng): n, p có dư, cập nhật khi tạo row, có job rebuild.
  4. Mọi TTL có jitter; job warm chia batch.
  5. Redis có replica, failover; đã đo DB QPS khi cache trống ở staging.
  6. Loader có giới hạn concurrency, timeout, fallback.
  7. Dashboard: hit rate, delta expired_keys, DB QPS, not-found từ cache.
  1. Vì sao cache rỗng giảm query ở kịch bản B nhưng không giảm ở A?
  2. Bloom filter thiếu một id có thật thì người dùng thấy gì?
  3. 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?
  4. Vì sao hit rate có thể trông tốt khi penetration đang xảy ra?