Skip to main content

Command Palette

Search for a command to run...

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

Updated
2 min read

在後端開發的世界裡,有一句至理名言:「沒有什麼效能問題是加一層 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來的好一些,因為他是計算次數,但冷啟動的問題就相對嚴重,也容易讓 “存越久的資料越不容易刪除”,

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

從Rookie到Junior,一個後端成長的30堂課

Part 6 of 31

“從 Rookie 到 Junior:一個後端成長的 30 堂課” 是一套專為後端新手所設計的成長型技術系列文章。內容以實務為導向,逐步拆解後端工程的核心能力,包括程式語言基礎、架構思維、框架運作原理、業務邏輯設計、資料庫操作、以及常見的開發模式。 本系列的目標是協助讀者從零散的學習堆疊,建立成體系的後端知識框架。讀者能夠理解各語言背後不變的工程思維與設計原則。這套內容旨在讓學習者從「會寫程式」進入「能理解系統設計」的階段,逐步具備勝任 Junior Backend Engineer 的能力。

Up next

Module 4: 效能優化與系統緩衝

序 當系統架構逐漸成形、程式碼品質也趨於穩定後,下一個遲早會浮現的現實問題:撐得住嗎? 也許在開發環境一切順暢,但一上線就開始出現以下情境: 使用者一多,API 回應時間明顯變慢 尖峰流量來臨時,資料庫 CPU 飆高、連線數耗盡 某個外部服務偶爾變慢,卻拖垮了整個系統 記憶體持續成長,最後只剩一句「Out of Memory」 這些問題,不是功能寫錯,而是系統沒有「緩衝能力」。 在 Module 4 中,我們將視角從「單一請求的正確性」,提升到「整體系統在壓力下的行為」。我們需要開始...

More from this blog

Lesson 26 : 系統韌性的守護者-限流、熔斷與背壓的設計模式

當這幾個名詞出現後,代表我們進到了一個高併發/大流量的系統了。在這個章節中,我們一起來看看如何透過一些方式來避免高併發導致我們的系統crash掉。 限流(Rate Limiting) 相信大家對這個名詞並不陌生,限流其實就是字面上的含意,限制流量。 限流的目的是保護「接收方」,確保系統不會因為瞬間的高併發請求而癱瘓。 限流通常發生在 API Gateway 或服務的最前端。它像是一個夜店門口的保全

Mar 26, 20262 min read

Lesson 25: 淺談 單體架構、微服務架構與單/多租戶架構

過去我們討論了 要把程式寫在哪、程式要怎麼拆的題目,接下來我們來看看「如何服務不同客戶」,這些架構反映了軟體開發在擴充性與複雜度之間的權衡。 軟體架構深度解析:從系統拆分到商業規模化 在軟體工程的演進中,架構的選擇往往是在「開發效率」、「系統擴充性」與「營運成本」之間尋求平衡。我們可以從兩個核心維度來觀察這些架構:系統如何運行(單體 vs. 分散式) 以及 如何服務客戶(單租戶 vs. 多租戶)。

Mar 26, 20262 min read

Lesson 24: 資料庫擴展術-讀寫分離、複寫機制與快取一致性挑戰

為什麼要讀寫分離? 大多數的 Web 應用都是 「讀多寫少」(例如:看文的人多,發文的人少,Heavy Read System)。當所有的請求都塞給同一台資料庫時,磁碟 I/O 和連線數會成為瓶頸。 Master (主庫): 負責寫入 (Insert/Update/Delete),確保數據一致性。 Slave (從庫): 負責讀取 (Select),可以有多個從庫來分擔讀取壓力。 為什麼讀

Mar 25, 20262 min read

面試經驗談 2025-2026

從2025年3月開始,我陸陸續續參與了從新創到上市櫃公司的Senior - Tech Lead的相關面試,其中有不乏 尊重面試者、展現高度專業的企業(公司),當然也有遇到幾場面試鬼故事,這篇文章主要分享我對於軟體工程師面試的方向分享,以及部分鬼故事,以此警惕自己不要成為這樣的面試官。 AI的洪流,改變了SWE的生態 LLM的發展確確實實的影響到了軟體工程師的生態。 過去受限於算力與資料規模,深度學

Mar 23, 20262 min read

Lesson 23: 系統的緩衝區-Queue 佇列與非同步處理 (Asynchronous)

佇列 佇列的實作工具非常多,舉凡AWS SQS、RabbitMQ、Kafka…等。 佇列的特性,其實是一個非常強大的系統緩衝區,應用層面非常廣。 什麼是佇列? 佇列可以想像成,在既有流程中外,有另一個”水管”,來連接原有的資料流(或邏輯過程),其中 呼叫方將資料 推(Push)到水管中,接受方(監聽) 從水管中將資料拉(Pull)出處理 為什麼佇列是「強大的緩衝區」? 在同步處理中,系統像是一

Mar 23, 20262 min read

Bennett's Tech Blog | 後端架構、系統設計

32 posts

來自台灣的軟體工程師,相信軟體可以改變世界

從Rookie到Junior,一個後端成長的30堂課 | Lesson 21: 空間換取時間-Cache 的快取策略