技術探索 · 商城後端服務

商品詳情快取失效 — 三種寫法比較

需求 商品詳情 API(QPS 約 8k,熱門檔次 200 倍尖峰)目前直打 MySQL,平均回應 180ms。 請給三種 Redis 快取失效策略寫法,列出各自取捨。

01純 TTL 過期

讀路徑只塞 Redis 加 60 秒到期,寫路徑完全不管快取。

func GetProduct(ctx ctx.Context, id int64) (*Product, error) {
  key := fmt.Sprintf("product:%d", id)

  if raw, err := rdb.Get(ctx, key).Bytes(); err == nil {
    return decode(raw)
  }

  p, err := db.LoadProduct(ctx, id)
  if err != nil {
    return nil, err
  }

  rdb.Set(ctx, key, encode(p), 60*time.Second)
  return p, nil
}

// 寫路徑不動快取,等 TTL 自然過期
func UpdatePrice(ctx ctx.Context, id int64, price int64) error {
  return db.SetPrice(ctx, id, price)
}
優點
缺點
實作最簡單,三十行內搞定
改價後最久要等 60 秒才生效,營運會抱怨
讀寫路徑解耦,寫端不會因 Redis 掛掉失敗
熱門商品快取過期瞬間會出現 cache stampede
Redis 異常時退化成直打 DB,行為可預測
記憶體用量不可控,冷門商品也佔位 60 秒
DB 命中率: ~6% 資料新鮮度: ≤60s 實作複雜度: 熱點防護:

02TTL + 主動失效 + LRU

寫路徑 DEL 對應 key,本地 LRU 擋一層減少 Redis 往返。

// 本地 LRU 1 萬筆,TTL 5 秒(容忍短暫不一致)
var local = lru.NewWithTTL[int64, *Product](10_000, 5*time.Second)

func GetProduct(ctx ctx.Context, id int64) (*Product, error) {
  if p, ok := local.Get(id); ok {
    return p, nil
  }

  key := fmt.Sprintf("product:%d", id)
  if raw, err := rdb.Get(ctx, key).Bytes(); err == nil {
    p, _ := decode(raw)
    local.Set(id, p)
    return p, nil
  }

  p, err := db.LoadProduct(ctx, id)
  if err != nil { return nil, err }
  rdb.Set(ctx, key, encode(p), 300*time.Second)
  local.Set(id, p)
  return p, nil
}

func UpdatePrice(ctx ctx.Context, id int64, price int64) error {
  if err := db.SetPrice(ctx, id, price); err != nil { return err }
  rdb.Del(ctx, fmt.Sprintf("product:%d", id))
  bus.Publish("product.invalidate", id) // 廣播給其他 pod 清 LRU
  return nil
}
優點
缺點
本地 LRU 擋掉 80% Redis 流量,p99 從 12ms 降到 1.8ms
需要 pub/sub 廣播,新增 NATS 或 Redis Streams 依賴
改價 5 秒內所有節點生效,可控
本地 LRU 跟 Redis 雙層,bug 變難 reproduce
寫量 / 讀量 比例 1:200,廣播成本可忽略
pod 啟動瞬間 LRU 是空的,會 stampede 一次
DB 命中率: ~0.4% 資料新鮮度: ≤5s 實作複雜度: 熱點防護: 部分

03Write-through + 熱 key 防穿透

寫路徑直接更新快取;讀路徑用 singleflight 鎖住同 key 並發。

var sf singleflight.Group

func GetProduct(ctx ctx.Context, id int64) (*Product, error) {
  key := fmt.Sprintf("product:%d", id)
  if raw, err := rdb.Get(ctx, key).Bytes(); err == nil {
    return decode(raw)
  }

  // 同 key 並發只放一個進 DB,其他等結果
  v, err, _ := sf.Do(key, func() (any, error) {
    p, err := db.LoadProduct(ctx, id)
    if err != nil { return nil, err }
    rdb.Set(ctx, key, encode(p), 10*time.Minute)
    return p, nil
  })
  if err != nil { return nil, err }
  return v.(*Product), nil
}

func UpdatePrice(ctx ctx.Context, id int64, price int64) error {
  p, err := db.UpdateAndReturn(ctx, id, price)
  if err != nil { return err }
  rdb.Set(ctx, fmt.Sprintf("product:%d", id),
    encode(p), 10*time.Minute)
  return nil
}
優點
缺點
寫完即時生效,營運不用等 TTL
寫路徑跟 Redis 強耦合,Redis 掛掉寫端會 retry 噴錯
singleflight 完全擋掉 cache stampede
DB 寫成功但 Redis 寫失敗時,需要對帳機制
10 分鐘 TTL 比較長,Redis CPU 跟記憶體都更穩
singleflight 只解單機並發,多 pod 還是會打 N 次
DB 命中率: ~0.1% 資料新鮮度: 即時 實作複雜度: 中高 熱點防護: