Casha | 5. 快取兩三事 - 雪崩, 擊穿和穿透介紹與範例解析

最近開始整理 Casha 的快取設計, 剛好有一些現成的案例可以講解, 順便把快取會遇到的一些問題與解法整理起來。

快取的地雷

以 F&B 系統來說, 在正午 12 點的時候一間餐廳湧入大量客戶, 在這個區間內同時瀏覽菜單, 為了緩解 DB 的壓力, 我們設定首次查詢時, 先嘗試從 Redis 讀取緩存, 若 Redis 內沒有菜單, 則從 DB 獲取, 並寫入 Redis 後返回。

快取雪崩 Cache Avalanche

泛指大量快取 Key 在同一時間大規模失效,導致所有請求瞬間湧向資料庫,造成資料庫壓力暴增甚至到崩潰。

通常發生的原因是:

  • 設定了相同的 TTL (Time To Live),導致大量快取同時到期(失效)
  • 快取的伺服器重啟或是崩潰

以 F&B 系統為例, 就是每家餐廳都設定了菜單快取是 180s, 正午的時候流量湧進, 剛好在第 180s 的時候大家的快取同時失效, 每一家餐廳的菜單在這時全部都要從資料庫查找導致系統出問題。

常見的解法是 設定 TTL 抖動: 在專案內每家餐廳的菜單是設定 180 ± (180 * 20%)s 的隨機 TTL。確保了大量快取不會在同一時刻失效,避免了重建請求的洪峰。而針對一些常數類型的參數甚至可以儲存在 final 物件或 caffeine 裡面, 由服務啟動時載入, 並且不設定過期時間

另外補充在高峰期到來之前, 還可以預熱快取, 先把可能被高頻訪問的資料觸發放置到 Redis 內。若菜單被更新, 則發送事件, 由監聽機制去更新快取內的菜單 (但應該不會有人這麼白目在高流量的時候還給我改資料吧, 應該吧…)。

快取擊穿 Hotspot Invalid

指某個超高度被訪問的 Key 失效時,大量併發請求同時湧向這個 Key,直接一路穿透到資料庫。跟快取雪崩不太一樣的是通常是針對指定的一個 Key, 因此無法靠抖動解決(只有自己是要抖什麼)。

好比我設定了折價券資訊的快取, 但是當他失效時, 所有針對折價券資訊的請求又都會湧向 DB。

常見的解法是設定該 Key 的快取永不過期由後台手動管理更新(適合少量需要特別照顧的熱點資料)。

第二個是使用分散式鎖機制:當快取失效時,只有一個執行緒(獲取鎖的)能去重建快取,其他執行緒會等待或降級。

在 Casha 中針對菜單其實也做了這一套防護, 當請求來臨時, 使用 Redis 的 SET key value NX EX timeout 指令獲取鎖,並透過 Lua 腳本保證釋放鎖的原子性, 到下游獲取資料,同時未能拿到鎖的請求主動睡眠一段短時間(50–150ms),以等待那個持有鎖的執行緒完成快取寫入,待睡眠結束後,再次嘗試從快取中讀取資料。

如果不幸退避等待後,快取依然為空(可能持有鎖的執行緒執行非常慢,或在寫入快取前失敗了),則不再等待,而是直接降級呼叫下游服務。雖然這違背了防止擊穿的初衷,但作為最後的保障,它保證了系統的可用性(寧可少量請求擊穿,也不能讓所有請求失敗)。同時,它也會將獲取到的結果寫入快取,以服務後續的請求。

但當然, 這些機制都要仰賴實際的業務需求來設計

詳見菜單緩存機制

快取穿透 Cache Penetration

請求根本不存在的資料,所以會繞過快取直接查詢資料庫。

這種請求通常是惡意的(排除你我寫了 Bug… 正常使用者不會請求不存在的資料的),好比他發了一個 productId = -1 的請求, 可能是從 http.get 的 url 上更改, 有夠白目, 或是有人使用 proxy 偽造網站的請求。

解決方案則是:

  • 請求參數驗證: 對參數格式、正則檢驗、參數範圍
  • 設置布隆過濾器:快速判斷資料是否存在
  • 空值快取:將不存在的結果也快取起來
  • 限流策略:對異常請求模式進行限流

驗證參數大家都懂, 針對設計的規則與範圍判斷錯誤回拋 Exception, 請求根本碰不到 DB, 甚至可以搭配限流策略, 若特定的 Exception 被拋出太多次, 則在 Cache 內設定黑名單, 讓後續該 ip 的請求在 gateway 就被擋下來。

空值快取我第一次聽到的時候就想原來還有這招, 在特定的情境下或許該條件暫時不會有資料, 好比一家分店的菜單查詢下面並沒有對應的版本(早餐午餐交替的時間可能有幾分鐘的清場), 查到一個空菜單, 便可以設定空結果和較短的 TTL(如 30-60秒),避免快取污染同時又能防護短時間內的重複攻擊。

最後是布隆過濾器, 簡單來說是設計在服務啟動的時候, 從 DB 內把所有有效資料 (假定是 productId List) 取出, 並以雜湊的方式建立一個 ReadOnly 的濾器物件, 這個物件通常會是一個 BitSet, 常常會被稱為 Bloom (這是設計這個方法的人的名子), 每次請求的時候, 會用請求的 productId 進行雜湊後, 再和 Bloom 核對, 可以想像成是一個快速的過濾器。

舉個例子, 我先建立一個長度 m = 18 的 BitSet, 裡面預設都是 0, 我有兩個合法的 productId BRC001, DIN001, 我使用雜湊函式(具體實踐略) 做出 3 個索引:

BRC001 假設做出來的索引是 {5, 11, 14}, DIN001 的是 {2, 11, 17}, 則我們把前面的 BitSet 內, {2, 5, 11, 14, 17} 鍵內的值都從 0 改為 1。所以當正常請求 BRTP001 查入時, 由雜湊函式算出來 {5, 11, 14}, 對應到 BitSet 內全都是 1, 則表示他可能存在, 就放過通行。但假使來個 FAKE000, 雜湊出來可能是 {1, 7, 11},索引內 1 和 7 的值都是 0, 就可以判斷一定不存在。

他有極低的機率會錯放, 但已經可以過濾掉大部分的場景了, 且這個 BitSet 占用的空間也不大, 不過在有新的 id 置入時, Bloom 也就需要更新。

預防快取穿透通常會整合上面的方法, 參數驗證 (阻擋明顯非法請求) > 布隆過濾器 (阻擋一定不存在的請求, 極低錯放) > 空值快取 (處理偶然的不存在請求)

結語

寫到這裡大家其實可以發現, 面對這些快取的議題, 除了依靠技術解決外, 最重要的還是了解核心業務流程, 才能選用最適合的方法。

最後想了解我對菜單在 Redis 上的設計可以參考我寫的 菜單緩存機制