Pull request · 喜鵲商城

#487 — 為商品搜尋 API 加上 Redis 快取層

7 個檔案 +352 / −118 分支 search-cachemain 作者 @chen-yh
Prompt 幫我把 PR #487 寫成讓 reviewer 一看就懂的描述。要交代為什麼要做、逐檔說明每個改動的意圖、放前後對照,並明確指出三個必看的點。Reviewer 已經半年沒碰搜尋這塊了,先假設他們忘光了。
TL;DR

商品搜尋 API(GET /api/v2/products/search)每次都打 Postgres + Elasticsearch,週末大檔活動時 p95 飆到 1.8 秒、DB 連線池被打爆兩次。這個 PR 在 query handler 前面塞一層 Redis 快取,key 用 normalized query + filters 雜湊,TTL 60 秒,並在商品被改動時主動失效相關 key,把熱門查詢從「每秒 800 次穿透到 DB」壓到「每秒 12 次」。

為什麼要做

上一季把首頁的「熱門搜尋」widget 從靜態詞庫換成即時 query 之後,前 20 個熱門詞(「無線耳機」「氣炸鍋」「除濕機」這類)每分鐘被打超過 5,000 次,但 query 結果其實一分鐘內幾乎不會變。我們把 DB 當快取在用,這是錯的。

改之前
  • 每個 request 都走 PG + ES 兩段查詢
  • 熱門詞重複落 DB,連線池被吃光時整站 5xx
  • p95 在尖峰:1,820 ms
  • 同樣的 query 重算 N 遍,DB CPU 80%+
改之後
  • 先查 Redis,hit 直接回;miss 才走 PG + ES
  • 商品更新時 publish invalidation event 清相關 key
  • p95 在尖峰:140 ms(staging 壓測)
  • DB qps 從 800 → 12,CPU 回到 25%

逐檔導覽

不照字母排,照「reviewer 應該照這個順序讀」排。先看快取本體,再看 invalidation pub/sub,最後是接線。

app/services/search/cache.py new +118

這個 PR 的核心。 提供 SearchCache.get_or_compute(),吃一個 query 物件、normalize 之後算 SHA-256 當 key、查 Redis;miss 就跑 callback 把結果寫回。TTL 用 60 秒 + ±10 秒 jitter 避免 thundering herd。空結果也快取(用較短的 15 秒 TTL),不然「拼錯字搜尋」會一直穿透。

def get_or_compute(self, query: SearchQuery, compute_fn) -> SearchResult:
    key = self._make_key(query)
    cached = self.redis.get(key)
    if cached is not None:
        self.metrics.incr("search.cache.hit")
        return SearchResult.from_json(cached)

    self.metrics.incr("search.cache.miss")
    result = compute_fn(query)

    ttl = EMPTY_TTL if result.is_empty else NORMAL_TTL
    ttl += random.randint(-JITTER, JITTER)         # anti-stampede
    self.redis.setex(key, ttl, result.to_json())

    # 記下這個 key 受哪些 category 影響,invalidator 用
    for cat in query.categories:
        self.redis.sadd(f"search.idx:cat:{cat}", key)
    return result
app/services/search/handler.py mod +18 −54

能看到收益的地方。 原本的 handler 直接呼叫 run_pg_queryrun_es_query,現在外面包一層 cache。注意 compute_fn 是 lambda,真的 miss 才會被呼叫,所以 hit 路徑完全不會碰 DB。

  query = SearchQuery.parse(request.params)

  pg_rows = run_pg_query(query)                  # 每次都打 DB
  es_hits = run_es_query(query)
  result = merge(pg_rows, es_hits)
  return result.to_response()
  result = self.cache.get_or_compute(
      query,
      lambda q: merge(run_pg_query(q), run_es_query(q)),
  )
  return result.to_response()
app/services/search/invalidator.py new +76

訂閱 product.updated / product.deleted Kafka topic,從 message 抽 category list,去 Redis 的反向索引 search.idx:cat:{cat} 拿到所有受影響的 cache key,一次 DEL 掉。實作刻意保守:寧可多清也不要漏清,60 秒 TTL 是兜底。

app/services/search/query.py mod +42 −28

SearchQuery.normalize() 從各 caller 散落的邏輯收回來:去前後空白、小寫化、unicode NFKC、filter list 排序。這非常重要——任何兩個語意相同但字面不同的 query 必須產生同一個 cache key,不然 hit rate 會被字面差異吃掉。

config/redis.py, infra/k8s/redis-cache.yaml mod +34 −6

用獨立的 Redis instance(不跟 session store 混),記憶體上限 2 GB,maxmemory-policy 設 allkeys-lru。Redis 整個掛掉時,get_or_compute 會 fallback 直接跑 callback——降級不掉資料,只是退回原本的慢。

tests/services/search/test_cache.py new +128

用 fakeredis 跑單元測試 + 用真的 Redis container 跑整合測試。覆蓋:normalize 等價性、空結果不同 TTL、invalidation 真的清到、Redis 斷線 fallback、jitter 範圍。Race condition 部分用 asyncio.gather 開 50 個並發 query 驗證只有一個會跑 callback。

請特別 review 這三個地方

1
Cache key 的 normalize 邏輯
query.py:67–94。如果這裡漏掉一個 normalize 步驟(例如 filter list 沒排序),同樣的搜尋會產生不同 key、hit rate 大跌;如果太激進(例如把同義詞折疊),不同搜尋會撞同一個 key、回錯結果。這是兩種失敗模式裡比較難察覺的那種,麻煩多看一眼 unit test 的等價類。
2
Invalidation 的反向索引
cache.py:88–93invalidator.py:42–60。寫入時把 key 加進 category set,更新時掃 set 全清。Set 沒設 TTL(我有想過、目前刻意不設),所以長期會膨脹。我估算下來七天內不會撞到 Redis 記憶體上限,但這個假設值得 challenge。
3
我刻意「沒做」的事
沒做 per-user 個人化快取(user_id 進 key 命中率太低)、沒做兩級快取(local LRU + Redis)、沒做 cache warming。這三個都可以之後疊上來;綁進這個 PR 會讓它太大、不好 review。

測試計畫

單元測試:normalize 等價性、空結果 TTL、Redis 斷線 fallback、jitter 範圍
tests/services/search — 26 個 case,全綠
整合測試:真 Redis container + 真 PG,驗證 invalidation 確實清到對的 key
Staging 壓測:1,000 rps 打熱門 20 詞 + 30% 長尾,跑 15 分鐘,p95 = 140 ms
壓測前 p95 = 1,820 ms,dashboard 連結在 PR 描述
手動:把 staging Redis 殺掉,確認 API 不會 5xx、只是變慢
會在 10% rollout 階段做

上線節奏

藏在 feature flag search_cache_v1 後面。舊路徑保留一個 release 週期,hit rate 跟錯誤率盯穩了再刪。

Day 0
internal
只開內部帳號。盯 hit rate(目標 > 85%)跟 invalidation lag(目標 < 2s)。
Day 2
10%
隨機抽樣 10% 流量。錯誤率異常或 hit rate < 70% 就回滾。
Day 5
100%
全量上線,下週開新 PR 把舊路徑跟 flag 一起拔掉。