KV 進階:一致性與快取,把邊緣讀取用得又快又對 | Cloudflare 完整教學

2026/08/17
KV 進階:一致性與快取,把邊緣讀取用得又快又對 | Cloudflare 完整教學

上一篇《KV 鍵值儲存入門》我們刻意在**最終一致性(eventual consistency)**上點到為止。這一篇,我們把它徹底拆開:寫入後究竟多久全球才會同步?為什麼連同一次請求裡讀都可能是舊值?cacheTtl 怎麼調校邊緣快取?expirationexpirationTtl 差在哪?為什麼每個 key 每秒只能寫 1 次,遇到高頻計數該怎麼辦?讀完這篇,你會把 Cloudflare KV 從「會用」升級到「用得又快又對」。

前言

上一篇《KV 鍵值儲存入門》,我們學會了 Workers KV 的資料模型與 get / put / list / delete 四大操作,把資料留在邊緣讀寫。但在正文裡,我們對一個關鍵特性刻意只點到為止——最終一致性(eventual consistency)。我們只說了「寫入後全球最多約 60 秒可見」,但這句話底下藏著一整組會讓新手栽跟頭的細節。

打個比方:如果說 KV 像遍佈全球每家便利商店的販賣機,那入門篇教的是「怎麼補貨、怎麼買」;而這篇進階要講的,是補貨到全球生效的時間差每家分店手上還握著多久的舊庫存資訊(邊緣快取)、以及同一格為什麼不能每秒狂補。這些「時間」與「快取」的細節,正是把 KV 用得又快又對的分水嶺——用對了,你的邊緣應用飛快又省錢;用錯了,你會遇到「明明寫了卻讀不到」「改了設定卻半天不生效」「一跑迴圈就 429」這類難以除錯的鬼故事。

這一篇我們聚焦一致性與效能,不重複入門篇的基礎 API。讀完你會掌握:

  • 最終一致性的真相——寫入傳播延遲(最多約 60 秒)、為什麼同一次請求也讀到舊值、負快取(negative caching)這個隱藏陷阱
  • 兩種「TTL」的差異——expiration / expirationTtl(資料何時消失)vs cacheTtl(讀取快取多久刷新),別再搞混
  • 寫入速率限制與批次策略——每 key 每秒 1 次寫入、429 退避重試、聚合與分片(sharding)
  • 選型邊界——KV 適用場景 vs 什麼時候該果斷改用 D1 或 Durable Objects

核心概念

最終一致性:寫入是怎麼傳播到全球的

要理解 KV 的一致性行為,得先看清它的資料流。KV 的架構可以簡化成「一個中央儲存 + 全球一堆邊緣快取」:

  你的 put ─────────────►  ┌─────────────────┐
  (寫入新值)               │  Cloudflare      │
                          │  中央儲存 (源頭)  │
                          └────────┬─────────┘
                                   │ 非同步傳播 (最多 ~60 秒)
              ┌──────────┬─────────┼──────────┬──────────┐
              ▼          ▼         ▼          ▼          ▼
          ┌───────┐  ┌───────┐ ┌───────┐  ┌───────┐  ┌───────┐
          │ 東京  │  │ 倫敦  │ │ 台北  │  │ 紐約  │  │ 雪梨  │
          │邊緣快取│  │邊緣快取│ │邊緣快取│  │邊緣快取│  │邊緣快取│
          └───┬───┘  └───┬───┘ └───┬───┘  └───┬───┘  └───┬───┘
              ▼          ▼         ▼          ▼          ▼
           你的 get 從「最近的邊緣快取」讀 (熱讀取 < 5ms)

關鍵在於:你的 get 讀的不是中央儲存,而是離你最近的那個邊緣節點的快取副本。 這正是熱讀取能快到 5ms 以下的原因——但也正是最終一致性的根源。當你 put 一個新值:

  1. 新值先寫進 Cloudflare 的中央儲存(源頭)。
  2. 中央儲存再非同步地把新值傳播到全球各邊緣節點的快取。
  3. 這個傳播過程最多約需 60 秒。在這段時間內,不同節點的快取可能還是舊值。

所以「最終一致」的意思是:只要你停止寫入,經過一段時間(最多約 60 秒)後,全球所有節點終究會一致。 但在那之前,讀到舊值是完全正常、且符合設計的行為。

陷阱一:同一次請求裡「寫完立刻讀」也可能是舊的

很多人以為傳播延遲只發生在「跨地區」——「反正我的 Worker 在台北,讀寫都在台北節點,應該同步吧?」錯。

即使在同一個 Worker、同一次請求裡,你先 putget,也可能讀到舊值或 null:

// ❌ 錯誤:寫入後立即讀取,可能拿到舊值或 null
await env.MY_KV.put("key", "新值");
const result = await env.MY_KV.get("key"); // 可能仍是 null 或舊值!

// ✅ 正確:直接使用你剛寫入的本地變數,不要重新 get
const newValue = "新值";
await env.MY_KV.put("key", newValue);
return new Response(newValue); // 用本地變數,不再呼叫 get

原因就是上面那張圖:你的 put 是把值送進中央儲存,而 get 讀的是邊緣快取副本——這兩者之間有傳播與快取的時間差。寫入後永遠直接用你手上的本地變數,這是使用 KV 的第一鐵律。

陷阱二:負快取(negative caching)——連「不存在」也會被快取

這是比上一個更陰險的陷阱。當你 get 一個不存在的 key,KV 不只回傳 null,還會把「這個 key 不存在」這個結果本身快取起來(預設約 60 秒)。這叫負快取(negative caching)

後果是:

// 1. 先查一個還不存在的 key → 回 null,而且「不存在」被快取約 60 秒
const before = await env.MY_KV.get("user:new-123"); // null

// 2. 馬上把它寫進去
await env.MY_KV.put("user:new-123", JSON.stringify({ name: "Alice" }));

// 3. 立刻再讀 → 在負快取過期前,這個節點「仍可能」回傳 null!
const after = await env.MY_KV.get("user:new-123"); // 可能還是 null!

這解釋了一個常見的鬼故事:「我明明剛剛寫進去了,為什麼還是讀不到?」——因為你在寫入之前先查過一次,那次的「不存在」還卡在快取裡。避免的方法很簡單:寫入後不要靠重新 get 來確認,一律用本地變數;真的要讀,也要接受可能有最長約 60 秒的延遲。

讀取快取:cacheTtl 是怎麼運作的

既然每次 get 讀的是邊緣快取,那「這份快取能撐多久」就由 cacheTtl 決定。

cacheTtl 是你在 get 時傳入的參數,單位是秒,最小值 60 秒(預設也是 60 秒)。它的意思是:這次讀取後,允許這個邊緣節點把值快取起來 cacheTtl 秒,在這段時間內同一節點的後續 get 直接回傳快取副本,不用回中央儲存重抓。

// 這個值在這個邊緣節點會被快取 300 秒(5 分鐘)
const config = await env.MY_KV.get("config", { type: "json", cacheTtl: 300 });

cacheTtl 越大:

  • 好處:回中央儲存的次數越少 → 讀取更快、計費的讀取操作次數更少(省錢)。
  • ⚠️ 代價:寫入新值後,已經快取舊值的節點要等 cacheTtl 到期才會刷新 → 放大了最終一致的延遲

所以 cacheTtl 本質是一個**「速度/省錢」 vs 「新鮮度」的旋鈕**:資料越靜態(如很少變的設定檔),cacheTtl 可以設大;資料需要相對即時,就設小。這一節後面「常見錯誤」會給具體的調校建議。

關鍵術語速覽

術語一句話定義
最終一致性(eventual consistency)停止寫入後,全球最多約 60 秒內收斂到一致
傳播延遲(propagation delay)新值從中央儲存同步到各邊緣快取所需的時間(≤ 60 秒)
負快取(negative caching)「key 不存在」這個結果也會被快取約 60 秒
expiration / expirationTtl資料的存活時間,到期 KV 會刪掉整個 key
cacheTtl單次讀取允許邊緣快取的秒數(最小 60 秒)
metadata綁在 key 上的一小段附註(最多 1,024 bytes),list 會一併回傳

實作範例

我們用一組可執行的 TypeScript 範例,把上面每個概念落地。

1. expiration 與 expirationTtl:兩種設定自動過期的方式

KV 原生支援自動過期,你不用自己寫排程清理。有兩種寫法,擇一使用:

// 方式 A:expirationTtl —— 從「現在」起算的相對秒數(最小 60 秒)
// 最直覺,適合「N 秒後過期」的場景,例如 1 小時後過期的 session
await env.MY_KV.put("session:abc", token, {
  expirationTtl: 3600, // 3600 秒後 KV 自動刪除這個 key
});

// 方式 B:expiration —— 絕對過期時間(Unix timestamp,單位秒)
// 適合「在某個明確時間點過期」的場景,例如「今晚午夜過期」
const midnight = new Date();
midnight.setHours(24, 0, 0, 0);
await env.MY_KV.put("promo:banner", html, {
  expiration: Math.floor(midnight.getTime() / 1000), // 換算成秒
});

兩者的差別只在「怎麼表達到期時間」:expirationTtl 是相對(從現在起 N 秒),expiration 是絕對(某個 timestamp)。兩者都必須讓 key 至少存活 60 秒(這是 KV 的限制),不能設定成幾秒後就過期。過期後,get 該 key 會回傳 null,就跟 key 不存在一樣。

2. cacheTtl:調校讀取快取

cacheTtl 放在 get 的第二個參數。這裡示範針對「幾乎不變的設定檔」用較大的 cacheTtl 來省讀取、加速:

interface SiteConfig {
  siteName: string;
  maintenanceMode: boolean;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // 設定檔很少變,用較大的 cacheTtl(這裡 3600 秒 = 1 小時)
    // → 一小時內同節點的讀取都走快取,大幅減少計費讀取次數
    const config = await env.MY_KV.get<SiteConfig>("site:config", {
      type: "json",
      cacheTtl: 3600,
    });

    // 別忘了處理 null(key 不存在或已過期)
    const siteName = config?.siteName ?? "My Site";
    return new Response(`Welcome to ${siteName}`);
  },
} satisfies ExportedHandler<Env>;

取捨提醒:上面 cacheTtl: 3600 意味著——如果你在後台改了 site:config,已快取舊值的節點最長要等 1 小時才刷新。若你需要「改完幾分鐘內生效」,就把 cacheTtl 調到你能接受的延遲(例如 300 秒),用一點讀取次數換新鮮度。

3. metadata:讓 list 一次掃描附註,省下逐一 get

metadata 的殺手級用法是搭配 list——因為 list一併回傳每個 key 的 metadata,你可以只靠一次 list 就掃描所有 key 的狀態,完全不必逐一 get 每個值,大幅節省讀取次數與費用。

以下範例:寫入 feature flag 時把「啟用狀態、最後修改者、修改時間」放進 metadata,之後只用一次 list 就能列出所有 flag 的稽核資訊:

interface FlagMeta {
  enabled: boolean;
  updatedBy: string;
  updatedAt: number;
}

// 寫入 feature flag,附帶稽核用的 metadata
async function setFlag(env: Env, name: string, enabled: boolean, user: string) {
  await env.MY_KV.put(`flag:${name}`, enabled ? "on" : "off", {
    metadata: { enabled, updatedBy: user, updatedAt: Date.now() } satisfies FlagMeta,
  });
}

// 只用「一次 list」掃描所有 flag 的狀態(不需逐一 get)
async function auditFlags(env: Env): Promise<FlagMeta[]> {
  const listed = await env.MY_KV.list<FlagMeta>({ prefix: "flag:" });
  return listed.keys
    .map((k) => k.metadata)          // metadata 直接在 list 結果裡
    .filter((m): m is FlagMeta => m !== undefined);
}

這比「先 list 拿 key 名稱,再對每個 key 各 get 一次」省下大量讀取——對有幾百上千個 flag 的系統,差距非常明顯。

4. 寫入速率限制:429 退避重試

每個 key 每秒最多寫入 1 次,超過回傳 HTTP 429。若你的寫入無法完全避免碰撞,可以用指數退避(exponential backoff) 重試:

// 對同一 key 寫入時,遇到 429 就指數退避重試
async function putWithRetry(
  kv: KVNamespace,
  key: string,
  value: string,
  maxAttempts = 5,
): Promise<void> {
  let delay = 1000; // 初始等待 1 秒
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      await kv.put(key, value);
      return; // 成功就結束
    } catch (err) {
      const is429 = err instanceof Error && err.message.includes("429");
      if (!is429 || attempt === maxAttempts - 1) throw err; // 非 429 或已用完次數
      await new Promise((r) => setTimeout(r, delay));
      delay *= 2; // 每次等待時間翻倍:1s → 2s → 4s → 8s
    }
  }
}

退避重試只是止血。真正的解法是從架構上避免高頻寫同一 key,見下一個範例。

5. 減少寫入:聚合與分片(sharding)策略

假設你要統計某頁面的瀏覽次數。直接每次瀏覽都 put 一次同一個 counter key,必然 429。兩種正確策略:

// ── 策略一:聚合後批次寫入 ──
// 在記憶體累積,達到門檻(或定時)才寫一次 KV,把寫入頻率壓到 1 RPS 以下
let pending = 0;
function onView(env: Env, ctx: ExecutionContext) {
  pending++;
  if (pending >= 100) { // 每累積 100 次才寫一次
    const batch = pending;
    pending = 0;
    ctx.waitUntil(
      (async () => {
        const current = Number((await env.MY_KV.get("views:home")) ?? "0");
        await env.MY_KV.put("views:home", String(current + batch));
      })(),
    );
  }
}

// ── 策略二:sharding 分片計數 ──
// 把 counter 拆成 10 個分片 key,寫入時隨機挑一個 → 寫入壓力分散到 10 個 key
async function incrementSharded(env: Env, shards = 10) {
  const shard = Math.floor(Math.random() * shards);
  const key = `views:home:shard:${shard}`;
  const current = Number((await env.MY_KV.get(key)) ?? "0");
  await env.MY_KV.put(key, String(current + 1));
}

// 讀取時把所有分片加總
async function readShardedTotal(env: Env, shards = 10): Promise<number> {
  let total = 0;
  for (let i = 0; i < shards; i++) {
    total += Number((await env.MY_KV.get(`views:home:shard:${i}`)) ?? "0");
  }
  return total;
}

但請注意:這兩種策略都因為最終一致性而不保證精準(讀-改-寫之間可能有競態,計數可能少算)。如果你需要精準即時的計數,別繞了——直接用 Durable Objects,它天然序列化寫入、強一致,是計數場景的標準答案。

常見錯誤與最佳實踐

坑一:期待 KV 寫完立刻能讀到一致的值。

這是最根本的誤解。KV 是最終一致,寫入後同一次請求、或全球其他節點,立刻 get 都可能是舊值或 null永遠用本地變數,不靠重新 get 確認寫入。 若業務需要「寫完立刻讀到正確值」(扣款、庫存、精準計數),KV 就是錯的工具。

坑二:先 get 探測、再 put,踩到負快取。

如果你的邏輯是「先查有沒有,沒有才寫」,那次「查不到」會被負快取約 60 秒,導致剛寫進去的值在快取過期前仍讀到 null。若無法避免這個模式,就別依賴事後立刻讀取來驗證結果。

坑三:高頻寫入同一個 key,觸發 429。

// ❌ 錯誤:迴圈裡對同一 key 快速寫入,必觸發 429
for (const item of items) {
  await env.MY_KV.put("counter", String(count++)); // 429!
}

// ✅ 正確:聚合後批次寫入、分片(sharding),或改用 Durable Objects 做原子計數

記住 KV 的定位是讀多寫少(理想上讀寫比 > 100:1)。

坑四:cacheTtl 設太長,導致更新半天不生效。

cacheTtl 是「速度/省錢」和「新鮮度」的旋鈕。設太長(如幾小時),寫入新值後邊緣快取遲遲不刷新,使用者一直看到舊資料。調校原則:

資料類型建議 cacheTtl理由
幾乎不變的設定檔3600 秒以上極少改,優先省讀取、加速
一般設定 / feature flag60~300 秒兼顧新鮮度與效能
需相對即時的資料60 秒(最小值)用最小快取換最新

也別忘了:cacheTtl 最小就是 60 秒,設更小沒有意義。

何時該放棄 KV,改用 D1 或 Durable Objects?

KV 用對場景無敵,用錯場景處處是坑。下面這張表幫你劃清邊界:

你的需求該用什麼為什麼不是 KV
讀多寫少、可容忍最終一致(設定檔、flag、快取、session)✅ KV這正是 KV 的主場
寫完要立刻讀到正確值(扣款、庫存)Durable ObjectsKV 最終一致,做不到即時強一致
精準即時計數器、全域唯一序號Durable ObjectsKV 每 key 1 RPS + 讀-改-寫競態,會漏算
即時協作、分散式鎖、WebSocket 房間Durable ObjectsKV 無法序列化並發、無協調能力
關聯式資料、需要 SQL / JOIN / 交易D1KV 只能用 key/prefix 查詢
大型二進位檔案(圖片、影片)R2KV 單值上限 25 MiB,且非為 blob 設計

最佳實踐小結:記住 KV 的一致性心法——寫入用本地變數不重讀、避免先探測再寫(負快取)、cacheTtl 按資料新鮮度需求調校、高頻寫入改聚合/分片/DO。守住這幾條,KV 就是你邊緣應用最快、最省的讀取層;跨過它的邊界(需要強一致或高頻寫入),就果斷交給 D1 或 Durable Objects。

小結

上一篇《KV 鍵值儲存入門》,我們學會了 KV 的資料模型與 get / put / list / delete 基礎操作;這一篇,我們把當時刻意保留的一致性與快取細節徹底拆開,讓你把 KV 用得又快又對:

  • 最終一致性的真相——put 寫進中央儲存,get 讀的是邊緣快取副本,傳播最多約 60 秒;連同一次請求「寫完立刻讀」也可能是舊值,還要小心負快取讓「不存在」的結果卡住約 60 秒。鐵律:寫入後用本地變數,不靠重新 get
  • 兩種 TTL——expiration / expirationTtl 管「資料何時被 KV 刪除」(最小 60 秒);cacheTtl 管「單次讀取的邊緣快取多久刷新」(最小 60 秒)。前者控存活,後者控新鮮度,別搞混。
  • 寫入速率與批次——每 key 每秒 1 次寫入,超過 429;用退避重試止血,用聚合、分片(sharding)或 Durable Objects 治本。
  • 選型邊界——讀多寫少、可容忍最終一致 → KV;要強一致、精準計數、即時協作 → Durable Objects;要 SQL / 關聯查詢 → D1;要存大型檔案 → R2。

到這裡,KV 的入門與進階就完整了——你已經能把邊緣鍵值儲存用得又快又對。但 KV 有個天生的限制:單值上限 25 MiB,而且它不是為「大型二進位檔案」設計的。那圖片、影片、使用者上傳的文件這些又大又重的東西該存哪?下一篇《R2 物件儲存》,我們就進入 Cloudflare 的物件儲存(object storage)——一個 S3 相容、而且零 egress 費用的殺手級服務,看看它如何解決「存大檔又不想被流量費坑」的痛點。

想先查閱官方對 KV 一致性與快取的完整說明,可以隨時參考 Cloudflare Workers KV 官方文件。一致性這一課已就緒,我們下一篇《R2 物件儲存》見。

BenZ Software Developer

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

本週主打

AI 自動化入門包

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

看看這個產品 →