從「週期性任務」優化談起,一步步深入後端核心議題第二篇 🚀 快取策略:讓網頁不卡卡的第一步
💡 選擇一種聰明的流程設計,比引入各種複雜解決方案更有效、更易維護
在一開始研究週期性任務該怎麼實作時,我發現 Microsoft To Do 採用了一個聰明的設計方式,
他們不會提前計算所有未來的任務,也不會一次產生一整串任務清單。而是:
🕹️ 當用戶完成任務的那一刻,根據任務的週期設定,自動計算並產生下一個任務。(這是我在使用中觀察到的,嘗試理解我所看到的,實際上我並不知道資料如何傳遞,以及在後端是如何被處理的,我也很想知道🥲)
這樣的好處是:
- ✅ 不需要排程系統定時處理,也不會產生過多冗餘資料
- ✅ 只要根據「當前任務的到期日 + 週期設定」去產生下一個即可,不用額外記錄週期歷史
也因此,像這樣的設計,本質上就大幅降低了「需要快取」的場景。
但若產品情境或需求比較偏向下列這些:
- 想要在首頁快速載入任務列表
- 想要顯示月曆檢視、日程總覽等
- 有很多使用者同時操作,容易造成資料庫壓力
那麼快取策略就會是一個非常值得探討的方向。
🔍 為什麼我們需要快取?
即使已經採用了「事件驅動式生成任務」,仍有一些資料是可以快取來提升效能的,例如:
- 用戶每次登入要看到哪些任務?是不是每次都查資料庫?
- 同一筆資料會不會被多次請求?
此外,如果未來要實作週期任務的預覽功能(例如:「下個月我有哪些重複任務?」),那麼我們還是得做一些計算,而這些計算也可以快取下來避免重複。
💾 後端快取策略:別讓資料庫喘不過氣
我想先從後端快取開始談起。在多使用者的情境下,如果每個人登入都重新查一次資料庫,那當使用者量成長時,系統的負擔會會上升。這時候,我們可以考慮將部分重複查詢且耗巨大資源的資料快取在記憶體中,減少資料庫的查詢次數。不過這當然增加了系統的複雜度,如果流量沒有造成瓶頸,就不要引入快取。
以下是幾個常見的後端快取策略與使用時機 👇
⌛️ 何時使用快取
- 需要大量計算才能得到的值
- 讀多寫少的資料
🧠 記憶體內快取(In-Memory Cache)
最直接的方式就是使用應用程式記憶體來快取資料。這種方式最簡單,但有一些限制:
- 適合單一實例的應用程式(例如只有一台後端伺服器)
- 快速、無需額外部署
- 常用的框架如 Spring Boot 提供了簡易的
@Cacheable
註解就能實作
範例:
@Cacheable("tasks")
public List<Task> getTasksByUser(Long userId) {
return taskRepository.findByUserId(userId);
}
🌐 分散式快取:Redis 登場
當服務需要橫向擴展(scalable)時,我們通常會用像 Redis 這樣的分散式快取來取代記憶體快取:
- ✅ 可以橫向擴展,支援多實例存取
- ✅ 支援快取失效時間(TTL),幫助控制資料新鮮度
- ✅ 可以當成 pub/sub、session 儲存或 ranking 使用(功能很廣)
- 🚧 需要額外部署與監控,也有資料一致性的考量
Redis 是目前業界非常主流的解法,使用上可以搭配 Spring Data Redis
🧩 資料一致性:常見策略
快取的核心價值在於加速存取,但這樣就會出現一個問題:當資料改變時,要怎麼讓快取也同步更新?
這是所謂的 cache invalidation 問題,而以下是幾種常見做法:
名稱 | 說明 | 優點 | 缺點 |
---|---|---|---|
Cache Aside | 先查快取,沒有再查 DB 並寫入快取 | 常見簡單,控制權完整 | 可能會有 cache miss 高峰 |
Read/Write Through | 寫入 DB 時也同時寫入快取 | 快取資料一定是最新的 | 寫入操作會變慢 |
Write Behind Cache | 寫入快取,可根據不同策略決定寫入 DB 的時機 | 高效能寫入 | 有資料遺失風險 |
在 Spring 中也可以使用 @CacheEvict
搭配 CRUD 操作來維護快取的一致性,例如:
@CacheEvict(value = "userTasks", key = "#userId")
public void completeTask(Long userId, Long taskId) {
taskRepository.markComplete(taskId);
}
🧨 快取的潛在問題
雖然快取帶來了效能提升,但也伴隨著一些常見陷阱。如果沒注意設計,反而可能讓系統更脆弱。以下是幾個業界常見的快取問題與對應策略 🔧:
🚫 Cache Penetration
這代表快取查不到資料,結果還是要回去查資料庫,等於快取白設了。常見情境有兩種:
查詢的資料本來就不存在(惡意請求或髒資料)
- 解法 1:對常被查但不存在的 Key 設一個預設空值,避免每次都回 DB
- 解法 2:使用 Bloom Filter 預先過濾不存在的 Key,直接拒絕請求
快取資料太大,資源吃緊導致經常 miss
- 解法:僅快取熱門資料,並分頁載入;避免整包塞進快取造成浪費
⏰ Cache Avalanche
當所有快取的過期時間都一樣時,一到時間點所有快取同時失效,大量請求湧入資料庫造成崩潰。
- 解法:設定快取時加入「隨機延遲時間」,避免同時過期的情況發生
🧱 Cache Breakdown
當某一筆熱門資料剛好過期,短時間內大量請求同時查詢,瞬間灌爆資料庫。
- 解法 1:加鎖更新快取,只有一個 thread 可以重新查詢 DB,其他等候。分佈式系統可用 Zookeeper
- 解法 2:後台更新快取,例如透過 background thread 或 queue 通知更新,主線程不阻塞
🔥 Hotspot Cache(熱點快取)
某些資料太熱門(例如首頁、熱門文章),全部人都查同一筆資料,導致單一快取節點壓力爆表。
- 解法:複製多份快取副本分散到多個節點
✅ 注意副本之間設定不同的過期時間,否則同時失效又會造成雪崩
下一篇,我會從這篇延伸一個很常見但又容易被忽略的問題 ——
👥 多用戶同時操作時,如何避免資料競爭與不一致的情況?我們該加鎖嗎?