為什麼需要快取#
| 層級 | 延遲(典型) |
|---|---|
| L1 cache | < 1 ns |
| L2 cache | 3 ns |
| 主記憶體 | 100 ns |
| 本地 SSD | 100 μs |
| 同房 Redis | 1 ms |
| MySQL 查詢 | 5~50 ms |
| 跨地 DB | 100+ ms |
讀 Redis 比讀 MySQL 至少快 5~50 倍。電商所有熱資料(首頁、商品、會話、購物車)都會經過某種快取。
四種讀寫模式#
Cache-Aside(最常用)#
讀:
1. 查 cache
2. miss → 查 DB
3. 回填 cache
4. 回應
寫:
1. 寫 DB
2. 刪 cache(不更新)def get_product(id):
p = redis.get(f"product:{id}")
if p: return p
p = db.query("SELECT * FROM products WHERE id=%s", id)
if p: redis.setex(f"product:{id}", 3600, p)
return p
def update_product(p):
db.update(p)
redis.delete(f"product:{p.id}")為什麼是「刪」不是「更新」?
- 寫入 cache 與 DB 不在同一事務 → 順序錯就不一致
- 多次更新 cache 浪費(值可能再被改)
- 刪 → 下次讀自然回填最新值
Read-Through#
cache 系統自己負責 miss 時去 DB 讀並回填:
應用 → cache.get(key) → cache 自動 fall-through 到 DB業務代碼簡潔,但要 cache 系統支援(Redis 不原生支援,要外加層)。
Write-Through#
寫入時同步寫 DB + cache:
def update_product(p):
cache.set(p)
db.update(p) # 等 DB 完成才回應優點:cache 永遠跟 DB 一致。 缺點:寫延遲 = DB 延遲,沒有性能提升。
Write-Back(Write-Behind)#
寫入只進 cache,異步寫 DB:
write → cache → 立即回應
↓ 後台 worker
DB優點:寫極快、可批次。 缺點:cache 故障 → 資料遺失。需要持久化 cache(如 Redis AOF)。
| 模式 | 強度 | 場景 |
|---|---|---|
| Cache-Aside | 最常用 | 讀寫比 9:1+ 的場景 |
| Read-Through | 簡化代碼 | cache 框架較強(如 Caffeine、Cache2k) |
| Write-Through | 強一致 | 對 cache 要求嚴格(少見) |
| Write-Back | 高吞吐 | 計數器、log buffer、可丟資料 |
不一致風險:Cache-Aside 的 race#
理論上 cache-aside 不會不一致,但在某些罕見順序下會:
時間軸:
T1 (讀): cache miss
T1 (讀): 從 DB 讀到舊值 V1
T2 (寫): 更新 DB 為 V2
T2 (寫): 刪 cache
T1 (讀): 把 V1 寫回 cache ← 髒了!T1 把舊值寫回了 cache。後續讀都拿到 V1。
實務頻率極低(要求 T1 的 DB 讀晚於 T2 的整個更新),但確實存在。解決:
- 設 TTL:即使髒了,過期後自然修正
- 更新後雙刪:寫 DB → 刪 cache → 等一會(如 1 秒)→ 再刪一次
- 更激進:用 binlog 變更通知(下章)
90% 的場景靠 TTL 就夠 ── 髒幾秒不致命。
三大災難#
1. Cache Penetration(穿透)#
查不存在的 key → 永遠 cache miss → 每次都打 DB。
惡意請求: GET /product/non_existent_id_123
→ cache miss → DB 查無 → 不寫 cache → 下次同樣請求又走 DB被攻擊時 DB 直接被打死。
解 1:null 值也快取#
p = db.query(...)
if p:
redis.setex(key, 3600, p)
else:
redis.setex(key, 60, "NULL") # 短 TTL下次同 key 取到 “NULL” → 直接回。
解 2:Bloom filter#
維護一個 bloom filter,紀錄「所有實際存在的 ID」。查詢時:
bloom.contains(id)?
├─ 否 → 直接拒絕(一定不存在)
└─ 是 → 走正常 cache → DBbloom filter 的特性:「不存在一定報否、存在可能誤報」── 對防穿透夠用(誤報的少數會走 DB,但 bulk 攻擊被擋)。
解 3:API 層校驗 ID 格式#
product_id 必須是 long、特定範圍 ── 不合法直接拒。能擋一部分 random 攻擊。
2. Cache Avalanche(雪崩)#
大量 key 同時過期 → 大量請求同時打 DB → DB 崩。
例:促銷活動把 100 萬商品快取了,全部 TTL 12 小時,12 小時後一齊失效。
解 1:TTL 加隨機#
ttl = base_ttl + random(0, 600) # 1 小時 ± 10 分鐘讓過期時間散開。
解 2:永不過期 + 主動更新#
熱 key 不設 TTL,靠後台 worker 定時更新。但要小心 cache 積累。
解 3:限流 / 熔斷#
cache 整個掛了 → 不要把流量全打到 DB,直接拒絕:
def get_with_circuit_breaker(key):
if circuit.is_open():
return None # 或回 fallback 值
try:
return cache.get(key)
except:
circuit.record_failure()
return None3. Cache Breakdown(擊穿)#
單個熱 key 過期 → 海量並發同時打 DB 查同一筆資料。
跟雪崩類似但是單 key。
解 1:互斥鎖#
def get_hot_product(id):
p = cache.get(id)
if p: return p
if cache.set_nx(f"lock:{id}", 1, ex=10): # 只一個 thread 拿到鎖
try:
p = db.query(id)
cache.setex(id, 3600, p)
return p
finally:
cache.delete(f"lock:{id}")
else:
# 其他 thread 等一下再試
time.sleep(0.05)
return get_hot_product(id)只有一個 thread 進到 DB 查,其他等。
解 2:邏輯過期#
cache value 帶一個邏輯過期時間:
{ "data": ..., "expires_at": "2025-04-29T12:00:00" }讀的時候檢查 expires_at,如果過期:
- 拿到鎖的 thread → 異步去更新 cache
- 沒拿到鎖 → 仍回舊值
業務上接受短暫舊值。
多級快取#
Browser cache → CDN cache → 本地 cache(caffeine、guava)→ Redis → MySQL每級命中 → 上層完全不知道下層。設計時思考每級的:
- 容量
- TTL
- 失效策略
- 一致性需求
對極熱資料,「應用本地 LRU」比 Redis 還快幾百倍 ── 但要解決多 instance 的一致性(hot key 改了 → 怎麼通知所有 instance 失效)。
實務:
- L1 本地 caffeine:100ms TTL,承擔短暫尖峰
- L2 Redis:5min ~ 1hour TTL
- 失效廣播:用 Redis pub/sub 或 Kafka 通知所有節點清本地
熱 key 問題#
「熱 key」= 某個 key 被打到流量極大(爆款商品、首頁、明星)。
問題:
- Redis cluster 中,熱 key 落在某個 shard → 該 shard 被打爆
- 快取擊穿時 DB 也容易掛
對策:
- 本地 cache:把熱 key 抓到本機,避免每次去 Redis
- 熱 key 拆分:把 key 加上隨機 suffix,讀寫時隨機打到 N 份副本
product:hot → product:hot:0, product:hot:1, ..., product:hot:9 - 限流:對熱 key 做專門限流,防止 spike
監控:Redis --hotkeys 指令或 monitor 抓樣本。
大 key 問題#
「大 key」= 一個 value 極大(list 幾千萬個元素、hash 幾百 MB)。
問題:
del bigkey阻塞 Redis(O(n))- 網路傳輸耗時,連續 hit 拉爆頻寬
- 記憶體不均
對策:
- 拆成多個小 key
- 用
unlink替代del(4.0+ 異步刪除) - 監控
redis-cli --bigkeys
TTL 設計#
不是「越長越好」也不是「越短越好」。
| 資料類型 | 建議 TTL |
|---|---|
| Session | 10 min ~ 1 day |
| 商品基本資訊 | 1 hour ~ 1 day |
| 庫存(精準) | 10 sec ~ 1 min(短) |
| 排行榜 | 1 min ~ 5 min |
| 統計資料 | 5 min ~ 1 hour |
| 設定 | 30 sec(強一致) |
短 TTL 的權衡:擊穿風險 vs 不一致風險。
快取淘汰策略#
Redis 記憶體滿時的淘汰:
| 策略 | 行為 |
|---|---|
| noeviction | 寫入直接拒絕(預設) |
| allkeys-lru | 從所有 key 中淘汰最久未用 |
| allkeys-lfu | 從所有 key 中淘汰最少用(4.0+,多數場景優於 lru) |
| volatile-lru | 只從帶 TTL 的 key 淘汰 |
| volatile-lfu | 同上 |
| volatile-random | 隨機 |
| allkeys-random | 隨機 |
業務 cache → allkeys-lfu。session、tmp 資料 → volatile-lru(純 cache 不該占走重要 key)。
監控指標#
| 指標 | 健康值 |
|---|---|
命中率 (hits/(hits+misses)) | > 90% |
| ops 每秒 | 視 instance 規格 |
| 99p 延遲 | < 5 ms |
| eviction count | 接近 0(除非主動限制) |
| fragmentation_ratio | 1.0 ~ 1.5 |
| connected_clients | < pool 上限 |
| slowlog | < 1ms 操作 |
INFO commandstats 看哪個指令最慢、哪個最頻繁。
小結#
- Cache-Aside 是 90% 場景的標準模式:寫 DB → 刪 cache
- 三災難對策:穿透(null/Bloom)、雪崩(隨機 TTL)、擊穿(互斥鎖)
- TTL 設計:根據業務容忍度
- 熱 key 拆分、大 key 拆塊、本地多級快取
- 強一致需求 → 別純靠 cache,配 binlog 通知或 invalidation
下章看「資料同步」── Redis 與 MySQL 之間怎麼保持一致。