電腦科學只有兩件難事:快取失效,與命名。 — Phil Karlton
前面幾章講了各種快取「怎麼存」(HTTP Caching、Service Worker、SWR)。本章換個角度,談快取最棘手的部分:怎麼讓快取與真實資料保持一致。這個問題的本質與分散式系統的資料複製如出一轍,借用那套框架能讓前端的快取決策更有依據。
為什麼快取失效這麼難#
快取的本質,是把「同一份資料」複製到離使用者更近的地方。一旦有多份副本,就會面臨經典難題:來源更新了,各個副本怎麼同步?
前端的資料其實散落在多層快取中:
flowchart LR
Origin[(來源 Server)] --> CDN[CDN 邊緣節點]
CDN --> HTTP[瀏覽器 HTTP Cache]
HTTP --> SW[Service Worker Cache]
SW --> Mem[記憶體中的應用狀態]
Mem --> UI[畫面]每一層都是來源的一份「延遲的副本」。當來源改變,這個變更要逐層傳遞,而傳遞是非同步的——這正是「快取失效難」的根源:你無法瞬間、可靠地通知所有副本「你手上的資料過期了」。
快取的核心取捨是一致性 vs. 效能:副本離使用者越近、活得越久,速度越快、但越容易與來源不一致。所有快取策略,本質上都是在這條軸上選一個位置。
一致性模型#
分散式系統用「一致性模型」描述副本能提供多強的保證。把它套到前端快取,能精準描述使用者會遇到的怪現象:
| 一致性等級 | 保證 | 前端對應情境 |
|---|---|---|
| 強一致 | 永遠讀到最新值 | 庫存數量、餘額——通常得繞過快取直接打 server |
| 讀己之寫 | 至少讀得到「自己剛寫的」 | 使用者改了個人資料後,自己看必須是新的 |
| 單調讀 | 不會「時光倒流」讀到更舊的值 | 重新整理後,已看到的留言不該又消失 |
| 最終一致 | 一段時間後終會一致,期間可能舊 | CDN 上的文章、SWR 先給舊的再背景更新 |
「讀己之寫」是前端最常踩的雷。 使用者送出表單後立刻重新讀取,卻因為快取或讀寫分離拿到舊資料,以為「沒存到」。常見解法:寫入成功後主動使對應快取失效,或樂觀更新本地狀態。
SWR 其實是「最終一致」的選擇#
Stale-While-Revalidate 先回傳舊副本、同時在背景更新——用一致性的語言說,這就是明確選擇了最終一致:以「短暫看到舊資料」換取「立即有畫面」。
max-age=60, stale-while-revalidate=300
└──────┬─────┘ └────────────┬────────────┘
60s 內視為新鮮 接下來 300s 內:先給舊的、背景更新設定這些秒數,本質上就是在宣告「我能容忍多久的不一致」。資料越敏感(如價格),容忍度越低、max-age 越短甚至不快取。
多層快取的副作用#
層數越多越快,但失效越難同步——因為要逐層擊穿。實務上有幾個必須認識的現象:
| 現象 | 說明 | 緩解 |
|---|---|---|
| 快取穿透 Penetration | 大量請求查詢「根本不存在」的資料,每次都穿透到來源 | 對「查無結果」也快取一個空值 |
| 快取擊穿 / 驚群 Stampede | 熱門資料一過期,瞬間大量請求同時湧向來源 | 單飛(single-flight):同一鍵只發一個請求 |
| 快取雪崩 Avalanche | 大量快取設定了相同過期時間,同時失效 | 過期時間加隨機抖動,錯開失效 |
多層快取的過期時間要刻意錯開。 若 CDN、HTTP、Service Worker 都設一樣的存活時間,它們會同時失效、同時回源,把多層快取的優勢一次清零。
前端的單飛(single-flight):避免重複請求同一資源
const inflight = new Map();
function dedupedFetch(key, fetcher) {
if (inflight.has(key)) return inflight.get(key); // 已有相同請求進行中,共用它
const promise = fetcher().finally(() => inflight.delete(key));
inflight.set(key, promise);
return promise;
}
// 同一時間多處呼叫 dedupedFetch('/api/user', ...) 只會真正發出一個請求
這正是 SWR、React Query 等資料層函式庫內建去重的核心概念。
ETag:協商快取背後的版本控制#
ETag / Last-Modified 的協商快取,本質是一種輕量的版本檢查:用戶端帶著手上副本的版本號(If-None-Match)去問來源「我這份還算數嗎?」,沒變就回 304 不傳 body。
# 第一次回應
ETag: "v42"
# 之後的請求帶上版本
If-None-Match: "v42"
# 來源若版本未變 → 304 Not Modified(省下傳輸)這讓「快取很久」與「需要時能驗證新鮮度」兼得,是強快取(完全不問)與不快取(每次重抓)之間的折衷。
小結#
- 快取 = 副本,多份副本就有「來源更新如何同步」的難題,且同步是非同步的。
- 用一致性模型描述需求:價格要強一致、文章可最終一致、「讀己之寫」最易出錯。
- SWR /
max-age的秒數,就是在宣告能容忍多久的不一致。 - 多層快取要防穿透、驚群、雪崩;過期時間錯開、請求去重。
ETag協商快取是「快取久」與「能驗證」之間的折衷。