Workers KV:最終一致性要怎麼用才對
這篇要解決的問題
Section titled “這篇要解決的問題”KV 是 Cloudflare 最老、最容易上手、也最容易被誤用的儲存產品。它的介面看起來就是一個全球分散的 Map,於是大家把它當 Redis 用 —— 然後在 production 撞上三堵牆。
這篇的目標不是教你 get/put(那五分鐘就會了),而是讓你在選型的當下就知道哪些需求 KV 撐不住。三件事:
- 「每個 key 每秒 1 次寫入」直接判死計數器、sliding session、last-seen 這三類需求。 這不是效能建議,是硬限制。
- read-your-own-write 不保證。 你剛
put完,下一行get可能拿到舊值。這對登出/撤銷是安全問題。 - 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 都不是答案。
🔴 三個 KV 撐不住的需求
Section titled “🔴 三個 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。
API 與實測的限制
Section titled “API 與實測的限制”get():四種型別,效能不同
Section titled “get():四種型別,效能不同”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 → json。json 最慢因為多一次 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>實測:
$ 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 而非物件。
put() 的兩個硬限制
Section titled “put() 的兩個硬限制”expirationTtl 最少 60 秒:
$ 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,而且算的是序列化後的長度:
$ 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。算的是序列化後的總長度,不是你的值本身。
metadata 是省一次讀取的技巧
Section titled “metadata 是省一次讀取的技巧”list() 回傳的結果直接帶著 metadata:
$ 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_complete 是 false。而 KV 完全可以回傳一個比 limit 短的頁面卻還沒結束 —— 因為它是按內部分片掃描的。
順帶一提,型別定義把這件事做對了:list_complete: false 的分支才有 cursor 欄位,true 的分支沒有。TypeScript 會逼你先檢查。
cacheTtl 最小值 2026 年改成 30 秒
Section titled “cacheTtl 最小值 2026 年改成 30 秒”$ 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 讀取(也就是更多錢)。
binding 不支援 bulk write
Section titled “binding 不支援 bulk write”put 只能一次一個 key。批次寫入要走 REST API 或 wrangler kv bulk put(上限 10,000 對 / 100MB)。
本機測不出來的那條限制
Section titled “本機測不出來的那條限制”「每個 key 每秒 1 次寫入」是 KV 最重要的限制,但本機不會擋你:
$ curl localhost:8787/hammer # 對同一個 key 連續寫 8 次{"writes":8,"results":[{},{},{},{},{},{},{},{}],"final":"7"}八次全過。這是本系列第五個「本機與 production 不一致」的案例,而且是最危險的一個 —— 因為它不會報錯,只是在 production 靜默地丟掉寫入。
規則:任何會對同一個 key 高頻寫入的設計,本機測試給不出任何訊號。要靠 code review 抓。
| 項目 | Free | Paid |
|---|---|---|
| 讀 | 100,000 / 日 | 含 1,000 萬 / 月,之後 $0.50 / 百萬 |
| 寫 / 刪 / list | 各 1,000 / 日 | 各含 100 萬 / 月,之後 $5.00 / 百萬 |
| 儲存 | 1 GB | 含 1 GB,之後 $0.50 / GB-月 |
| namespace 數 | 1,000 | 1,000 |
不分方案:key ≤ 512 B、value ≤ 25 MiB、metadata ≤ 1024 B、每個 key 每秒 1 次寫入、單次 invocation 最多 1000 次 KV 操作。
三個計費細節:
- 寫比讀貴 10 倍。
- 查不到(
null)也計費。 拿 KV 當「檢查存不存在」的機制時要注意。 - Free 方案的每日配額 00:00 UTC 重置。
⚠️ 2026-10-15 的停用期限
Section titled “⚠️ 2026-10-15 的停用期限”舊的 REST 路徑 /accounts/{id}/workers/namespaces/* 2026-07-15 起棄用、2026-10-15 停用。改用 /accounts/{id}/storage/kv/namespaces/*。
如果你有腳本或 CI 在打舊路徑,這是一個會在本系列生命週期內到期的硬期限。
完整程式碼:
examples/ch08-kv/
cd examples/ch08-kv && npm install && npm run devB=localhost:8787curl -s "$B/seed?n=120" # 灌 120 筆curl -s "$B/bulk?n=100" # Map,size 100curl -s "$B/bulk?n=101" # 400 最多 100 個 keycurl -s "$B/ttl" # 59 失敗、60 成功curl -s "$B/metadata" # 1024 bytes 的內容反而失敗curl -s "$B/list" # list_complete=false 但 keys.length < limitcurl -s "$B/cachettl" # 29 失敗、30 成功curl -s "$B/hammer" # 8 次連寫全過 —— 本機不擋不需要 Cloudflare 帳號,全部跑在本機的 .wrangler/state/。
接進 LinkForge
Section titled “接進 LinkForge”KV 在 LinkForge 只負責一件事:redirect 熱路徑的 slug → URL 查詢。這是它最擅長的形狀 —— 讀多寫少、可接受最終一致、需要全球低延遲。
key 設計
Section titled “key 設計”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 的三件事
Section titled “明確不用 KV 的三件事”| 需求 | 用 | 為什麼不用 KV |
|---|---|---|
| 點擊計數 | Durable Object(第 14 篇) | 每個 key 每秒 1 寫 |
| 使用者 session | DO 或 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 停用。
本篇要記住的三句話
Section titled “本篇要記住的三句話”- 每個 key 每秒 1 次寫入判死計數器、sliding session、last-seen —— 而且本機不會擋你。
- **bulk get 一次 100 個 key 只算 1 次操作。**這是 KV 上最大的成本槓桿。
- **KV 是投影不是真相來源。**唯一約束、transaction、立即失效,三個它都給不了。
下一篇:09. D1 入門:邊緣上的 SQLite —— rows_read 就是你的帳單,而 wrangler d1 migrations apply 預設打的是本機,不是 production。