sell整合實戰
bolt

用 KV 在資料庫前面加一層快取

先問 KV,沒有才查資料庫,查完順手把答案加上存活時間(TTL)快取起來。

100K每日免費 KV 讀取
~0ms邊緣快取讀取
300s範例 TTL
2串接的儲存 (KV + D1)
insights

我們要做什麼?

我們要在資料庫前面加一層快取。Worker 會先去問一個很快的鍵值資料庫(Workers KV);只有在找不到值的時候,才去查比較慢的資料庫(D1),並把查到的答案存回 KV 供下次使用。這個做法叫做「cache-aside(旁路快取)」。

對於「讀多寫少」的資料——同一個商品頁、同一份設定、同一個個人檔案被讀取上千次——這個做法效益巨大:大多數請求根本不會打到資料庫,因此更快也更省錢。

local_cafe

把它想成…

咖啡師在櫃台上放了一壺已經煮好的咖啡(KV)。壺裡有咖啡,你馬上就有一杯。只有當壺空了,他們才去用機器重新煮一壺(D1)——然後把壺再裝滿,讓接下來十位客人又能立刻拿到。

schemaCache-aside 讀取流程

有 (命中)

沒有 (未命中)

使用者請求

Worker

KV 有快取嗎?

把結果回傳給使用者

查詢 D1 資料庫

把結果寫入 KV (TTL 300 秒)

account_tree

每一層各做什麼?

三個角色一起合作。Worker 是負責決策的大腦;KV 是快速的快取;D1 則是「真相來源(source of truth)」,永遠保存正確、最新的資料。

psychology

Worker - 指揮者

接收請求、先查 KV、決定要不要去打 D1,並把結果寫回快取。

bolt

KV - 快取層

在每位使用者附近保存近期答案的副本。讀取幾乎瞬間完成;每筆資料會依 TTL 自動過期。

database

D1 - 真相來源

權威的 SQL 資料庫。每次讀取較慢、較貴,但永遠正確——只有在快取未命中時才會被查詢。

key

快取鍵 (key)

像 product:42 這種穩定的名稱,把一筆資料列對應到一筆 KV 資料。相同輸入就對應到相同的 key。

swap_vert

命中與未命中,並排比較

對某個 key 的第一次請求是「未命中(miss)」:Worker 必須一路走到 D1 再走回來,然後把快取填好。之後的每一次請求(在 TTL 過期前)都是「命中(hit)」:直接從 KV 回傳,完全不碰 D1。

schema未命中 vs 命中的往返
D1"KV 快取"Worker使用者D1"KV 快取"Worker使用者第一次請求 - 未命中下一次請求 - 命中GET /api/product/42get product:42空的, 未命中查詢 product 42資料列put product:42 設定 TTL 300JSON, 慢路徑GET /api/product/42get product:42快取的 JSONJSON, 快路徑
trending_down

為什麼這樣能省這麼多

如果 95% 的請求都命中,你的資料庫讀取量大約只剩 1/20。D1 讀取變少,代表帳單更低、而且幾乎每個人的延遲都更短。

construction

動手做:KV + D1 + Worker

建立兩個儲存、綁定它們、塞一筆資料,然後寫 cache-aside 邏輯。前端只負責呼叫你的 API——它完全不知道背後有快取。

  1. 建立 KV 命名空間與 D1 資料庫

    每個指令都會印出一組 id——下一步把它們貼進 wrangler.jsonc。

    bash
    npx wrangler kv namespace create CACHE
    npx wrangler d1 create shop
  2. 把兩者綁定到 Worker

    這些綁定讓兩個儲存在程式碼裡能用 env.CACHE(KV)和 env.DB(D1)取用。

    json
    {
      "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>"
        }
      ]
    }
  3. 建立資料表並塞一筆資料

    D1 是真相來源。執行一次這段 SQL,讓快取有東西可以載入。

    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);
  4. 寫 cache-aside 讀取

    先用 get(key, "json") 問 KV。命中就立刻回傳;未命中就查 D1,再用 300 秒的 TTL 把結果 put 回 KV。

    js
    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 });
      },
    };
  5. 寫入時讓快取失效

    當資料變動時,先更新 D1,再 delete(key)。快取副本就此消失,所以下一次讀取會未命中,並用最新資料重新填回快取。

    js
    // 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}`);
    }
  6. 在前端呼叫它

    瀏覽器只是 fetch 你的 API。快取完全是伺服器端的事。

    js
    // 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;
    }
schema寫入路徑:先更新、再失效

用戶端寫入請求

Worker

在 D1 更新資料

刪除對應的 KV key

回覆 OK 給用戶端

下次讀取會未命中並重新抓最新資料

school

重點概念

layers

Cache-aside(旁路快取 / 延遲載入)

由應用程式的程式碼(而不是快取自己)負責載入:先讀快取,未命中就讀資料庫,再把快取填好。快取在被需要之前都不插手。

timer

TTL(存活時間)

expirationTtl: 300 代表這筆資料 300 秒後自動刪除。TTL 越短,資料越新鮮;TTL 越長,越省資料庫讀取。它就是你要調整的旋鈕。

compare_arrows

命中 vs 未命中

命中 = 值就在 KV 裡(快、不碰資料庫)。未命中 = 不在,所以要付一次完整的資料庫往返,然後把它快取起來。「命中率」是你要盯的指標。

schedule

最終一致性

KV 的寫入最多要約 60 秒才會散布全球,而且快取的值在 TTL 過期前可能是舊的。讀取者最終會看到新值,但不是立刻。

delete_sweep

失效處理

每次寫入時刪除(或覆寫)對應的 key,能讓快取維持誠實。經典的難題是:要記得某筆資料被快取在哪些地方。

menu_book

真相來源

D1 是權威;KV 是可丟棄的副本。若 KV 與 D1 不一致,以 D1 為準——所以刪掉某筆 KV 再重建永遠是安全的。

tips_and_updates

陷阱與小提示

warning

讀到舊資料是快取的代價

在「寫入」與「刪除快取」之間(或 TTL 還沒過期前),有些使用者可能看到舊資料。對於必須處處精準的東西——結帳價格、庫存數量、帳戶餘額——請用很短的 TTL、寫入時主動失效,或直接讀 D1。

  • 依「能容忍多舊」來選 TTL:價格用秒、商品頁用分鐘、很少變的設定用小時。
  • 寫入時務必失效(刪除 key),否則讀取者可能整段 TTL 都看到舊資料。
  • 只快取真的常被讀取的東西——快取一次性的查詢只是白白浪費寫入次數。
  • 絕不要把每位使用者的私密資料存在共用 key 下;把使用者 id 放進 key,例如 cart:{userId}。
  • 盯著命中率看。命中率太低代表 TTL 太短,或 key 太細而無法被重複使用。
  • 「未命中風暴」(冷門 key 同時湧入大量未命中)可能短暫衝擊 D1——讓資料庫查詢保持便宜並建好索引。
info

KV 天生就適合做這件事

Workers KV 是為讀取最佳化、且全球複寫的,這正是快取想要的特性。它的弱點——寫入要時間才會擴散——在這裡幾乎沒差,因為快取本來就允許稍微落後。