Lesson 22: 快取災難預防:快取穿透 、擊穿與雪崩
我們在上一篇文章中介紹了基本的Cache Aside Pattern,也補充了在Database 主從分離架構下可能造成Cache的異常,並一同介紹了:延遲雙刪 以及 CDC。
我們這個章節要來談談Cache還有哪些問題
快取穿透 (Cache Penetration)
定義
請求的資料 不在快取中,也不在資料庫中。 每次請求都會穿過快取,直接打到 DB,但 DB 也查不到資料,導致無法回寫快取。如果有惡意攻擊者使用大量不存在的 ID 進行攻擊,DB 會瞬間承受巨大壓力。
常見場景
惡意攻擊:使用
id = -1或隨機生成的 UUID 發起大量請求。程式邏輯錯誤:前端送出了後端根本沒有的資料查詢。
解決方案
快取空物件 (Cache Null Value):
當 DB 查不到資料時,仍在 Redis 中紀錄該 Key,但值設為
null或特定標記,並設定一個較短的過期時間 (例如 30 秒)。優點: 實作簡單。
缺點: 會浪費 Redis 空間存垃圾資料;且在過期前可能會有資料不一致問題 (若該資料突然被新增了)。
布隆過濾器 (Bloom Filter):
在請求進入快取之前,先透過 Bloom Filter 判斷該 Key 是否「可能存在」。如果 Bloom Filter 說不存在,則直接攔截,不查 Redis 也不查 DB。
優點: 記憶體佔用極少,效率極高,適合超大數據集。
缺點: 實作複雜,存在極低機率的誤判 (False Positive),且刪除資料困難。
嚴格的參數校驗:
- 在 Controller 層就攔截不合法的參數 (如 ID < 0)。
補充說明 - 布隆過濾器
Bloom Filter 是一種 「機率型資料結構」。
它的核心價值在於:用極小的記憶體空間,快速判斷一個元素「絕對不存在」或「可能存在」。
核心原理:BitMap + 多重雜湊 (Multiple Hashing)
有一個長度為 m 的位元陣列 ,初始值全為 0。 當要儲存一個資料(例如 user_id: 1001)時:
使用 k 個不同的雜湊函數 對該資料進行運算。
算出 k 個位置索引 (Index)。
將 Bit Array 中這 k 個位置的值都設為
1。
查詢流程
當一個請求來查詢 user_id: 9999:
用K個雜湊函數運算。
檢查這 k 個位置的 Bit 是否全為
1。情況 A: 只要有 任何一個 位置是 0 →代表該資料 絕對不存在 (直接攔截,不查 DB)。
情況 B: 所有位置都是 1 → 代表該資料 可能存在 (放行,去查 Cache/DB)。
為什麼會有誤判?
這就是「雜湊碰撞 (Hash Collision)」的代價。 假設 Key A 把位置 1, 5, 7 設為 1。 假設 Key B 把位置 2, 5, 8 設為 1。 現在來了一個不存在的 Key C,它的雜湊結果剛好是 1, 2, 8。 系統一查,發現 1, 2, 8 全部都是 1(由 A 和 B 湊出來的),於是 Bloom Filter 誤以為 Key C 存在。
誤判率與 陣列長度 (m) 和 雜湊函數數量 (k) 有關。陣列越長,誤判越低;k 越多,誤判越低(但也越慢)。
無法刪除: 標準的 Bloom Filter 不支援刪除。因為你不知道把某個位置的 1 改回 0 時,會不會誤傷到其他也共用該位置的 Key。
進階解法: 若需刪除,需使用 Counting Bloom Filter (每個位置不存 Bit,改存 Counter),但空間成本會暴增。
實務選擇
在開發中,我們通常不需手寫 BitMap,而是使用現成的 Redis Module (RedisBloom) RedisBloom - Redis 官方模組,指令如 BF.ADD, BF.EXISTS,效能極佳。
快取擊穿 (Cache Breakdown)
定義
針對 「單一個」 非常熱門的 Key (Hot Key),在這個 Key 過期的瞬間,同時有大量的併發請求進來。 因為快取剛好失效,所有請求瞬間打到 DataBase,就像在盾牌上鑿了一個洞。
常見場景
電商秒殺活動的商品頁。
熱門新聞或話題的 API。
解決方案
互斥鎖 (Mutex Lock / Distributed Lock):
當發現 Cache 失效時,不是所有執行緒都去查 DB。而是使用
SETNX(Redis) 搶鎖。搶到鎖的人去查 DB 並回寫 Cache,其他人在旁邊等待並重試讀取 Cache。
優點: 保證資料一致性,DB 壓力最小。
缺點: 程式碼複雜度增加,可能會稍微降低吞吐量。
邏輯過期 (Logical Expiry / Soft TTL):
Redis Key 設定為「永不過期」,但在 Value 內部包含一個邏輯上的「過期時間欄位」。
取出資料時,若發現邏輯時間已過期,則開啟一個「非同步執行緒」去背景更新資料,當下先回傳舊資料給使用者。
優點: 使用者體驗好,幾乎無延遲。
缺點: 需要額外的記憶體存時間戳;在更新完成前,使用者會看到短暫的舊資料。
補充說明 - 互斥鎖
當快取失效,只允許一個 Thread 去重建快取,其他 Thread 等待。
這看似簡單,但實作上有兩個經典的「坑」 - 死鎖 (Deadlock) 與 誤刪鎖。
流程設計
我們通常使用 Redis 的 SETNX (Set if Not Exists) 來實作分散式鎖。
查詢 Cache: 沒資料 (Miss)。
爭奪鎖: 嘗試
SET lock_key unique_id NX PX 10000(設定 10 秒過期)。成功 (Got Lock): 查詢 DB → 寫入 Cache → 釋放鎖 (DEL)。
失敗 (Lock Busy): 休眠 50ms(舉例) → 重試 (Retry)。
關鍵細節
雙重檢查鎖:
當等待的 Thread 搶到鎖時,不要直接查 DB。因為在它等待的期間,前一個持有鎖的人可能已經把資料寫進 Cache 了。正確邏輯: 搶到鎖 → 再查一次 Cache → 若有資料直接回傳;若無資料才查 DB
鎖的原子性與過期:
錯誤寫法: 先
SETNX再EXPIRE。如果程式在SETNX後當機,EXPIRE沒執行,這個鎖就變成「永不過期」,造成死鎖。正確寫法: 使用 Redis 的原子指令
SET key value NX PX milliseconds一步到位。
誰加鎖,誰解鎖:
場景: Thread A 搶到鎖,但 DB 查詢太慢,過了 10 秒鎖自動過期了。Thread B 此時搶到鎖開始執行。突然,Thread A 做完了,執行
DEL。結果 A 刪掉了 B 的鎖!解法:
SET的 value 必須是一個 UUID (Request ID)。解鎖時,先檢查 Value 是否等於自己的 UUID,若是才執行DEL。這通常需要用 Lua Script 來保證「檢查 + 刪除」的原子性。
快取雪崩 (Cache Avalanche)
定義
雪崩是指 「大量」 的 Key 在 同一時間集體過期,或者 Redis 節點當機。 這會導致原本由 Redis 承擔的海量請求,瞬間全部轉移到 DB,造成 DB CPU 飆升並當機。
常見場景
系統剛重啟,預熱了大量資料,並設定了相同的過期時間 (例如都是 1 小時)。
Redis Master 節點掛掉。
解決方案
隨機過期時間 (Random TTL):
- 在設定過期時間時,不要設為固定值。例如:原定 60 分鐘過期,改成
60 分鐘 + 隨機 0~5 分鐘。讓失效時間分散開來。
- 在設定過期時間時,不要設為固定值。例如:原定 60 分鐘過期,改成
高可用架構 (High Availability):
- 使用 Redis Sentinel 或 Redis Cluster。當主節點掛掉時,從節點能自動接手,避免全盤崩潰。
限流與降級 (Rate Limiting & Circuit Breaker):
- 當偵測到 Redis 或 DB 壓力過大時,啟動斷路器 (如 Hystrix),直接拒絕部分請求或回傳預設值,保全系統核心功能。
多級快取 (Multi-Level Cache):
- 在 Nginx 或應用程式記憶體 (Local Cache, 如 Guava/Caffeine) 增加一層快取。即使 Redis 掛了,本地快取還能擋一陣子。
整理
| 問題 | 關鍵點 | 發生原因 | 核心解法 |
| 穿透 (Penetration) | 查無此人 | 資料在 Cache 和 DB 都不存在 | Bloom Filter、快取空物件 (Null) |
| 擊穿 (Breakdown) | 單點突破 | 單一熱點 Key 過期 | 互斥鎖 (Mutex)、邏輯過期 |
| 雪崩 (Avalanche) | 全面崩盤 | 大量 Key 同時過期 或 Redis 當機 | 隨機 TTL、Redis Cluster、限流降級 |

