商品搜尋 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_query 跟 run_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 這三個地方
query.py:67–94。如果這裡漏掉一個 normalize 步驟(例如 filter list 沒排序),同樣的搜尋會產生不同 key、hit rate 大跌;如果太激進(例如把同義詞折疊),不同搜尋會撞同一個 key、回錯結果。這是兩種失敗模式裡比較難察覺的那種,麻煩多看一眼 unit test 的等價類。cache.py:88–93 跟 invalidator.py:42–60。寫入時把 key 加進 category set,更新時掃 set 全清。Set 沒設 TTL(我有想過、目前刻意不設),所以長期會膨脹。我估算下來七天內不會撞到 Redis 記憶體上限,但這個假設值得 challenge。測試計畫
上線節奏
藏在 feature flag search_cache_v1 後面。舊路徑保留一個 release 週期,hit rate 跟錯誤率盯穩了再刪。