先描述一種很常見的 codebase 長相:快取邏輯到處都是。A service 裡有一段 redis.getJSON.parse,B service 自己 JSON.stringify 塞回去,C 的 TTL 寫 300、D 的寫 600(為什麼不一樣?沒人記得)。key 的命名有人用 user_1、有人用 cache:user:1

這樣的 code 平常都能動。痛是在這些時刻爆發的:

  • 要換快取後端(Redis 換 Memcached、或本地開發想用 in-memory)——你 grep 出兩百處 redis. 呼叫,每一處都要動。
  • 壓測想量「沒有快取時 DB 扛不扛得住」——你發現快取關不掉,因為沒有一個總開關。
  • Redis 某天卡住,全站跟著卡——因為兩百處呼叫沒有一處設 timeout,也沒有一處想過「拿不到快取要怎麼辦」。

病因只有一個:快取的橫切邏輯(序列化、TTL、命名、容錯)沒有自己的家。解法就是給它一層。


這層要收哪些髒活

一個像樣的 CacheService,呼叫端應該只看到這樣:

const user = await userRepo.get(1);   // 呼叫端不知道快取存在

而底下那層把四件事全包了:

一、序列化。 進去一律 JSON.stringify、出來一律 JSON.parse,集中在一個地方做。誰都不准在業務 code 裡手動 stringify——序列化規則(日期怎麼處理、數字精度)要改的時候,只改這一處。

二、TTL 預設值。 set(key, val, ttl = 300)——呼叫端可以覆寫,但不寫就有合理預設。「忘了設 TTL 導致快取永生」這種事故,從結構上變成不可能。

三、key 命名 convention。 前綴、分隔符、版本,這層統一管(例如 ioredis 直接給 keyPrefix)。名字亂掉的 codebase,之後想「清掉某一類快取」會很想哭。

四、容錯——這是最值錢的一件。 連線設 command timeout,快取拿不到就 catch 掉、直接走 DB(fail-open),回填失敗也不影響回傳(best-effort)。核心心法一句話:快取是加速器,不是命脈——它掛了服務要照常出貨,只是慢一點。


Repository 的形狀

讀路徑(cache-aside)和寫路徑,收在資料存取層:

async get(id) {
  try {
    const hit = await cache.get(`user:${id}`);
    if (hit) return hit;                    // 命中,直接回
  } catch { /* 快取掛了?當作沒中,往下走 DB */ }
 
  const user = await db.getUser(id);        // 真相來源
  try { await cache.set(`user:${id}`, user); } catch { /* 回填失敗不影響回傳 */ }
  return user;
}
 
async update(id, patch) {
  await db.update(id, patch);               // 先改 DB(真相)
  try { await cache.del(`user:${id}`); } catch { /* 刪失敗靠 TTL 自癒 */ }
}

兩個容易被挑戰的設計決定,值得講清楚為什麼:

寫路徑是「刪快取」不是「更新快取」。 更新快取你要在寫入端重算一次完整的快取內容,一來邏輯重複、二來兩個寫入端併發時誰蓋誰說不清。刪掉最乾淨:下一個讀的人自然會用 DB 的最新值把它填回來。

所有 cache 操作都包 try/catch,而且 catch 裡幾乎什麼都不做。 看起來像壞味道,其實是這層的本質:快取失敗不是錯誤,是降級。真的要做事,就在 catch 裡記個 metric,讓監控知道降級發生了。


enabled toggle:被低估的一個小開關

給這層一個總開關(環境變數就行):CACHE_ENABLED=false 時,get 永遠 miss、set 直接 no-op。三個場景會感謝它:

  • 壓測:量「裸 DB」的真實容量,答案直接影響你敢不敢做 fail-open。
  • 除錯:懷疑「是不是快取到舊資料」,關掉快取重打一次,一分鐘內排除或坐實。
  • 事故現場:快取層行為詭異時,有一個「一秒退回無快取世界」的逃生門。

沒有抽象層的話,這個開關根本做不出來——這也是「為什麼要收成一層」最實際的理由之一。

這篇有個對照組:我自己壓測平台裡的 core/cache.py,一個不到八十行的 CacheService。跟這篇一致的是 enabled 開關——get 回 None、set/delete 變 no-op,上面說「沒有抽象層這個開關做不出來」,那個檔案就是活證據:壓測要「開快取一輪、關快取一輪」量差距,全靠它。走不同路的地方也攤開講:序列化沒包(string 進出,呼叫端自己 JSON)、空值標記沒有、最要命的是 TTL 是 optional——我的抽象層允許永生 key,跟這篇「回填必設 TTL 是紀律」直接打架。為什麼這樣設計?沒有為什麼,當時就是先做出來再說。這大概就是抽象層的真實生命週期:第一版收的是重複的髒活,紀律是之後一條一條長進去的——「介面強制 ttl 參數」這種預設值,與其說是遠見,不如說是繳過學費的人才想得到。


一句收束:快取的複雜度不會消失,只能選擇「散在兩百處」還是「集中在一層」。 集中的那層不用大——一個 helper 加一個 repository 慣例就夠——但 timeout、fail-open、TTL 預設、總開關這四件事必須在裡面,因為它們是你出事那天唯一會感謝自己的東西。

(快取跟 DB 的一致性、讀寫順序的完整討論,見 Cache 正確用法;stampede 防護見同系列 #18。)

接下來往哪走

  • 四種 Cache Pattern — 抽象層包的讀寫路徑,pattern 本身長這樣
  • TTL 策略 — 介面該不該強制 ttl?先懂 TTL 是設計題不是數字
  • Cache Penetration — 空值標記為什麼值得進抽象層:穿透的完整帳