為什麼需要快取#

層級延遲(典型)
L1 cache< 1 ns
L2 cache3 ns
主記憶體100 ns
本地 SSD100 μs
同房 Redis1 ms
MySQL 查詢5~50 ms
跨地 DB100+ 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 → DB

bloom 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 None

3. 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 也容易掛

對策:

  1. 本地 cache:把熱 key 抓到本機,避免每次去 Redis
  2. 熱 key 拆分:把 key 加上隨機 suffix,讀寫時隨機打到 N 份副本
    product:hot       → product:hot:0, product:hot:1, ..., product:hot:9
  3. 限流:對熱 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
Session10 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_ratio1.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 之間怎麼保持一致。