跳到內容

Workers KV:最終一致性要怎麼用才對

查證日期
驗證環境wrangler@4.114.0·workerd@1.20260722.1·compatibility_date: 2026-07-24
對應範例examples/ch08-kv

KV 是 Cloudflare 最老、最容易上手、也最容易被誤用的儲存產品。它的介面看起來就是一個全球分散的 Map,於是大家把它當 Redis 用 —— 然後在 production 撞上三堵牆。

這篇的目標不是教你 get/put(那五分鐘就會了),而是讓你在選型的當下就知道哪些需求 KV 撐不住。三件事:

  1. 「每個 key 每秒 1 次寫入」直接判死計數器、sliding session、last-seen 這三類需求。 這不是效能建議,是硬限制。
  2. read-your-own-write 不保證。 你剛 put 完,下一行 get 可能拿到舊值。這對登出/撤銷是安全問題。
  3. bulk get 一次最多 100 個 key,但只計 1 次操作。 這是 KV 上最大的成本優化,而多數人不知道它存在。

以下每個限制都用實際的錯誤訊息佐證 —— 我在本機一條一條撞出來的。


KV 是什麼:一份被推到全球邊緣的唯讀快取

Section titled “KV 是什麼:一份被推到全球邊緣的唯讀快取”

正確的心智模型不是「分散式資料庫」,是**「一份你可以偶爾更新、然後被複製到全球每個 colo 的快取」**。

寫入走中心,讀取走邊緣。這個不對稱決定了一切:

路徑最近的 colo(熱資料可能就在本機)中央,再向外傳播
延遲極低(p95 約 50ms,2025 年混合儲存上線後)
一致性最終(傳播可能 60 秒以上)
每秒上限極高每個 key 1 次
成本$0.50 / 百萬$5.00 / 百萬(10 倍)

寫入比讀取貴 10 倍、慢很多、而且有每秒 1 次的硬上限。 任何寫入頻繁的需求,KV 都不是答案。

① 計數器

// ❌ Wrong on three counts.
const n = Number(await env.KV.get(`views:${slug}`)) ?? 0;
await env.KV.put(`views:${slug}`, String(n + 1));

錯在:read-modify-write 有 race condition(沒有 CAS、沒有原子遞增);熱門項目會撞上每秒 1 寫的上限;而且讀到的值本來就可能是舊的。

→ 用 Durable Object(第 14 篇)。

② Sliding session / last-seen

「每次請求就把 session 的過期時間往後延」—— 一個活躍使用者每秒可能發好幾個請求,直接撞上每秒 1 寫。

→ session 用 KV 存不可變的 token → 使用者對照可以,但過期時間要固定(expirationTtl),不要滑動。需要立即撤銷就用 DO(第 27 篇會完整處理)。

③ 需要立即失效的東西

await env.KV.delete(`session:${token}`); // 使用者按了「登出所有裝置」
// 其他 colo 可能還要 60 秒以上才看得到這個刪除

官方原文:read-your-own-write “is not guaranteed and therefore it is not advised to rely on this behaviour”

→ 撤銷語意要用 DO 或 D1。

await kv.get(key); // 預設 "text"
await kv.get(key, "text");
await kv.get<T>(key, "json");
await kv.get(key, "arrayBuffer");
await kv.get(key, "stream");

效能由快到慢:stream → arrayBuffer → text → jsonjson 最慢因為多一次 parse,而那是 CPU time(第 1 篇:計算很貴)。大的 JSON 值考慮存 arrayBuffer 或直接串流出去。

🎯 Bulk get:一次 100 個 key,只算 1 次操作

Section titled “🎯 Bulk get:一次 100 個 key,只算 1 次操作”

這是 KV 上最有價值、也最少人知道的優化:

const map = await kv.get(["k:0000", "k:0001", /* ... */], "json");
// Map<string, T | null>

實測:

Terminal window
$ curl "localhost:8787/bulk?n=100"
{"requested":100,"ok":{"type":"Map","size":100,"sample":[["k:0000",{"i":0}],["k:0001",{"i":1}]]}}
$ curl "localhost:8787/bulk?n=101"
{"requested":101,"threw":"Error: KV GET_BULK failed: 400 You can request a maximum of 100 keys"}

100 個 key = 1 次操作(而不是 100 次)。以 $0.50/百萬讀來算,這是 100 倍的成本差距,同時也省掉 99 次 subrequest 配額(第 3 篇:Free 方案總共只有 50 次)。

限制:最多 100 個 key、只支援 "text""json"、回傳 Map 而非物件。

expirationTtl 最少 60 秒

Terminal window
$ curl localhost:8787/ttl
{
"ttl=10": { "threw": "Error: KV PUT failed: 400 Invalid expiration_ttl of 10. Expiration TTL must be at least 60." },
"ttl=59": { "threw": "Error: KV PUT failed: 400 Invalid expiration_ttl of 59. Expiration TTL must be at least 60." },
"ttl=60": {},
"ttl=61": {}
}

metadata 最多 1024 bytes,而且算的是序列化後的長度

Terminal window
$ curl localhost:8787/metadata
{
"bytes=100": {},
"bytes=1000": {},
"bytes=1024": { "threw": "Error: KV PUT failed: 413 Metadata length of 1034 exceeds limit of 1024." },
"bytes=2000": { "threw": "Error: KV PUT failed: 413 Metadata length of 2010 exceeds limit of 1024." }
}

注意 1024 bytes 的內容失敗了 —— 因為 {"pad":"..."} 這層 JSON 包裝又多了 10 bytes。算的是序列化後的總長度,不是你的值本身。

list() 回傳的結果直接帶著 metadata

Terminal window
$ curl localhost:8787/list
{
"keys": [
{ "name": "k:0000", "metadata": { "even": true } },
{ "name": "k:0001", "metadata": { "even": false } },
{ "name": "k:0002", "metadata": { "even": true } }
],
"list_complete": false,
"cursor": "azowMDAy",
"cacheStatus": null,
"naiveDoneCheck": false
}

所以把「列表頁需要顯示的欄位」放進 metadata(狀態、tenant id、建立時間),列表就完全不需要再逐一 get()。1024 bytes 塞得下不少東西。

🔴 list() 的分頁必須看 list_complete

Section titled “🔴 list() 的分頁必須看 list_complete”
// ❌ Wrong. A page can be short and still not be the last one.
if (page.keys.length < limit) done = true;
// ✅ Right.
if (page.list_complete) done = true;

上面的實測輸出裡,keys.length 是 3、limit 也是 3,但 list_completefalse。而 KV 完全可以回傳一個比 limit 短的頁面卻還沒結束 —— 因為它是按內部分片掃描的。

順帶一提,型別定義把這件事做對了:list_complete: false 的分支才有 cursor 欄位,true 的分支沒有。TypeScript 會逼你先檢查。

Terminal window
$ curl localhost:8787/cachettl
{"cacheTtl=10":{"threw":"... Invalid cache_ttl of 10. Cache TTL must be at least 30."},
"cacheTtl=29":{"threw":"... Cache TTL must be at least 30."},
"cacheTtl=30":{"ok":"{\"i\":0}"},
"cacheTtl=60":{"ok":"{\"i\":0}"}}

2026-01-30 起最小值從 60 秒降到 30 秒,預設仍是 60。2025 年以前的教學會說最小 60。

它控制的是 colo 內部快取這個 key 的時間 —— 調低可以讓寫入更快傳播到讀取端,代價是更多次真正的 KV 讀取(也就是更多錢)。

put 只能一次一個 key。批次寫入要走 REST API 或 wrangler kv bulk put(上限 10,000 對 / 100MB)。

「每個 key 每秒 1 次寫入」是 KV 最重要的限制,但本機不會擋你

Terminal window
$ curl localhost:8787/hammer # 對同一個 key 連續寫 8 次
{"writes":8,"results":[{},{},{},{},{},{},{},{}],"final":"7"}

八次全過。這是本系列第五個「本機與 production 不一致」的案例,而且是最危險的一個 —— 因為它不會報錯,只是在 production 靜默地丟掉寫入。

規則:任何會對同一個 key 高頻寫入的設計,本機測試給不出任何訊號。要靠 code review 抓。

項目FreePaid
100,000 / 日含 1,000 萬 / 月,之後 $0.50 / 百萬
寫 / 刪 / list各 1,000 / 日各含 100 萬 / 月,之後 $5.00 / 百萬
儲存1 GB含 1 GB,之後 $0.50 / GB-月
namespace 數1,0001,000

不分方案:key ≤ 512 Bvalue ≤ 25 MiBmetadata ≤ 1024 B每個 key 每秒 1 次寫入單次 invocation 最多 1000 次 KV 操作

三個計費細節:

  1. 寫比讀貴 10 倍。
  2. 查不到(null)也計費。 拿 KV 當「檢查存不存在」的機制時要注意。
  3. Free 方案的每日配額 00:00 UTC 重置。

舊的 REST 路徑 /accounts/{id}/workers/namespaces/* 2026-07-15 起棄用、2026-10-15 停用。改用 /accounts/{id}/storage/kv/namespaces/*

如果你有腳本或 CI 在打舊路徑,這是一個會在本系列生命週期內到期的硬期限。


完整程式碼:examples/ch08-kv/

Terminal window
cd examples/ch08-kv && npm install && npm run dev
Terminal window
B=localhost:8787
curl -s "$B/seed?n=120" # 灌 120 筆
curl -s "$B/bulk?n=100" # Map,size 100
curl -s "$B/bulk?n=101" # 400 最多 100 個 key
curl -s "$B/ttl" # 59 失敗、60 成功
curl -s "$B/metadata" # 1024 bytes 的內容反而失敗
curl -s "$B/list" # list_complete=false 但 keys.length < limit
curl -s "$B/cachettl" # 29 失敗、30 成功
curl -s "$B/hammer" # 8 次連寫全過 —— 本機不擋

不需要 Cloudflare 帳號,全部跑在本機的 .wrangler/state/


KV 在 LinkForge 只負責一件事:redirect 熱路徑的 slug → URL 查詢。這是它最擅長的形狀 —— 讀多寫少、可接受最終一致、需要全球低延遲。

link:{slug} → value: 目標 URL(純字串,不是 JSON)
metadata: { t: tenantId, s: status, e: expiresAt }

三個決策:

① value 存純字串不存 JSON。 redirect 只需要那個 URL。存 JSON 就得付一次 JSON.parse 的 CPU,而熱路徑上的 CPU 就是錢(第 1 篇)。

② 路由需要的中繼資料全部塞進 metadata。 tenant、狀態、過期時間都在 metadata 裡,所以:

const { value: target, metadata } = await env.LINKS.getWithMetadata<LinkMeta>(slug);
if (!target || metadata?.s !== "active") return notFound();

一次讀取就拿到全部,不用再打 D1 查連結狀態。這是把 D1 完全移出熱路徑的關鍵。

③ 欄位名縮到一個字母。 metadata 只有 1024 bytes,而且算的是序列化後長度。{"t":"...","s":"active"}{"tenantId":"...","status":"active"} 省下的空間,在多租戶場景會有感。

寫入路徑:D1 是真相來源,KV 是投影

Section titled “寫入路徑:D1 是真相來源,KV 是投影”
POST /v1/links
├─ 1. 寫 D1(真相來源,有 transaction 語意、有唯一約束)
└─ 2. ctx.waitUntil(寫 KV 投影)

KV 從來不是真相來源。 它是 D1 的一份非同步投影。理由:

  • KV 沒有唯一約束 —— 「slug 不能重複」只有 D1 擋得住。
  • KV 沒有 transaction —— 「建立連結同時扣掉配額」需要原子性。
  • KV 傳播延遲最多 60 秒 —— 但短網址建立後晚幾秒生效完全可以接受。

如果 KV 寫入失敗,第 20 篇的 cron 會做對帳補寫。

需求為什麼不用 KV
點擊計數Durable Object(第 14 篇)每個 key 每秒 1 寫
使用者 sessionDO 或 D1(第 27 篇)登出需要立即失效
每租戶速率限制ratelimits binding(第 4 篇)同上,且 KV 更貴

以每月 1,000 萬次 redirect、1 萬個連結、每天 100 次連結異動計:

  • :1,000 萬次 —— 但第 6 篇的 Workers Cache 會擋掉大部分。假設 90% 命中,實際 KV 讀取 100 萬次 → $0.50
  • :3,000 次 → 遠低於免費額度
  • 儲存:1 萬 × 約 200 bytes = 2 MB → 免費額度內

每月不到 1 美元。 而如果沒有第 6 篇那層快取,就是 1,000 萬次讀取 = $5.00 —— 十倍。這就是為什麼快取要排在資料層前面講。

本篇交付物apps/redirector 接上 KV(取代第 3 篇的硬編碼 map)、packages/shared 定義 LinkMeta 型別、apps/api 的建立流程加上 KV 投影寫入。


① 把 KV 當計數器

沒有原子遞增,而且每個 key 每秒只能寫 1 次。

② 依賴 read-your-own-write

官方明說不保證。

③ 用 keys.length < limit 判斷分頁結束

要看 list_complete

④ 不知道 bulk get 存在

100 個 key 只算 1 次操作,成本差 100 倍。

⑤ 以為 metadata 上限是 1024 bytes 的「內容」

算的是序列化後的長度,JSON 包裝也算進去。

⑥ 在熱路徑存 JSON

json 型別最慢,而 CPU time 是計費維度。

⑦ 以為本機測得出寫入頻率問題

本機不擋。連寫 8 次全過。

⑧ 忘記查不到也計費

null 的回應一樣算一次讀取。

⑨ 還在用舊的 REST 路徑

/workers/namespaces/* 2026-10-15 停用。


  1. 每個 key 每秒 1 次寫入判死計數器、sliding session、last-seen —— 而且本機不會擋你。
  2. **bulk get 一次 100 個 key 只算 1 次操作。**這是 KV 上最大的成本槓桿。
  3. **KV 是投影不是真相來源。**唯一約束、transaction、立即失效,三個它都給不了。


下一篇09. D1 入門:邊緣上的 SQLite —— rows_read 就是你的帳單,而 wrangler d1 migrations apply 預設打的是本機,不是 production。