Cache-aside 是你最常使用的快取模式,可能也是你已經在使用但還不知道它名稱的模式。應用程式會先檢查快取,如果未命中,就從資料庫載入資料並自行填入快取。它簡單易懂、讓應用程式掌握控制權,而且當快取無法使用時仍能正常降級運作。充分理解這個模式(包括它可能引發的兩個微妙問題)是學習本模組其他快取模式的基礎。

這是 Redis 大師班的第 9 部分,也是快取模組的開端,接續前一篇 金鑰過期與 TTL

模式說明

Cache-aside(也稱為延遲載入)由應用程式直接管理快取。讀取時的流程如下:

  1. 先在 Redis 中檢查該金鑰。
  2. 如果存在(命中),就直接回傳。
  3. 如果不存在(未命中),就從資料庫讀取、將結果寫入 Redis 並設定 TTL,最後再回傳。
function getUser(id) {
  const cached = await redis.get(`user:${id}`);
  if (cached) return JSON.parse(cached);         // hit

  const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);  // miss
  await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 300);  // populate, 5-min TTL
  return user;
}

Enter fullscreen mode Exit fullscreen mode

這就是整個模式的運作方式。快取是延遲填充的,只會儲存真正被請求的資料,因此不會快取沒人讀取的內容。它也具有容錯性:如果 Redis 當機,每個請求都會變成「未命中」,直接落到資料庫,應用程式仍能繼續運作(速度較慢,但能正常運作)。這種優雅降級是 cache-aside 成為預設模式的關鍵原因。

寫入:失效而非更新

在寫入方面,cache-aside 的標準做法是更新資料庫後,再讓快取條目失效,而不是直接更新快取:

function updateUser(id, data) {
  await db.query('UPDATE users SET ... WHERE id = $1', [id]);
  await redis.del(`user:${id}`);   // invalidate; next read re-populates
}

Enter fullscreen mode Exit fullscreen mode

刪除金鑰代表下一次讀取時會發生未命中,並從最新的資料庫值重新填充快取。這種做法比直接把新值寫入快取更簡單且安全,因為它避免了舊寫入在更新後仍可能晚一步寫入快取的競態條件。寫入時刪除金鑰是 cache-aside 讀取的傳統搭配方式。

必須知道的兩個問題

Cache-aside 雖然簡單,但容易引發兩個眾所周知的問題。

過期快取競態。 假設:請求 A 讀取時未命中,從資料庫載入舊值。在 A 寫入快取之前,請求 B 更新了資料庫並刪除了(仍為空)的快取金鑰。之後 A 將已過期的值寫入快取,這個值會保留到 TTL 到期為止。雖然這個時間窗很小,但在高負載下確實可能發生。主要防範措施是設定合理的 TTL(讓過期時間有限),以及在正確性要求較高時,採用我們稍後會介紹的更謹慎模式。對大多數快取來說,短 TTL 已足以讓錯誤值快速自我修正。

驚群效應(快取雪崩)。 當一個熱門金鑰過期時,許多並行請求會同時未命中,並同時到資料庫重新填充,導致負載在金鑰最熱門的時刻突然飆升。這很常見,因此稍後在本模組會有專文討論。快速的緩解措施包括在 TTL 中加入抖動,讓金鑰不會同時過期,以及使用鎖定機制,讓只有一個請求重建金鑰,其他請求等待。

選擇快取的內容與時間

兩項決策會影響 cache-aside 的設定。要快取什麼:讀取次數遠多於寫入、且取得成本高的資料(例如跨表聯結的使用者個人資料、計算後的儀表板、很少變動的設定)。快取每次讀取都會變動的資料,或是取得成本很低的資料,只會增加複雜度卻沒有好處。

快取多久(TTL):這是新鮮度與命中率的權衡。短 TTL 能保持資料新鮮,但未命中次數較多;長 TTL 快取效果較好,但提供的資料較舊。應根據資料可接受的過期程度來設定:快速變動的資料用秒級 TTL,穩定的資料可用分鐘或小時級 TTL。當需要精確的新鮮度時,可搭配較長的 TTL 與寫入時的明確失效,讓快取在資料變動時立即更新,而 TTL 僅作為備援機制。

為什麼 cache-aside 是預設模式

Cache-aside 的優勢在於簡單且具容錯性。應用程式擁有快取邏輯,因此不需要額外的快取基礎設施。它只會快取被使用的資料。即使快取發生故障,也能透過資料庫繼續運作。它也能與其他模式組合:read-through、write-through 等模式,基本上就是把快取邏輯移到不同層級或更複雜化的 cache-aside。

需要內化的模式是:先檢查快取,未命中時載入並填充,寫入時讓快取失效。加上 TTL 作為過期備援,並注意過期寫入競態與驚群效應。這就涵蓋了大多數實際的快取情境,而本模組的其他內容都建立在此基礎上。

接下來,我們會探討 read-through 與 write-through 快取,它們將快取邏輯從應用程式移到快取層,並探討這會如何改變權衡。

重點摘要

  • Cache-aside:先檢查 Redis,未命中時從資料庫載入、用 TTL 填入快取,然後回傳。
  • 它會延遲填充(只快取被請求的資料),且具優雅降級能力,因為快取故障只會變成未命中並落到資料庫。
  • 寫入時更新資料庫並刪除快取金鑰,讓下次讀取重新填充,而不是直接寫入新值。
  • 注意兩個問題:過期寫入競態(由 TTL 限制)和熱門金鑰過期時的快取雪崩。
  • 快取讀取頻繁且取得成本高的資料,並根據資料可接受的過期程度設定 TTL。

常見問題

什麼是 cache-aside 模式?

一種快取方法:應用程式先檢查快取,未命中時從資料庫載入資料、以 TTL 存入快取,然後回傳。應用程式直接管理快取,只會延遲填充被請求的資料。

寫入時應該更新還是刪除快取?

刪除(失效)快取金鑰,讓下次讀取時從資料庫重新填充。刪除可避免直接更新快取可能造成的競態條件,即舊值在更新後仍可能晚一步寫入快取。

如果 Redis 當機,cache-aside 會發生什麼事?

應用程式仍能繼續運作。每個讀取都會變成快取未命中並落到資料庫,因此回應速度變慢但仍正確。這種優雅降級是 cache-aside 的一大優勢。

什麼是快取雪崩?

當一個熱門的快取金鑰過期時,許多並行請求同時未命中,並同時到資料庫重建,導致負載突然飆升。可透過在 TTL 中加入抖動讓金鑰不同時過期,以及使用鎖定機制讓只有一個請求重建金鑰來緩解。

如何選擇快取 TTL?

根據資料可接受的過期程度,在新鮮度與命中率之間取得平衡:快速變動的資料用短 TTL,穩定的資料用較長 TTL。當需要精確的新鮮度時,可搭配較長的 TTL 與寫入時的明確失效,讓快取在資料變動時立即更新。

延伸閱讀


This article was originally published on amanksingh.com/blog/redis-cache-aside.

關於作者

Aman Kumar Singh 是一位位於印度諾伊達的團隊主管與資深軟體工程師,專注於全端工程、系統設計,以及使用 TypeScript、Next.js、NestJS、PostgreSQL 和 Redis 開發生產級 SaaS。