Durable Objects:邊緣上的 actor
這篇要解決的問題
Section titled “這篇要解決的問題”Durable Objects 是 Cloudflare 平台上最強大、也最容易被誤解的東西。三個必須先破除的誤解:
- 「DO 是有狀態的儲存」 —— 不是。它解決的是協調問題。儲存只是附帶的。
- 「DO 是單執行緒,所以我不用擔心 race condition」 —— 大錯。 我實測一個標準的 read-modify-write,三個並發呼叫全部讀到
0、全部寫入1,更新遺失了。 - 「切分鍵之後再調整就好」 —— 第 13 篇說過,這是本系列最貴的錯誤。切分鍵錯了等於重寫。
這篇會把第 2 點用實驗打死,並且花最多篇幅在第 3 點。
DO 是什麼:有身分的單一執行緒
Section titled “DO 是什麼:有身分的單一執行緒”三個性質合起來定義了 DO:
| 性質 | 意義 |
|---|---|
| 全球唯一 | 同一個 id 在全世界只會有一個實例在跑 |
| 單一執行緒 | 沒有平行執行 |
| 有身分 | 你可以用一個名字直接定址到它 |
這三點加起來解決的是協調:原子操作、單一真相、即時廣播、per-entity 排程。
如果你的需求不需要「同一時刻只有一個人在改這份狀態」,你大概不需要 DO。 儲存有更便宜的選項(第 13 篇)。
2026 年的標準寫法
Section titled “2026 年的標準寫法”import { DurableObject } from "cloudflare:workers";
export class LinkCounter extends DurableObject<CloudflareBindings> { constructor(ctx: DurableObjectState, env: CloudflareBindings) { super(ctx, env); ctx.blockConcurrencyWhile(async () => { ctx.storage.sql.exec( `CREATE TABLE IF NOT EXISTS hits (id INTEGER PRIMARY KEY, at INTEGER NOT NULL)`, ); }); }
async increment(): Promise<number> { /* ... */ }}四個要點:
import { DurableObject } from "cloudflare:workers"並extends它。沒繼承的 class 不能用 RPC。- constructor 的參數叫
ctx(官方文件註明:舊稱state)。super(ctx, env)必須先呼叫。 ctx.blockConcurrencyWhile()是非同步初始化的正確位置 —— 它會擋住所有事件直到 callback 完成。上限 30 秒,callback 拋錯會讓物件被終止並重置。- 泛型參數是 env 型別:
DurableObject<CloudflareBindings>,這樣this.env才有型別。
定址:getByName() 是 2026 年的預設
Section titled “定址:getByName() 是 2026 年的預設”// ✅ Idiomaticconst stub = env.COUNTER.getByName("link:abc");
// The long form it replacesconst stub = env.COUNTER.get(env.COUNTER.idFromName("link:abc"));實測 id 的行為:
$ curl -s localhost:8787/addressing{ "idFromName": "71f1b2461b763e01aec0a7a6858d0fef683e94d2ce3e8cab502082b71d245f27", "sameNameSameId": true, "nameOnNamedId": "link:abc", "nameOnUniqueId": null, "uniqueIsDifferent": true, "idFromStringRoundTrips": true, "nameSurvivesIdFromString": null, "badIdFromString": { "threw": "TypeError: Invalid Durable Object ID: must be 64 hex digits" }}五個結論:
idFromName是確定性的 —— 同一個名字永遠得到同一個 id(64 位十六進位)。ctx.id.name只有具名物件才有。newUniqueId()產生的物件name是null。- 🔴
name撐不過idFromString()的往返。 id 本身相等(idFromStringRoundTrips: true),但name變成null。所以如果你把 id 序列化存起來再還原,物件就不知道自己叫什麼了 —— 需要名字就自己另外存。 - 無效字串會 throw,錯誤訊息很明確。
newUniqueId()比idFromName()快 —— 官方說法是它「results in lower request latency at first use」,因為省掉了具名 id 需要的全球一致性檢查。
三種定址方式怎麼選
Section titled “三種定址方式怎麼選”| 方式 | 用在 | 代價 |
|---|---|---|
getByName(name) | 有天然識別碼的實體(連結、房間、使用者) | 首次使用有全球一致性檢查 |
newUniqueId() | 短命、高流失率的物件(一次性 session、job) | id 必須自己存下來,否則就找不回來了 |
idFromString(hex) | 從已存的 id 還原 | name 會遺失 |
🔴 「單執行緒」不代表「不會交錯」
Section titled “🔴 「單執行緒」不代表「不會交錯」”這是本篇最重要的一節。
直覺是:既然 DO 是單執行緒,那 read-modify-write 應該是安全的。實測:
async slowIncrement(delayMs: number) { const before = this.inMemoryHits; await scheduler.wait(delayMs); // ← 非 storage 的 await this.inMemoryHits = before + 1; return { before, after: this.inMemoryHits };}同時對同一個物件發三個呼叫:
$ curl -s localhost:8787/serialize{ "calls": [ { "before": 0, "after": 1 }, { "before": 0, "after": 1 }, { "before": 0, "after": 1 } ], "lostUpdates": true}三個呼叫全部讀到 0、全部寫入 1。三次遞增只留下一次。 這就是經典的 lost update,發生在一個「單執行緒」的物件裡。
為什麼?input gate 只在 storage 操作期間關閉
Section titled “為什麼?input gate 只在 storage 操作期間關閉”Cloudflare 的併發模型是 input gate / output gate,官方定義:
Input gate:「While a storage operation is executing, no events shall be delivered to the object except for storage completion events.」
關鍵在 “While a storage operation is executing”。scheduler.wait() 不是 storage 操作,所以那個 await 會讓出執行權,其他事件就被送進來了。
規則可以這樣記:
await一個 storage 操作 → 安全。await其他任何東西 → 會交錯。
「其他任何東西」包括 fetch()、scheduler.wait()、任何外部 API 呼叫。
那什麼時候是安全的?
Section titled “那什麼時候是安全的?”改用 storage 且中間不 await 非 storage 操作:
$ curl -s localhost:8787/persisted{ "calls": [{ "before": 0, "after": 1 }, { "before": 0, "after": 2 }, { "before": 0, "after": 3 }] }before 仍然都是 0(三次讀取都在任何寫入之前發生),但 after 是 1、2、3 —— 寫入沒有遺失。因為 INSERT 是累加而不是 read-modify-write。
三條實務規則:
- 計數器用累加語意(
INSERT、UPDATE ... SET n = n + 1),不要用 read-modify-write。 - 必須 read-modify-write 時,中間不要 await 非 storage 操作。
- 真的需要跨越外部 I/O 的原子性時,用
blockConcurrencyWhile()—— 它比 input gate 強,會擋住所有事件,包括 storage 之外的 await 期間。
// Atomic across a non-storage await.await this.ctx.blockConcurrencyWhile(async () => { const before = await this.readSomething(); const enriched = await fetch("https://api.example.com/..."); // network I/O await this.writeSomething(before, enriched);});代價是完全序列化,吞吐量會掉。只在真的需要時用。
output gate:為什麼你可以不 await 寫入
Section titled “output gate:為什麼你可以不 await 寫入”反過來,output gate 給了你一個省延遲的機會:
「When a storage write operation is in progress, any new outgoing network messages will be held back until the write has completed.」
寫入失敗時「outgoing network messages will be discarded and replaced with errors, while the Durable Object will be shut down and restarted」。
所以「不 await put() 直接回應」是安全的 —— 沒有任何確認能在寫入落地之前離開這個物件。這和第 3 篇 Worker 裡的 fire-and-forget 完全相反。
不同物件是真的平行
Section titled “不同物件是真的平行”$ time curl -s localhost:8787/parallel # 三個不同的物件,各 300ms{"results":[{"before":0,"after":1},{"before":0,"after":1},{"before":0,"after":1}]}real 0m0.406s0.4 秒,不是 0.9 秒。 序列化是 per-object 的,不是全域的。這正是為什麼切分鍵決定了你的擴展上限。
waitUntil 在 DO 裡是 no-op
Section titled “waitUntil 在 DO 裡是 no-op”第 3 篇實測過,Worker 裡沒進 waitUntil 的 promise 會被丟棄。DO 裡不一樣:
$ curl -s localhost:8787/waituntil{ "withWaitUntil": 1, "bare": 1 }包不包 waitUntil 結果完全一樣,兩個背景工作都完成了。 官方原文:
「Unlike in Workers,
waitUntilhas no effect in Durable Objects.」
因為只要呼叫端還連著,物件就不會被回收。在 DO 裡寫 ctx.waitUntil() 是多餘的。
RPC 與保留方法名
Section titled “RPC 與保留方法名”任何 public 方法都可以直接呼叫:
const n = await env.COUNTER.getByName("link:abc").increment();但有幾個名字是保留的,用錯會得到困惑的錯誤:
| 名稱 | 狀態 |
|---|---|
fetch | 保留。必須接受一個 Request、回傳 Response |
connect | 保留(目前不支援) |
dup | 禁用(保留給複製 stub) |
constructor | 禁用 |
alarm、webSocketMessage、webSocketClose、webSocketError | 不能透過 RPC 呼叫,它們是系統事件處理器 |
fetch() 仍然可用,只是換一種呼叫慣例:
$ curl -s localhost:8787/viafetch{"status":200,"body":{"via":"fetch","path":"/hello"}}什麼時候用 fetch() 而不是 RPC? 只有兩種情況:你真的在轉發一個 HTTP 請求(含 headers、streaming body),或你要做 WebSocket upgrade(第 16 篇)。其餘一律用 RPC —— 型別安全、不用序列化成 HTTP。
跨 RPC 邊界的規則和第 18 篇一樣:structured-cloneable 值、function(變成 callback stub)、RpcTarget 子類、stream、Request/Response、stub。非 RpcTarget 子類的 class 傳不過去。
設定:exports map 取代了 migrations(但沒有棄用它)
Section titled “設定:exports map 取代了 migrations(但沒有棄用它)”2026-06-30 起(wrangler 4.107.0、@cloudflare/vite-plugin 1.43.0)有了宣告式的 exports map:
{ "durable_objects": { "bindings": [{ "name": "COUNTER", "class_name": "LinkCounter" }] }, "exports": { "LinkCounter": { "type": "durable-object", "storage": "sqlite" } }}對照舊的 migrations 陣列:
{ "migrations": [{ "tag": "v1", "new_sqlite_classes": ["LinkCounter"] }]}exports 的 state 欄位取代了各種 migration 動詞:
migrations 動詞 | exports 寫法 |
|---|---|
new_sqlite_classes | { "type": "durable-object", "storage": "sqlite" } |
new_classes | { "type": "durable-object", "storage": "legacy-kv" } |
deleted_classes | { "type": "durable-object", "state": "deleted" } |
renamed_classes | { "state": "renamed", "renamed_to": "NewName" } |
transferred_classes | { "state": "transferred", "transferred_to": "target-worker" } + 對方 { "state": "expecting-transfer", "transfer_from": "source-worker" } |
實測三件事:
① 兩者同時寫 → 硬性錯誤
✘ [ERROR] Processing wrangler.jsonc configuration: - `migrations` and `exports` are mutually exclusive. Choose one or the other to declare your Durable Object lifecycle, but not both.② 只寫 exports → 正常
③ 兩個都不寫 → 警告,而且它建議的是 exports
▲ [WARNING] you have configured `durable_objects` exported by this Worker (LinkCounter), but no live `exports` entry for them. ... Add the following configuration:
{ "exports": { "LinkCounter": { "type": "durable-object", "storage": "sqlite" } } }⚠️
migrations沒有被棄用。 官方原文:「Existing Workers using the legacymigrationsarray continue to work unchanged.」上面那個警告是「完全沒宣告生命週期」時才觸發的,4.107.0 只是把它建議的解法從migrations改成exports。
🔴 但這是一扇單向門:「Once a Worker has been deployed with
exports, subsequent deploys cannot return to the legacymigrationsarray.」
本系列新專案一律用 exports,理由是它沒有 migration tag 的心智負擔(「current exports map is the source of truth」),而且重新命名與轉移是一等公民。
KV-backed 已經關門了
Section titled “KV-backed 已經關門了”官方原文:
「Creating new namespaces with the key-value storage backend is no longer supported for accounts without an existing key-value-backed namespace.」
也就是說 new_classes / storage: "legacy-kv" 對新帳號已經不可用。而且:
「You cannot enable a SQLite storage backend on an existing, deployed Durable Object class」
儲存後端是不可變的。選錯要重建 namespace。新專案一律 sqlite,這也是第 15 篇的主題。
⚠️ 文件在這一點上自相矛盾:同一個頁面的 HTML 版還留著「KV storage backend remains for backwards compatibility, and a migration path … will be available in the future」的舊文字,而 markdown 版是上面那句硬性的「no longer supported」。以硬性版本為準,並注意那條「未來會有遷移路徑」到今天仍未實現。
位置與司法管轄
Section titled “位置與司法管轄”Location hints(11 個值):wnam、enam、sam、weur、eeur、apac、apac-ne、apac-se、oc、afr、me。
兩句官方警告值得直接引用:
「Only the first call to
get()for a particular Object will respect the hint.」
「Hints are a best effort and not a guarantee. … Durable Objects will not necessarily be instantiated in the hinted location, but instead instantiated in a data center selected to minimize latency from the hinted location.」
Jurisdictions(eu、us、fedramp)則是強制的,不是提示。用 ns.jurisdiction("eu") 建立子命名空間。
| 項目 | Free | Paid |
|---|---|---|
| 物件數 / namespace | 無限 | 無限 |
| class 數 / 帳號 | 100 | 500 |
| 單一物件儲存 | 10 GB | 10 GB |
| 帳號總儲存 | 5 GB | 無限 |
| 請求 | 10 萬 / 日 | 含 100 萬 / 月,之後 $0.15 / 百萬 |
| Duration | 13,000 GB-s / 日 | 含 40 萬 GB-s / 月,之後 $12.50 / 百萬 GB-s |
| Rows read | 500 萬 / 日 | 含 250 億 / 月,之後 $0.001 / 百萬 |
| Rows written | 10 萬 / 日 | 含 5,000 萬 / 月,之後 $1.00 / 百萬 |
| 儲存 | 5 GB | 含 5 GB-月,之後 $0.20 / GB-月 |
| CPU / 請求 | 30 s(可設定到 5 min) | 同左 |
| Wall time(HTTP/RPC) | 呼叫端連著就不限 | 同左 |
| Wall time(alarm) | 15 分鐘 | 15 分鐘 |
| WebSocket 連線 / 物件 | 32,768 | 32,768 |
「請求」的定義包含:HTTP 請求、RPC session、WebSocket 訊息、alarm 觸發。
單一物件的吞吐量,官方原文:
「An individual Object has a soft limit of 1,000 requests per second. You can have an unlimited number of individual objects per namespace.」
注意 soft —— 是指引不是強制配額。但它就是你設計切分鍵時的天花板。
🎯 切分鍵:本系列最貴的決定
Section titled “🎯 切分鍵:本系列最貴的決定”第 13 篇說過,其他選型錯誤都能增量修,切分鍵錯了要重寫。這裡給一套判準。
① 這個切分下,單一物件的請求速率會超過每秒 1,000 嗎?
一個 DO 沒辦法橫向擴展。如果你的切分讓某個物件變成熱點,你只能重新切分。
❌ 每個租戶一個 DO → 大租戶會變成瓶頸✅ 每個連結一個 DO → 熱門連結各自獨立② 需要一起原子更新的東西,有沒有被切到不同物件?
跨 DO 沒有原子性。如果 A 和 B 必須一起改,它們必須在同一個物件裡。
❌ 每個連結一個 DO,但配額是每租戶的 → 扣配額不是原子的✅ 配額放在 TenantQuota DO,點擊放在 LinkCounter DO,兩件事本來就不需要原子③ 這個切分下,會不會產生大量幾乎沒用到的物件?
物件數量本身沒有上限也沒有直接成本,但每個物件的儲存有 5 GB 的免費額度共享,而且冷啟動有成本。
LinkForge 的切分決定
Section titled “LinkForge 的切分決定”| 狀態 | 切分鍵 | 理由 |
|---|---|---|
| 點擊計數 | link:{linkId} | 每個連結獨立;熱門連結不會拖累其他連結 |
| 即時看板 | dashboard:{tenantId} | WebSocket 廣播天然是 per-tenant |
| 使用者 session | session:{userId} | 「登出所有裝置」需要單一真相(第 27 篇) |
| 租戶配額 | quota:{tenantId} | 必須是原子的,而且天然是 per-tenant |
注意點擊計數是 per-link 而不是 per-tenant。 這是刻意的:一個大租戶可能有幾千個連結,全部塞進一個 DO 會撞上每秒 1,000 的軟上限。per-link 讓熱點分散。
代價是「這個租戶的總點擊數」需要跨物件聚合 —— 那由第 17 篇的 alarm 寫進 D1 的 rollup 表解決,而不是靠 DO。
在 repo 裡放一份 docs/adr/001-do-partitioning.md,記錄每個 DO 的切分鍵、預估的每秒請求數、以及「什麼情況下這個切分會不夠用」。這是唯一能在半年後救你的東西。
完整程式碼:
examples/ch14-durable-objects/
cd examples/ch14-durable-objects && npm install && npm run devB=localhost:8787curl -s "$B/addressing" # id 的確定性、name 的存活範圍curl -s "$B/identity" # ctx.id.namecurl -s "$B/serialize" # 🔴 lostUpdates: truecurl -s "$B/parallel" # 不同物件是真的平行(0.4s 不是 0.9s)curl -s "$B/persisted" # 累加語意不會遺失寫入curl -s "$B/waituntil" # waitUntil 在 DO 裡是 no-opcurl -s "$B/viafetch" # fetch() 的呼叫慣例練習:把 slowIncrement 裡的 await scheduler.wait(delayMs) 拿掉,再跑一次 /serialize,看 lostUpdates 變成 false。那個 await 就是全部的差別。
① 以為單執行緒就沒有 race condition
非 storage 的 await 會讓出執行權。實測 lost update 確實會發生。
② read-modify-write 中間跨越 fetch() 或 setTimeout
同上。需要跨外部 I/O 的原子性就用 blockConcurrencyWhile()。
③ 在 DO 裡用 ctx.waitUntil()
官方明說沒有效果。實測包不包結果一樣。
④ 以為 idFromString() 能還原 name
id 會相等,name 會變 null。
⑤ 用 newUniqueId() 卻沒把 id 存起來
那個物件就永遠找不回來了。
⑥ exports 和 migrations 同時寫
硬性錯誤。而且用了 exports 就回不去 migrations。
⑦ 期待 location hint 是保證
是 best effort,而且只有第一次 get() 有效。要強制就用 jurisdiction。
⑧ 想在既有 class 上改儲存後端
不可變。而且 KV-backed 對新帳號已經關門。
⑨ 用保留的方法名
fetch 必須是 Request→Response;dup、constructor 禁用;alarm 和 webSocket* 不能透過 RPC 呼叫。
⑩ 一個 DO 服務全部流量
每秒 1,000 的軟上限,無法橫向擴展。
本篇要記住的三句話
Section titled “本篇要記住的三句話”- **「單執行緒」不等於「不會交錯」。**input gate 只在 storage 操作期間關閉,其他
await一律讓出執行權 —— 實測 lost update 真的會發生。 - **DO 解決的是協調問題。**只需要存東西的話,第 13 篇有更便宜的選項。
- **切分鍵是唯一無法增量修正的決定。**寫成 ADR,並記下「什麼情況下這個切分會不夠用」。
- Durable Object 基礎類別 · Namespace · Id
- 宣告式 class exports(2026-06-30)
- Durable Objects: Easy, Fast, Correct — Choose three —— input/output gate 的原始論述,值得完整讀一遍
- 限制 · 計費
- 資料位置與 jurisdiction
下一篇:15. DO SQLite Storage:每個 object 一顆資料庫 —— 同步 API、真正的 transaction,以及一個「cursor 跨越 await 會看到別人寫入的資料」的陷阱。