KV 進階:一致性與快取,把邊緣讀取用得又快又對 | Cloudflare 完整教學
上一篇《KV 鍵值儲存入門》我們刻意在**最終一致性(eventual consistency)**上點到為止。這一篇,我們把它徹底拆開:寫入後究竟多久全球才會同步?為什麼連同一次請求裡讀都可能是舊值?
cacheTtl怎麼調校邊緣快取?expiration與expirationTtl差在哪?為什麼每個 key 每秒只能寫 1 次,遇到高頻計數該怎麼辦?讀完這篇,你會把 Cloudflare KV 從「會用」升級到「用得又快又對」。
前言
上一篇《KV 鍵值儲存入門》,我們學會了 Workers KV 的資料模型與 get / put / list / delete 四大操作,把資料留在邊緣讀寫。但在正文裡,我們對一個關鍵特性刻意只點到為止——最終一致性(eventual consistency)。我們只說了「寫入後全球最多約 60 秒可見」,但這句話底下藏著一整組會讓新手栽跟頭的細節。
打個比方:如果說 KV 像遍佈全球每家便利商店的販賣機,那入門篇教的是「怎麼補貨、怎麼買」;而這篇進階要講的,是補貨到全球生效的時間差、每家分店手上還握著多久的舊庫存資訊(邊緣快取)、以及同一格為什麼不能每秒狂補。這些「時間」與「快取」的細節,正是把 KV 用得又快又對的分水嶺——用對了,你的邊緣應用飛快又省錢;用錯了,你會遇到「明明寫了卻讀不到」「改了設定卻半天不生效」「一跑迴圈就 429」這類難以除錯的鬼故事。
這一篇我們聚焦一致性與效能,不重複入門篇的基礎 API。讀完你會掌握:
- 最終一致性的真相——寫入傳播延遲(最多約 60 秒)、為什麼同一次請求也讀到舊值、負快取(negative caching)這個隱藏陷阱
- 兩種「TTL」的差異——
expiration/expirationTtl(資料何時消失)vscacheTtl(讀取快取多久刷新),別再搞混 - 寫入速率限制與批次策略——每 key 每秒 1 次寫入、429 退避重試、聚合與分片(sharding)
- 選型邊界——KV 適用場景 vs 什麼時候該果斷改用 D1 或 Durable Objects
核心概念
最終一致性:寫入是怎麼傳播到全球的
要理解 KV 的一致性行為,得先看清它的資料流。KV 的架構可以簡化成「一個中央儲存 + 全球一堆邊緣快取」:
你的 put ─────────────► ┌─────────────────┐
(寫入新值) │ Cloudflare │
│ 中央儲存 (源頭) │
└────────┬─────────┘
│ 非同步傳播 (最多 ~60 秒)
┌──────────┬─────────┼──────────┬──────────┐
▼ ▼ ▼ ▼ ▼
┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐
│ 東京 │ │ 倫敦 │ │ 台北 │ │ 紐約 │ │ 雪梨 │
│邊緣快取│ │邊緣快取│ │邊緣快取│ │邊緣快取│ │邊緣快取│
└───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘
▼ ▼ ▼ ▼ ▼
你的 get 從「最近的邊緣快取」讀 (熱讀取 < 5ms)
關鍵在於:你的 get 讀的不是中央儲存,而是離你最近的那個邊緣節點的快取副本。 這正是熱讀取能快到 5ms 以下的原因——但也正是最終一致性的根源。當你 put 一個新值:
- 新值先寫進 Cloudflare 的中央儲存(源頭)。
- 中央儲存再非同步地把新值傳播到全球各邊緣節點的快取。
- 這個傳播過程最多約需 60 秒。在這段時間內,不同節點的快取可能還是舊值。
所以「最終一致」的意思是:只要你停止寫入,經過一段時間(最多約 60 秒)後,全球所有節點終究會一致。 但在那之前,讀到舊值是完全正常、且符合設計的行為。
陷阱一:同一次請求裡「寫完立刻讀」也可能是舊的
很多人以為傳播延遲只發生在「跨地區」——「反正我的 Worker 在台北,讀寫都在台北節點,應該同步吧?」錯。
即使在同一個 Worker、同一次請求裡,你先 put 再 get,也可能讀到舊值或 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 flag | 60~300 秒 | 兼顧新鮮度與效能 |
| 需相對即時的資料 | 60 秒(最小值) | 用最小快取換最新 |
也別忘了:cacheTtl 最小就是 60 秒,設更小沒有意義。
何時該放棄 KV,改用 D1 或 Durable Objects?
KV 用對場景無敵,用錯場景處處是坑。下面這張表幫你劃清邊界:
| 你的需求 | 該用什麼 | 為什麼不是 KV |
|---|---|---|
| 讀多寫少、可容忍最終一致(設定檔、flag、快取、session) | ✅ KV | 這正是 KV 的主場 |
| 寫完要立刻讀到正確值(扣款、庫存) | Durable Objects | KV 最終一致,做不到即時強一致 |
| 精準即時計數器、全域唯一序號 | Durable Objects | KV 每 key 1 RPS + 讀-改-寫競態,會漏算 |
| 即時協作、分散式鎖、WebSocket 房間 | Durable Objects | KV 無法序列化並發、無協調能力 |
| 關聯式資料、需要 SQL / JOIN / 交易 | D1 | KV 只能用 key/prefix 查詢 |
| 大型二進位檔案(圖片、影片) | R2 | KV 單值上限 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 物件儲存》見。