# Lesson 22: 快取災難預防：快取穿透 、擊穿與雪崩

我們在上一篇文章中介紹了基本的Cache Aside Pattern，也補充了在Database 主從分離架構下可能造成Cache的異常，並一同介紹了：延遲雙刪 以及 CDC。

我們這個章節要來談談Cache還有哪些問題

# 快取穿透 (Cache Penetration)

## 定義

請求的資料 **不在快取中，也不在資料庫中**。 每次請求都會穿過快取，直接打到 DB，但 DB 也查不到資料，導致無法回寫快取。如果有惡意攻擊者使用大量不存在的 ID 進行攻擊，DB 會瞬間承受巨大壓力。

## 常見場景

* 惡意攻擊：使用 `id = -1` 或隨機生成的 UUID 發起大量請求。
    
* 程式邏輯錯誤：前端送出了後端根本沒有的資料查詢。
    

## **解決方案**

1. **快取空物件 (Cache Null Value)：**
    
    * 當 DB 查不到資料時，仍在 Redis 中紀錄該 Key，但值設為 `null` 或特定標記，並設定一個較短的過期時間 (例如 30 秒)。
        
    * 優點： 實作簡單。
        
    * 缺點： 會浪費 Redis 空間存垃圾資料；且在過期前可能會有資料不一致問題 (若該資料突然被新增了)。
        
2. **布隆過濾器 (Bloom Filter)：**
    
    * 在請求進入快取之前，先透過 Bloom Filter 判斷該 Key 是否「可能存在」。如果 Bloom Filter 說不存在，則直接攔截，不查 Redis 也不查 DB。
        
    * 優點： 記憶體佔用極少，效率極高，適合超大數據集。
        
    * 缺點： 實作複雜，存在極低機率的誤判 (False Positive)，且刪除資料困難。
        
3. **嚴格的參數校驗：**
    
    * 在 Controller 層就攔截不合法的參數 (如 ID &lt; 0)。
        

## 補充說明 - 布隆過濾器

Bloom Filter 是一種 **「機率型資料結構」**。

它的核心價值在於：用極小的記憶體空間，快速判斷一個元素「絕對不存在」或「可能存在」。

### **核心原理：BitMap + 多重雜湊 (Multiple Hashing)**

有一個長度為 m 的位元陣列 ，初始值全為 0。 當要儲存一個資料（例如 user\_id: 1001）時：

1. 使用 k 個不同的雜湊函數 對該資料進行運算。
    
2. 算出 k 個位置索引 (Index)。
    
3. 將 Bit Array 中這 k 個位置的值都設為 `1`。
    

### 查詢流程

當一個請求來查詢 user\_id: 9999：

1. 用K個雜湊函數運算。
    
2. 檢查這 k 個位置的 Bit 是否全為 `1`。
    
    1. 情況 A： 只要有 任何一個 位置是 0 →代表該資料 絕對不存在 (直接攔截，不查 DB)。
        
    2. 情況 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。
    

## 解決方案

1. 互斥鎖 (Mutex Lock / Distributed Lock)：
    
    * 當發現 Cache 失效時，不是所有執行緒都去查 DB。而是使用 `SETNX` (Redis) 搶鎖。
        
    * 搶到鎖的人去查 DB 並回寫 Cache，其他人在旁邊等待並重試讀取 Cache。
        
    * *優點：* 保證資料一致性，DB 壓力最小。
        
    * *缺點：* 程式碼複雜度增加，可能會稍微降低吞吐量。
        
2. 邏輯過期 (Logical Expiry / Soft TTL)：
    
    * Redis Key 設定為「永不過期」，但在 Value 內部包含一個邏輯上的「過期時間欄位」。
        
    * 取出資料時，若發現邏輯時間已過期，則開啟一個「非同步執行緒」去背景更新資料，當下先回傳舊資料給使用者。
        
    * *優點：* 使用者體驗好，幾乎無延遲。
        
    * *缺點：* 需要額外的記憶體存時間戳；在更新完成前，使用者會看到短暫的舊資料。
        

## 補充說明 - 互斥鎖

當快取失效，只允許一個 Thread 去重建快取，其他 Thread 等待。

這看似簡單，但實作上有兩個經典的「坑」 - 死鎖 (Deadlock) 與 誤刪鎖。

### 流程設計

我們通常使用 Redis 的 `SETNX` (Set if Not Exists) 來實作分散式鎖。

1. 查詢 Cache： 沒資料 (Miss)。
    
2. 爭奪鎖： 嘗試 `SET lock_key unique_id NX PX 10000` (設定 10 秒過期)。
    
    1. 成功 (Got Lock)： 查詢 DB → 寫入 Cache → 釋放鎖 (DEL)。
        
    2. 失敗 (Lock Busy)： 休眠 50ms(舉例) → 重試 (Retry)。
        

### 關鍵細節

1. 雙重檢查鎖：
    
    當等待的 Thread 搶到鎖時，不要直接查 DB。因為在它等待的期間，前一個持有鎖的人可能已經把資料寫進 Cache 了。正確邏輯： 搶到鎖 → 再查一次 Cache → 若有資料直接回傳；若無資料才查 DB
    
2. 鎖的原子性與過期：
    
    * 錯誤寫法： 先 `SETNX` 再 `EXPIRE`。如果程式在 `SETNX` 後當機，`EXPIRE` 沒執行，這個鎖就變成「永不過期」，造成死鎖。
        
    * 正確寫法： 使用 Redis 的原子指令 `SET key value NX PX milliseconds` 一步到位。
        
3. 誰加鎖，誰解鎖：
    
    * 場景： 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 節點掛掉。
    

## 解決方案

1. 隨機過期時間 (Random TTL)：
    
    * 在設定過期時間時，不要設為固定值。例如：原定 60 分鐘過期，改成 `60 分鐘 + 隨機 0~5 分鐘`。讓失效時間分散開來。
        
2. 高可用架構 (High Availability)：
    
    * 使用 Redis Sentinel 或 Redis Cluster。當主節點掛掉時，從節點能自動接手，避免全盤崩潰。
        
3. 限流與降級 (Rate Limiting & Circuit Breaker)：
    
    * 當偵測到 Redis 或 DB 壓力過大時，啟動斷路器 (如 Hystrix)，直接拒絕部分請求或回傳預設值，保全系統核心功能。
        
4. 多級快取 (Multi-Level Cache)：
    
    * 在 Nginx 或應用程式記憶體 (Local Cache, 如 Guava/Caffeine) 增加一層快取。即使 Redis 掛了，本地快取還能擋一陣子。
        

# 整理

| **問題** | **關鍵點** | **發生原因** | **核心解法** |
| --- | --- | --- | --- |
| **穿透 (Penetration)** | **查無此人** | 資料在 Cache 和 DB 都不存在 | Bloom Filter、快取空物件 (Null) |
| **擊穿 (Breakdown)** | **單點突破** | 單一熱點 Key 過期 | 互斥鎖 (Mutex)、邏輯過期 |
| **雪崩 (Avalanche)** | **全面崩盤** | 大量 Key 同時過期 或 Redis 當機 | 隨機 TTL、Redis Cluster、限流降級 |
