電腦科學只有兩件難事:快取失效,與命名。 — 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 協商快取是「快取久」與「能驗證」之間的折衷。