Workers Cache API:邊緣快取完整實作 | Cloudflare 完整教學

2026/08/06
Workers Cache API:邊緣快取完整實作 | Cloudflare 完整教學

Cloudflare Workers 中,最迷人的效能武器之一就是 Cache API——它讓你把運算結果直接快取在離使用者最近的邊緣節點上,下一次同樣的請求進來,連運算和回源都省了,直接從邊緣秒回。這一篇我們深入 caches.default 的三個核心方法、快取鍵怎麼設計、TTL 怎麼用 Cache-Control 控制,以及為什麼 ctx.waitUntil 是「非同步寫快取」的最佳拍檔。

前言

上一篇《Request 與 Response》我們把 fetch handler 手上的兩張牌拆開來看——請求怎麼讀、回應怎麼構造、body 為什麼只能讀一次、ctx.waitUntil 怎麼在回應後做背景工作。文章結尾我留了一個問題:如果同一個請求每次都要重新運算或重新抓資料,不是很浪費嗎? 這一篇就是這個問題的答案。

先給一句話定義:Cache API 是 Workers 提供的一組讓你在邊緣節點「命令式」讀寫快取的介面,透過全域的 caches.default 物件,用 cache.match(讀)、cache.put(寫)、cache.delete(刪)三個方法,把 Response 以 Request 為鍵存進離使用者最近的資料中心。 關鍵字是「命令式」與「邊緣」:你不是設一條規則讓 Cloudflare 自動處理,而是在程式碼裡明確決定什麼要快取、快取多久、命中了怎麼回。

打個比方:邊緣快取就像便利商店的冷藏櫃。 總倉庫(你的 Origin 或運算邏輯)在很遠的地方,每次都跑去總倉庫拿貨又慢又累。於是你在每個街角的便利商店(邊緣節點)擺一個冷藏櫃,第一個客人來要某樣商品時,店員跑一趟總倉庫拿回來、順手放一份到冷藏櫃(cache.put);之後同一條街的客人再來要同樣的東西,店員直接從冷藏櫃拿(cache.match),秒給。冷藏櫃有保鮮期(TTL),過期就丟掉重拿;而且每家分店的冷藏櫃是各自獨立的——台北店放的貨,東京店的櫃子裡沒有。這個「各自獨立」正是理解 Cache API 最重要的一件事。

讀完本篇,你會掌握:

  • caches.default 與三大方法——match 讀取、put 寫入、delete 刪除,以及各自的行為與限制
  • 快取優先(cache-first)模式——標準的「先查快取,未命中才運算並寫回」寫法,搭配 ctx.waitUntil 不阻擋回應
  • 快取鍵設計與 TTL 控制——用自訂 URL 當快取鍵、用 Cache-Control 設定邊緣 TTL
  • 快取失效與 per-datacenter 特性——cache.delete 只清本地節點,全球清除要用 Purge API
  • Cache API 與 Cloudflare CDN 快取的關係——什麼交給 CDN 自動處理、什麼該自己用 Cache API 寫

核心概念

caches.default:邊緣快取的入口

Workers 提供一個全域物件 caches,你有兩種方式取得快取實例:

  • caches.default:預設快取,最常用,直接拿來就能 match / put / delete。它和 Cloudflare CDN 使用的是同一份底層邊緣快取。
  • caches.open("my-namespace"):開一個具名的獨立快取空間(回傳 Promise),適合你想把不同用途的快取隔離開來時使用。

三個核心方法對照如下:

方法行為重點限制
cache.match(request, options?)依 request(快取鍵)取出快取的 Response未命中回傳 undefined(不是 null)
cache.put(request, response)把 response 以 request 為鍵存入快取快取鍵只接受 GET 方法;response 需可快取
cache.delete(request)刪除該鍵的快取只清除本地節點,非全球

這裡有兩個必須刻進記憶的規則:

其一,cache.put 只能用 GET 當快取鍵。 你不能快取一個 POST / PUT / DELETE 請求的回應——如果傳進去的 request.method 不是 GET,put 會直接拋錯。原因很直觀:POST 這類方法本質上代表「會改變狀態的操作」,快取它沒有意義。如果你想快取的邏輯其實是由 POST 觸發,做法是另外構造一個 GET 的 Request 當快取鍵(下面實作範例會示範)。

其二,快取是 per-datacenter(每個資料中心各自獨立)。 你在台北節點寫入的快取,只存在台北;請求打到東京節點時是一份全新的空快取。這代表全球第一批打到各節點的請求都會「未命中」,之後才逐漸暖起來。也因此,Cache API 絕不能當可靠儲存——讀不到就重算,這是它的正常行為,不是 bug。

什麼樣的 Response 可以被快取

cache.put 不是什麼都收。要能寫進邊緣快取,Response 需滿足幾個條件(否則 put 會被忽略或拋錯):

  • 對應的請求方法是 GET(如上)。
  • 狀態碼可快取:200、301、404 這類通常可以;206 Partial Content 不行。
  • 不能帶 Set-Cookie 標頭:帶了 Cloudflare 會拒絕快取(避免把某使用者的 cookie 快取給別人)。若你確定安全,可以在 put 前把 Set-Cookie 移除。
  • 不能是 Cache-Control: no-storeprivate:這兩個明確表示「別存」。

TTL(存活時間)主要由 Response 的 Cache-Control 標頭決定。你在 put 進去的 Response 上設定 Cache-Control: max-age=3600,這份快取就會在該節點存活約一小時,之後 match 會視為過期而回傳 undefined(實際淘汰也受節點資源與 LRU 影響)。這就是「用 Cache-Control 控制邊緣 TTL」的核心機制。

快取鍵(Cache Key):命中與否的關鍵

cache.matchcache.put 都以「Request」作為鍵。預設情況下,這個鍵是完整的請求 URL(含 query string)。這帶來兩個設計重點:

  • query 不同 = 不同的快取項:/data?page=1/data?page=2 是兩份獨立快取,符合直覺。
  • 快取鍵絕對不要包含使用者專屬資料:如果你把使用者 token、session id 之類的東西塞進快取鍵(或更糟,快取了帶有個人資料的回應卻用了太寬鬆的鍵),就可能發生「A 使用者的私密回應被 B 使用者命中」的資安災難。快取鍵要能穩定對應到「對所有人都一樣」的內容。

你可以透過構造一個「正規化」的 URL 當快取鍵,來精確控制命中邏輯——例如刻意忽略不影響內容的追蹤參數(utm_source 等),讓 /article?id=5&utm_source=fb/article?id=5 命中同一份快取。這在實作範例會示範。

實作範例

概念講完,我們把零件組成一個可執行的邊緣快取 Worker。它示範最標準的「快取優先」模式:先查快取,命中就秒回;未命中才做耗時運算(這裡用一次回源 fetch 模擬),然後用 ctx.waitUntil 在背景把結果寫回快取。

快取優先(cache-first)的完整範例

// src/index.ts —— 標準的邊緣快取優先模式
export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    // ① 只快取 GET;其他方法直接放行不快取
    if (request.method !== "GET") {
      return fetch(request);
    }

    const cache = caches.default; // 取得預設邊緣快取

    // ② 建立「正規化」的快取鍵:忽略追蹤參數,讓命中更穩定
    const cacheKey = buildCacheKey(request);

    // ③ 先查快取
    let response = await cache.match(cacheKey);
    if (response) {
      // 命中:直接回傳,順手標記一下方便觀察(可省略)
      const hit = new Response(response.body, response);
      hit.headers.set("X-Cache", "HIT");
      return hit;
    }

    // ④ 未命中:做耗時工作(這裡用回源 fetch 模擬一次昂貴運算)
    response = await fetch("https://origin.example.com/api/data");

    // ⑤ 只快取成功的回應,並設定邊緣 TTL(Cache-Control)
    if (response.ok) {
      response = new Response(response.body, response);
      response.headers.set("Cache-Control", "public, max-age=3600"); // 邊緣存 1 小時
      response.headers.set("X-Cache", "MISS");

      // ⑥ 用 waitUntil 在背景寫快取,不阻擋回應;務必 clone
      ctx.waitUntil(cache.put(cacheKey, response.clone()));
    }

    return response;
  },
} satisfies ExportedHandler<Env>;

// 自訂快取鍵:以 pathname 為主,剝除不影響內容的追蹤參數
function buildCacheKey(request: Request): Request {
  const url = new URL(request.url);
  // 移除追蹤參數,讓 ?id=5&utm_source=fb 與 ?id=5 命中同一份快取
  url.searchParams.delete("utm_source");
  url.searchParams.delete("utm_medium");
  url.searchParams.delete("fbclid");
  // 快取鍵必須是 GET Request
  return new Request(url.toString(), { method: "GET" });
}

這段程式碼有幾個關鍵細節值得展開:

  • 步驟 ③ 的 cache.match:未命中時回傳的是 undefined,所以用 if (response) 判斷即可。
  • 步驟 ⑥ 的 response.clone():這是最容易被忽略卻最致命的一行。Response 的 body 是只能讀一次的串流,你要「同時」把它回給使用者、又寫進快取,就必須複製一份。少了 clone()put 和回傳兩邊會搶同一個 body,其中一邊會爆 body has already been used
  • 步驟 ⑥ 的 ctx.waitUntil:讓 cache.put 這個 Promise 在回應送出後繼續於背景跑完。若你直接 return 而不 waitUntil,這個還沒完成的寫入很可能在 Worker 回收時被中斷,快取根本沒寫進去。

由 POST 觸發、卻要快取的情境

有時「觸發運算」的是一個 POST 請求(例如帶了一段查詢條件的搜尋),但你希望把「相同條件」的結果快取起來。做法是根據 POST 內容算出一個穩定的 GET 快取鍵:

// 把 POST body 的查詢條件轉成一個穩定的 GET 快取鍵
async function cacheKeyFromPost(request: Request): Promise<Request> {
  const body = await request.clone().json<{ query: string; lang: string }>();
  // 用查詢條件組出一個正規化的虛擬 URL 當快取鍵
  const key = new URL("https://cache.internal/search");
  key.searchParams.set("q", body.query);
  key.searchParams.set("lang", body.lang);
  // 注意:快取鍵一定要是 GET
  return new Request(key.toString(), { method: "GET" });
}

重點:快取鍵用的是一個虛擬 URLhttps://cache.internal/...),它不需要真的存在,只是拿來當作「內容的唯一識別」。相同的查詢條件會算出相同的鍵,於是命中同一份快取。

主動使快取失效(cache.delete)

當底層資料更新了,你想讓某個快取立刻失效,用 cache.delete:

// 例如:某文章被編輯後,刪掉它的快取(僅限本地節點)
async function invalidateArticle(articleId: string): Promise<void> {
  const cache = caches.default;
  const key = new Request(`https://origin.example.com/api/article?id=${articleId}`, {
    method: "GET",
  });
  await cache.delete(key); // 回傳 boolean:true 表示有刪到東西
}

但這裡有個巨大的陷阱(下一節詳述):cache.delete 只清除當前這個邊緣節點的快取,其他節點該筆快取依然存在,要等它們各自的 TTL 到期。真正的全球清除,得靠 Cloudflare 的 Purge API,不在 Worker 程式碼範圍內。

常見錯誤與最佳實踐

坑一:用非 GET 方法(或帶 Set-Cookie 的回應)呼叫 cache.put,寫入被拒。

cache.put 只接受 GET 方法的快取鍵,且拒絕帶 Set-CookieCache-Control: no-store/private 的回應。新手常直接把一個 POST 的 request 拿去 put,結果拋錯或靜默失敗,然後困惑「為什麼永遠 MISS」。正確做法:快取鍵一律用 GET Request(需要時另外構造一個虛擬 GET URL,如上面 cacheKeyFromPost);put 前檢查並移除 Set-Cookie——response = new Response(response.body, response); response.headers.delete("Set-Cookie"); 之後再存。

坑二:快取鍵包含使用者專屬資料,導致「A 的私密回應被 B 命中」。

這是最危險的一種錯誤。如果你快取了一個含個人資料的回應,卻用了一個「多人共用」的快取鍵(例如只用 /profile 當鍵,沒區分使用者),那麼第一個使用者的私密資料就會被快取,接著命中給所有後續使用者——這是嚴重的資料外洩。正確做法:快取鍵要能穩定對應「對所有人都相同」的內容;個人化、需登入、帶 Authorization 或 cookie 的回應根本不該進共用快取。若真要快取個人化內容,快取鍵必須明確納入使用者識別(且要極度小心),或改用其他儲存機制。判斷原則很簡單:這份回應能不能安全地給下一個陌生人看?不能,就別快取。

坑三:忘記 ctx.waitUntil,或 put 時沒 clone()

兩個相關的高頻錯誤。第一,cache.put(...) 是 Promise,若你既不 await 也不 waitUntil 就直接 return,Worker 一回收,這個 floating promise 就被丟棄,快取沒寫成,於是「明明有寫 put 卻永遠 MISS」。第二,put 傳入的 Response 若沒 clone(),會和「回傳給使用者的 Response」搶同一個只能讀一次的 body,導致 body has already been used正確做法:固定寫成 ctx.waitUntil(cache.put(cacheKey, response.clone()));——waitUntil 保證背景寫完不阻擋回應,clone() 保證兩邊各有獨立 body。

額外提醒:cache.delete 只清本地節點,全球失效要靠 TTL 或 Purge API。 別以為呼叫一次 cache.delete 就全球生效。它只影響當前節點。要讓內容在全球快速一致,實務上的策略有二:其一,縮短 TTL——用 Cache-Control: max-age 設一個你能接受的過期時間,讓快取自然汰換;其二,版本化快取鍵——資料更新時改變快取鍵(例如在 URL 帶一個 ?v=時間戳 或內容雜湊),新請求打到新鍵、舊快取自然被冷落淘汰。需要「立即全球清除」時,才動用 Cloudflare 的 Cache Purge API(Dashboard / API 層級操作,非 Worker 內)。

關於 Cache API 與 Cloudflare CDN 快取的關係。 這是最多人搞混的點。Cloudflare 的 CDN 快取是宣告式的:你設好規則(副檔名、Cache-Control、Cache Rules),Cloudflare 自動幫靜態資源快取,你不寫程式;而 Cache API(caches.default)是命令式的:你在 Worker 裡明確決定快取什麼。兩者共用同一份底層邊緣快取儲存,但用途分工明確:靜態檔案(圖片、CSS、JS)交給 CDN 規則自動處理最省事;需要快取「動態運算後的結果」——例如組合多個 API 的回應、個人化前的通用內容——才用 Cache API 自己寫。另外若你只是想控制「子請求(fetch 到別的來源)」的快取,其實不必動用 Cache API,直接在 fetchcf 選項用 cacheEverything / cacheTtl 更簡單:

// 用 fetch 的 cf 選項控制子請求快取(比手動 Cache API 更簡潔的場景)
const res = await fetch("https://api.example.com/data", {
  cf: {
    cacheEverything: true,               // 強制快取此回應
    cacheTtl: 3600,                      // 邊緣 TTL 一小時
    cacheTtlByStatus: { "200-299": 3600, "404": 60, "500-599": 0 }, // 依狀態碼分別設定
  },
});

小結

上一篇《Request 與 Response》我們把 Worker 每天處理的兩個核心物件拆開來看——請求怎麼讀、回應怎麼構造、ctx.waitUntil 怎麼做背景工作。這一篇,我們把「請求與回應」推進到「讓它們跑得更快」的層次,深入 Workers 的邊緣快取:

  • caches.default 與三大方法——match 讀(未命中回 undefined)、put 寫(快取鍵只接受 GET)、delete 刪(只清本地節點)。
  • 快取優先模式——先 match,命中秒回;未命中才運算,再用 ctx.waitUntil(cache.put(key, response.clone())) 在背景寫快取,不阻擋回應。
  • 快取鍵與 TTL——快取鍵預設是完整 URL,可正規化以穩定命中;TTL 由 Response 的 Cache-Control: max-age 控制。
  • per-datacenter 特性——快取每個資料中心各自獨立,讀不到就重算是正常行為;cache.delete 只清本地,全球失效靠 TTL、版本化快取鍵或 Purge API。
  • 與 CDN 快取的分工——靜態資源交給 CDN 宣告式規則;動態運算結果才用 Cache API 命令式快取;控制子請求快取可用 fetchcf.cacheEverything

掌握了 Cache API,你的 Worker 就從「每次都重算」進化成「離使用者最近的地方秒回」。但你可能已經注意到:前面我們一直很小心地處理 body、clone()、只能讀一次——這背後其實是 Workers 的另一個核心機制在支撐:串流(Streams)。當你要處理很大的回應、邊收邊送、或即時轉換內容而不把整份資料塞進記憶體時,就得靠串流。下一篇《Streams 串流處理》,我們會深入 ReadableStream / WritableStream / TransformStream,看 Workers 如何用串流在 128 MB 記憶體上限內處理遠大於此的資料。快取讓重複請求變快,串流則讓「大」與「即時」變得可能。

想深入官方 Cache API 細節,可以隨時參考 Cloudflare 官方 Cache API 文件。準備好讓你的 Worker 用邊緣快取飛起來了嗎?我們下一篇《Streams 串流處理》見。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →