# Lesson 21: 空間換取時間-Cache 的快取策略 (Cache-Aside / Delayed Double Delete)

在後端開發的世界裡，有一句至理名言：「沒有什麼效能問題是加一層 Cache 不能解決的；如果有，那就加兩層。」

雖然是句玩笑話，但道出了後端架構的核心思想——「以空間換取時間」。資料庫（Database）存放在硬碟，讀取速度慢且 I/O 成本高；而快取（Cache, 如 Redis）存放在記憶體（RAM），讀取速度極快但空間昂貴。

## 什麼是 Cache-Aside Pattern？

Cache 不會主動和 Database 溝通，所有的數據流動都由應用程式的程式碼來控制。

這就像去圖書館借書：

1. 先看架上（Cache）有沒有這本書。
    
2. 如果有，直接拿走（Read Hit）。
    
3. 如果沒有，去倉庫（Database）把書找出來，讀完後順手放一本在架上（寫Cache），方便下一個人拿。
    

## 運作流程

要實作 Cache-Aside，需要處理「讀取」與「寫入/更新」兩個面向。

### 1\. 讀取路徑 (Read Path)

這是最常見的流程，目的在於減少 Database 的讀取壓力。

1. 應用程式接收到請求，先查詢 Cache。
    
2. Hit (命中)：如果 Cache 中有資料，直接回傳給使用者。
    
3. Miss (未命中)：如果 Cache 中沒有資料：
    
    * 從 **Database** 讀取原始資料。
        
    * 將這筆資料寫入 **Cache**（通常會設定過期時間 TTL）。
        
    * 回傳資料給使用者。
        

### 2\. 寫入路徑 (Write Path) - 關鍵在於「刪除」

當資料發生變動（新增、修改、刪除）時，我們該怎麼處理 Cache？這也是保持資料最終一致性的關鍵戰場。

Cache Aside Pattern 的正確流程：

1. 先更新 Database。
    
2. 刪除 (Delete/Invalidate) Cache 中的對應資料。
    

## 進階挑戰：延遲雙刪 (Delayed Double Delete)

雖然「先更 DB，再刪 Cache」已經能解決 99% 的問題，但在極端的資料庫讀寫分離架構下，仍可能有問題：

**問題場景：**

1. 應用程式更新 Master DB。
    
2. 應用程式刪除 Cache。
    
3. （此時 DB 主從同步尚未完成，Slave DB 仍是舊資料）。
    
4. 另一個讀取請求進來，Cache Miss，於是去讀 Slave DB（讀到舊資料）。
    
5. 讀取請求把舊資料寫回 Cache。
    
6. **結果**：Cache 裡又變成了髒資料。
    

**解決方案：延遲雙刪** 為了確保萬無一失，我們可以採用延遲雙刪策略：

1. 先刪除 Cache。
    
2. 更新 Database。
    
3. **休眠一小段時間**：這個時間 T 需要大於「DB 主從同步」的時間，
    
4. **再次刪除 Cache**。
    

這樣做可以確保在 DB 同步完成後，任何可能被寫入 Cache 的髒資料都會被再次清除。

> 延遲雙刪並不是很常見，因為 休眠時間 很難精準估，實務上通常只在**高一致性要求系統**才會用。

## 進階挑戰：徹底解耦—基於 Binlog 的快取自動同步 (CDC)

在標準的 Cache-Aside Pattern 中，工程師必須小心翼翼地在每一處「更新 DB」的程式碼後方，補上一句「刪除 Cache」。如果某個新人忘記寫了，或者某個後台管理腳本直接改了 SQL，Cache 裡的資料就會變成永久的髒資料。

**問題核心：業務邏輯與快取維護邏輯高度耦合 (Coupling)。**

為了解決這個問題，我們引入 **CDC (Change Data Capture)** 技術。我們不再由應用程式去管 Cache，而是讓 Database 的變更「主動」通知 Cache 進行更新。

## 架構原理

這個架構的核心在於 **MySQL Binlog**。MySQL 的 Binlog 記錄了所有的資料變更（Insert, Update, Delete）。我們可以透過一個中介軟體（Middleware）偽裝成 MySQL 的 Slave 節點，監聽 Binlog，解析出變更後的資料，然後推送到 Message Queue (如 RabbitMQ/Kafka)，最後由一個獨立的 Consumer 更新 Redis。

### 完整資料流：

1. **Business App**：只管寫入 MySQL，完全不用管 Redis。
    
2. **MySQL**：將變更寫入 Binlog (需設定 `binlog_format = ROW`)。
    
3. **CDC Middleware**：
    
    * 偽裝成 MySQL Slave。
        
    * 即時讀取 Binlog。
        
    * 將二進位的日誌解析成 JSON 格式 (例如：`{ "table": "users", "type": "UPDATE", "data": {...} }`)。
        
4. **Message Queue (RabbitMQ/Kafka)**：緩衝這些變更訊息，確保順序性與可靠性。
    
5. **Cache Consumer**：訂閱 MQ，收到變更通知後，對 Redis 進行「刪除」或「更新」操作。
    

## 業界常用工具

要實作這套架構，你不需要自己去寫 Binlog Parser，業界已有成熟方案：

1. **Canal (阿裡巴巴開源)**：
    
    * 最經典的方案，Java 寫的。
        
    * 部署簡單，直接支援投遞到 RocketMQ, Kafka, RabbitMQ。
        
    * 適合：以 MySQL 為主的架構。
        
2. **Debezium (RedHat 開源)**：
    
    * 基於 Kafka Connect。
        
    * 支援多種 DB (MySQL, PostgreSQL, MongoDB...)。
        
    * 適合：已有 Kafka 生態系的大型架構。
        
3. **Maxwell**：
    
    * 輕量級，將 Binlog 解析為 JSON 並發送至 Kafka/RabbitMQ。
        

# 快取不是只有加速

## 刪除與更新的抉擇

### 為什麼是「刪除 Cache」而不是「更新 Cache」？

很多人會直覺地認為：「我改了 DB，順便把新的值寫入 Redis 不就好了嗎？」

但這裡卻採用刪除的方式，原因以下：

1. 併發競爭： 假設有兩個請求 A 和 B 同時修改同一筆資料。
    
    * A 先改了 DB，正準備更新 Cache...
        
    * B 緊接著改了 DB，並且搶先更新了 Cache。
        
    * 這時 A 終於更新了 Cache。
        
    * **結果**：DB 裡是 B 的新資料，但 Cache 裡卻是 A 的舊資料（髒資料）。
        
2. 效能浪費： 有些資料是「寫多讀少」的。如果你每次修改 DB 都去計算並更新 Cache，但這筆資料可能根本沒人來讀，那你花費在計算 Cache 的資源就浪費了。採用「刪除」策略，即是Lazy Loading（延遲加載） 的概念——等到真的有人要讀時，再重新計算並寫入。
    

## 淘汰策略

上面講的是如何讓cache的資料跟Database一致，或者說：當我們更新數據源的時候要怎麼讓快取也一併更新。

那我們現在來談談：快取的淘汰策略。

### TTL

一般來說第一層淘汰策略就是：TTL。

TTL 是由 Redis 依據過期時間進行惰性刪除與定期清理的機制。設定一個有效期限（例如 300 sec），到期後即自動失效。

TTL的優點就是簡單有效，也可以避免資料長時間占用Redis空間。

但也有缺點：

1. 不會自動調整策略，需要依靠經驗或業務邏輯來設定相關的有效時間。
    
2. 過早淘汰或過晚失效都有可能讓系統不堪負荷。
    
3. 無法處理 Cache 滿了的時候 應該怎麼辦。
    

### LRU

Least Recently Used，淘汰「最久沒被用」的資料，近期熱資料優先保留。

依照「最後一次存取時間」排序，Cache 滿了 → 把最久沒被碰過的 key 刪除。

LRU非常符合人類直覺的淘汰策略，但對於週期性存取不太友善，也有可能被爬蟲(或突發流量)影響。

適用場景：電子商務商品列表、熱門文章…等。

### LFU

Least Frequently Used，淘汰「使用次數最少」的資料，長期熱資料優先保留。

記錄每個 key 被存取的「次數」，淘汰累積使用最少的 key。

LFU對應突發流量的處理情況會比LRU來的好一些，因為他是計算次數，但冷啟動的問題就相對嚴重，也容易讓 “存越久的資料越不容易刪除”，

適用場景：國碼表、程式表、設定檔、幾乎不變但很常被查的資料。
