用 KV 在資料庫前面加一層快取
先問 KV,沒有才查資料庫,查完順手把答案加上存活時間(TTL)快取起來。
我們要做什麼?
我們要在資料庫前面加一層快取。Worker 會先去問一個很快的鍵值資料庫(Workers KV);只有在找不到值的時候,才去查比較慢的資料庫(D1),並把查到的答案存回 KV 供下次使用。這個做法叫做「cache-aside(旁路快取)」。
對於「讀多寫少」的資料——同一個商品頁、同一份設定、同一個個人檔案被讀取上千次——這個做法效益巨大:大多數請求根本不會打到資料庫,因此更快也更省錢。
把它想成…
咖啡師在櫃台上放了一壺已經煮好的咖啡(KV)。壺裡有咖啡,你馬上就有一杯。只有當壺空了,他們才去用機器重新煮一壺(D1)——然後把壺再裝滿,讓接下來十位客人又能立刻拿到。
每一層各做什麼?
三個角色一起合作。Worker 是負責決策的大腦;KV 是快速的快取;D1 則是「真相來源(source of truth)」,永遠保存正確、最新的資料。
Worker - 指揮者
接收請求、先查 KV、決定要不要去打 D1,並把結果寫回快取。
KV - 快取層
在每位使用者附近保存近期答案的副本。讀取幾乎瞬間完成;每筆資料會依 TTL 自動過期。
D1 - 真相來源
權威的 SQL 資料庫。每次讀取較慢、較貴,但永遠正確——只有在快取未命中時才會被查詢。
快取鍵 (key)
像 product:42 這種穩定的名稱,把一筆資料列對應到一筆 KV 資料。相同輸入就對應到相同的 key。
命中與未命中,並排比較
對某個 key 的第一次請求是「未命中(miss)」:Worker 必須一路走到 D1 再走回來,然後把快取填好。之後的每一次請求(在 TTL 過期前)都是「命中(hit)」:直接從 KV 回傳,完全不碰 D1。
為什麼這樣能省這麼多
如果 95% 的請求都命中,你的資料庫讀取量大約只剩 1/20。D1 讀取變少,代表帳單更低、而且幾乎每個人的延遲都更短。
動手做:KV + D1 + Worker
建立兩個儲存、綁定它們、塞一筆資料,然後寫 cache-aside 邏輯。前端只負責呼叫你的 API——它完全不知道背後有快取。
建立 KV 命名空間與 D1 資料庫
每個指令都會印出一組 id——下一步把它們貼進 wrangler.jsonc。
npx wrangler kv namespace create CACHE npx wrangler d1 create shop把兩者綁定到 Worker
這些綁定讓兩個儲存在程式碼裡能用 env.CACHE(KV)和 env.DB(D1)取用。
{ "name": "cache-aside-api", "main": "src/index.js", "compatibility_date": "2025-01-01", "kv_namespaces": [ { "binding": "CACHE", "id": "<your-kv-namespace-id>" } ], "d1_databases": [ { "binding": "DB", "database_name": "shop", "database_id": "<your-d1-database-id>" } ] }建立資料表並塞一筆資料
D1 是真相來源。執行一次這段 SQL,讓快取有東西可以載入。
CREATE TABLE IF NOT EXISTS products ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, price REAL NOT NULL ); INSERT INTO products (id, name, price) VALUES (42, 'Mechanical Keyboard', 89.00);寫 cache-aside 讀取
先用 get(key, "json") 問 KV。命中就立刻回傳;未命中就查 D1,再用 300 秒的 TTL 把結果 put 回 KV。
export default { async fetch(request, env) { const url = new URL(request.url); const id = url.pathname.split("/").pop(); const key = `product:${id}`; // 1) Cache-aside: ask KV first let product = await env.CACHE.get(key, "json"); if (product) { // HIT: return straight away, no database needed return Response.json({ source: "cache", product }); } // 2) MISS: fall back to D1 (the source of truth) const { results } = await env.DB .prepare("SELECT * FROM products WHERE id = ?") .bind(id) .all(); product = results[0] ?? null; if (!product) { return new Response("Not found", { status: 404 }); } // 3) Re-fill the cache with a 5-minute TTL, then return await env.CACHE.put(key, JSON.stringify(product), { expirationTtl: 300, }); return Response.json({ source: "database", product }); }, };寫入時讓快取失效
當資料變動時,先更新 D1,再 delete(key)。快取副本就此消失,所以下一次讀取會未命中,並用最新資料重新填回快取。
// On any write, update D1 first, then invalidate the cached copy. async function updateProduct(env, id, data) { // 1) Write to the source of truth await env.DB .prepare("UPDATE products SET name = ?, price = ? WHERE id = ?") .bind(data.name, data.price, id) .run(); // 2) Invalidate: delete the stale key so the next read repopulates it await env.CACHE.delete(`product:${id}`); }在前端呼叫它
瀏覽器只是 fetch 你的 API。快取完全是伺服器端的事。
// Plain browser fetch - it never talks to KV or D1 directly. async function loadProduct(id) { const res = await fetch(`/api/product/${id}`); const data = await res.json(); // data.source tells you whether it came from "cache" or "database" console.log("served from:", data.source); return data.product; }
重點概念
Cache-aside(旁路快取 / 延遲載入)
由應用程式的程式碼(而不是快取自己)負責載入:先讀快取,未命中就讀資料庫,再把快取填好。快取在被需要之前都不插手。
TTL(存活時間)
expirationTtl: 300 代表這筆資料 300 秒後自動刪除。TTL 越短,資料越新鮮;TTL 越長,越省資料庫讀取。它就是你要調整的旋鈕。
命中 vs 未命中
命中 = 值就在 KV 裡(快、不碰資料庫)。未命中 = 不在,所以要付一次完整的資料庫往返,然後把它快取起來。「命中率」是你要盯的指標。
最終一致性
KV 的寫入最多要約 60 秒才會散布全球,而且快取的值在 TTL 過期前可能是舊的。讀取者最終會看到新值,但不是立刻。
失效處理
每次寫入時刪除(或覆寫)對應的 key,能讓快取維持誠實。經典的難題是:要記得某筆資料被快取在哪些地方。
真相來源
D1 是權威;KV 是可丟棄的副本。若 KV 與 D1 不一致,以 D1 為準——所以刪掉某筆 KV 再重建永遠是安全的。
陷阱與小提示
讀到舊資料是快取的代價
在「寫入」與「刪除快取」之間(或 TTL 還沒過期前),有些使用者可能看到舊資料。對於必須處處精準的東西——結帳價格、庫存數量、帳戶餘額——請用很短的 TTL、寫入時主動失效,或直接讀 D1。
- 依「能容忍多舊」來選 TTL:價格用秒、商品頁用分鐘、很少變的設定用小時。
- 寫入時務必失效(刪除 key),否則讀取者可能整段 TTL 都看到舊資料。
- 只快取真的常被讀取的東西——快取一次性的查詢只是白白浪費寫入次數。
- 絕不要把每位使用者的私密資料存在共用 key 下;把使用者 id 放進 key,例如 cart:{userId}。
- 盯著命中率看。命中率太低代表 TTL 太短,或 key 太細而無法被重複使用。
- 「未命中風暴」(冷門 key 同時湧入大量未命中)可能短暫衝擊 D1——讓資料庫查詢保持便宜並建好索引。
KV 天生就適合做這件事
Workers KV 是為讀取最佳化、且全球複寫的,這正是快取想要的特性。它的弱點——寫入要時間才會擴散——在這裡幾乎沒差,因為快取本來就允許稍微落後。