Lesson 21: 空間換取時間-Cache 的快取策略 (Cache-Aside / Delayed Double Delete)
在後端開發的世界裡,有一句至理名言:「沒有什麼效能問題是加一層 Cache 不能解決的;如果有,那就加兩層。」
雖然是句玩笑話,但道出了後端架構的核心思想——「以空間換取時間」。資料庫(Database)存放在硬碟,讀取速度慢且 I/O 成本高;而快取(Cache, 如 Redis)存放在記憶體(RAM),讀取速度極快但空間昂貴。
什麼是 Cache-Aside Pattern?
Cache 不會主動和 Database 溝通,所有的數據流動都由應用程式的程式碼來控制。
這就像去圖書館借書:
先看架上(Cache)有沒有這本書。
如果有,直接拿走(Read Hit)。
如果沒有,去倉庫(Database)把書找出來,讀完後順手放一本在架上(寫Cache),方便下一個人拿。
運作流程
要實作 Cache-Aside,需要處理「讀取」與「寫入/更新」兩個面向。
1. 讀取路徑 (Read Path)
這是最常見的流程,目的在於減少 Database 的讀取壓力。
應用程式接收到請求,先查詢 Cache。
Hit (命中):如果 Cache 中有資料,直接回傳給使用者。
Miss (未命中):如果 Cache 中沒有資料:
從 Database 讀取原始資料。
將這筆資料寫入 Cache(通常會設定過期時間 TTL)。
回傳資料給使用者。
2. 寫入路徑 (Write Path) - 關鍵在於「刪除」
當資料發生變動(新增、修改、刪除)時,我們該怎麼處理 Cache?這也是保持資料最終一致性的關鍵戰場。
Cache Aside Pattern 的正確流程:
先更新 Database。
刪除 (Delete/Invalidate) Cache 中的對應資料。
進階挑戰:延遲雙刪 (Delayed Double Delete)
雖然「先更 DB,再刪 Cache」已經能解決 99% 的問題,但在極端的資料庫讀寫分離架構下,仍可能有問題:
問題場景:
應用程式更新 Master DB。
應用程式刪除 Cache。
(此時 DB 主從同步尚未完成,Slave DB 仍是舊資料)。
另一個讀取請求進來,Cache Miss,於是去讀 Slave DB(讀到舊資料)。
讀取請求把舊資料寫回 Cache。
結果:Cache 裡又變成了髒資料。
解決方案:延遲雙刪 為了確保萬無一失,我們可以採用延遲雙刪策略:
先刪除 Cache。
更新 Database。
休眠一小段時間:這個時間 T 需要大於「DB 主從同步」的時間,
再次刪除 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。
完整資料流:
Business App:只管寫入 MySQL,完全不用管 Redis。
MySQL:將變更寫入 Binlog (需設定
binlog_format = ROW)。CDC Middleware:
偽裝成 MySQL Slave。
即時讀取 Binlog。
將二進位的日誌解析成 JSON 格式 (例如:
{ "table": "users", "type": "UPDATE", "data": {...} })。
Message Queue (RabbitMQ/Kafka):緩衝這些變更訊息,確保順序性與可靠性。
Cache Consumer:訂閱 MQ,收到變更通知後,對 Redis 進行「刪除」或「更新」操作。
業界常用工具
要實作這套架構,你不需要自己去寫 Binlog Parser,業界已有成熟方案:
Canal (阿裡巴巴開源):
最經典的方案,Java 寫的。
部署簡單,直接支援投遞到 RocketMQ, Kafka, RabbitMQ。
適合:以 MySQL 為主的架構。
Debezium (RedHat 開源):
基於 Kafka Connect。
支援多種 DB (MySQL, PostgreSQL, MongoDB...)。
適合:已有 Kafka 生態系的大型架構。
Maxwell:
- 輕量級,將 Binlog 解析為 JSON 並發送至 Kafka/RabbitMQ。
快取不是只有加速
刪除與更新的抉擇
為什麼是「刪除 Cache」而不是「更新 Cache」?
很多人會直覺地認為:「我改了 DB,順便把新的值寫入 Redis 不就好了嗎?」
但這裡卻採用刪除的方式,原因以下:
併發競爭: 假設有兩個請求 A 和 B 同時修改同一筆資料。
A 先改了 DB,正準備更新 Cache...
B 緊接著改了 DB,並且搶先更新了 Cache。
這時 A 終於更新了 Cache。
結果:DB 裡是 B 的新資料,但 Cache 裡卻是 A 的舊資料(髒資料)。
效能浪費: 有些資料是「寫多讀少」的。如果你每次修改 DB 都去計算並更新 Cache,但這筆資料可能根本沒人來讀,那你花費在計算 Cache 的資源就浪費了。採用「刪除」策略,即是Lazy Loading(延遲加載) 的概念——等到真的有人要讀時,再重新計算並寫入。
淘汰策略
上面講的是如何讓cache的資料跟Database一致,或者說:當我們更新數據源的時候要怎麼讓快取也一併更新。
那我們現在來談談:快取的淘汰策略。
TTL
一般來說第一層淘汰策略就是:TTL。
TTL 是由 Redis 依據過期時間進行惰性刪除與定期清理的機制。設定一個有效期限(例如 300 sec),到期後即自動失效。
TTL的優點就是簡單有效,也可以避免資料長時間占用Redis空間。
但也有缺點:
不會自動調整策略,需要依靠經驗或業務邏輯來設定相關的有效時間。
過早淘汰或過晚失效都有可能讓系統不堪負荷。
無法處理 Cache 滿了的時候 應該怎麼辦。
LRU
Least Recently Used,淘汰「最久沒被用」的資料,近期熱資料優先保留。
依照「最後一次存取時間」排序,Cache 滿了 → 把最久沒被碰過的 key 刪除。
LRU非常符合人類直覺的淘汰策略,但對於週期性存取不太友善,也有可能被爬蟲(或突發流量)影響。
適用場景:電子商務商品列表、熱門文章…等。
LFU
Least Frequently Used,淘汰「使用次數最少」的資料,長期熱資料優先保留。
記錄每個 key 被存取的「次數」,淘汰累積使用最少的 key。
LFU對應突發流量的處理情況會比LRU來的好一些,因為他是計算次數,但冷啟動的問題就相對嚴重,也容易讓 “存越久的資料越不容易刪除”,
適用場景:國碼表、程式表、設定檔、幾乎不變但很常被查的資料。

