跳到內容

Durable Objects:邊緣上的 actor

查證日期
驗證環境wrangler@4.114.0·workerd@1.20260722.1·compatibility_date: 2026-07-24

Durable Objects 是 Cloudflare 平台上最強大、也最容易被誤解的東西。三個必須先破除的誤解:

  1. 「DO 是有狀態的儲存」 —— 不是。它解決的是協調問題。儲存只是附帶的。
  2. 「DO 是單執行緒,所以我不用擔心 race condition」 —— 大錯。 我實測一個標準的 read-modify-write,三個並發呼叫全部讀到 0、全部寫入 1更新遺失了
  3. 「切分鍵之後再調整就好」 —— 第 13 篇說過,這是本系列最貴的錯誤。切分鍵錯了等於重寫。

這篇會把第 2 點用實驗打死,並且花最多篇幅在第 3 點。


DO 是什麼:有身分的單一執行緒

Section titled “DO 是什麼:有身分的單一執行緒”

三個性質合起來定義了 DO:

性質意義
全球唯一同一個 id 在全世界只會有一個實例在跑
單一執行緒沒有平行執行
有身分你可以用一個名字直接定址到它

這三點加起來解決的是協調:原子操作、單一真相、即時廣播、per-entity 排程。

如果你的需求不需要「同一時刻只有一個人在改這份狀態」,你大概不需要 DO。 儲存有更便宜的選項(第 13 篇)。

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 年的預設”
// ✅ Idiomatic
const stub = env.COUNTER.getByName("link:abc");
// The long form it replaces
const stub = env.COUNTER.get(env.COUNTER.idFromName("link:abc"));

實測 id 的行為:

Terminal window
$ 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() 產生的物件 namenull
  • 🔴 name 撐不過 idFromString() 的往返。 id 本身相等(idFromStringRoundTrips: true),但 name 變成 null。所以如果你把 id 序列化存起來再還原,物件就不知道自己叫什麼了 —— 需要名字就自己另外存。
  • 無效字串會 throw,錯誤訊息很明確。
  • newUniqueId()idFromName() 快 —— 官方說法是它「results in lower request latency at first use」,因為省掉了具名 id 需要的全球一致性檢查。
方式用在代價
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 };
}

同時對同一個物件發三個呼叫:

Terminal window
$ 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 呼叫。

改用 storage 且中間不 await 非 storage 操作:

Terminal window
$ 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。

三條實務規則:

  1. 計數器用累加語意INSERTUPDATE ... SET n = n + 1),不要用 read-modify-write。
  2. 必須 read-modify-write 時,中間不要 await 非 storage 操作。
  3. 真的需要跨越外部 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 完全相反。

Terminal window
$ time curl -s localhost:8787/parallel # 三個不同的物件,各 300ms
{"results":[{"before":0,"after":1},{"before":0,"after":1},{"before":0,"after":1}]}
real 0m0.406s

0.4 秒,不是 0.9 秒。 序列化是 per-object 的,不是全域的。這正是為什麼切分鍵決定了你的擴展上限。

第 3 篇實測過,Worker 裡沒進 waitUntil 的 promise 會被丟棄。DO 裡不一樣:

Terminal window
$ curl -s localhost:8787/waituntil
{ "withWaitUntil": 1, "bare": 1 }

包不包 waitUntil 結果完全一樣,兩個背景工作都完成了。 官方原文:

「Unlike in Workers, waitUntil has no effect in Durable Objects.」

因為只要呼叫端還連著,物件就不會被回收。在 DO 裡寫 ctx.waitUntil() 是多餘的。

任何 public 方法都可以直接呼叫:

const n = await env.COUNTER.getByName("link:abc").increment();

但有幾個名字是保留的,用錯會得到困惑的錯誤:

名稱狀態
fetch保留。必須接受一個 Request、回傳 Response
connect保留(目前不支援)
dup禁用(保留給複製 stub)
constructor禁用
alarmwebSocketMessagewebSocketClosewebSocketError不能透過 RPC 呼叫,它們是系統事件處理器

fetch() 仍然可用,只是換一種呼叫慣例:

Terminal window
$ 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"] }]
}

exportsstate 欄位取代了各種 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 legacy migrations array continue to work unchanged.」上面那個警告是「完全沒宣告生命週期」時才觸發的,4.107.0 只是把它建議的解法從 migrations 改成 exports

🔴 但這是一扇單向門:「Once a Worker has been deployed with exports, subsequent deploys cannot return to the legacy migrations array.」

本系列新專案一律用 exports,理由是它沒有 migration tag 的心智負擔(「current exports map is the source of truth」),而且重新命名與轉移是一等公民。

官方原文:

「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」。以硬性版本為準,並注意那條「未來會有遷移路徑」到今天仍未實現。

Location hints(11 個值):wnamenamsamweureeurapacapac-neapac-seocafrme

兩句官方警告值得直接引用:

「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.」

Jurisdictionseuusfedramp)則是強制的,不是提示。用 ns.jurisdiction("eu") 建立子命名空間。

項目FreePaid
物件數 / namespace無限無限
class 數 / 帳號100500
單一物件儲存10 GB10 GB
帳號總儲存5 GB無限
請求10 萬 / 日含 100 萬 / 月,之後 $0.15 / 百萬
Duration13,000 GB-s / 日含 40 萬 GB-s / 月,之後 $12.50 / 百萬 GB-s
Rows read500 萬 / 日含 250 億 / 月,之後 $0.001 / 百萬
Rows written10 萬 / 日含 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,76832,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 —— 是指引不是強制配額。但它就是你設計切分鍵時的天花板。


第 13 篇說過,其他選型錯誤都能增量修,切分鍵錯了要重寫。這裡給一套判準。

① 這個切分下,單一物件的請求速率會超過每秒 1,000 嗎?

一個 DO 沒辦法橫向擴展。如果你的切分讓某個物件變成熱點,你只能重新切分。

❌ 每個租戶一個 DO → 大租戶會變成瓶頸
✅ 每個連結一個 DO → 熱門連結各自獨立

② 需要一起原子更新的東西,有沒有被切到不同物件?

跨 DO 沒有原子性。如果 A 和 B 必須一起改,它們必須在同一個物件裡。

❌ 每個連結一個 DO,但配額是每租戶的 → 扣配額不是原子的
✅ 配額放在 TenantQuota DO,點擊放在 LinkCounter DO,兩件事本來就不需要原子

③ 這個切分下,會不會產生大量幾乎沒用到的物件?

物件數量本身沒有上限也沒有直接成本,但每個物件的儲存有 5 GB 的免費額度共享,而且冷啟動有成本。

狀態切分鍵理由
點擊計數link:{linkId}每個連結獨立;熱門連結不會拖累其他連結
即時看板dashboard:{tenantId}WebSocket 廣播天然是 per-tenant
使用者 sessionsession:{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/

Terminal window
cd examples/ch14-durable-objects && npm install && npm run dev
Terminal window
B=localhost:8787
curl -s "$B/addressing" # id 的確定性、name 的存活範圍
curl -s "$B/identity" # ctx.id.name
curl -s "$B/serialize" # 🔴 lostUpdates: true
curl -s "$B/parallel" # 不同物件是真的平行(0.4s 不是 0.9s)
curl -s "$B/persisted" # 累加語意不會遺失寫入
curl -s "$B/waituntil" # waitUntil 在 DO 裡是 no-op
curl -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 存起來

那個物件就永遠找不回來了。

exportsmigrations 同時寫

硬性錯誤。而且用了 exports 就回不去 migrations

⑦ 期待 location hint 是保證

是 best effort,而且只有第一次 get() 有效。要強制就用 jurisdiction。

⑧ 想在既有 class 上改儲存後端

不可變。而且 KV-backed 對新帳號已經關門。

⑨ 用保留的方法名

fetch 必須是 Request→Response;dupconstructor 禁用;alarmwebSocket* 不能透過 RPC 呼叫。

⑩ 一個 DO 服務全部流量

每秒 1,000 的軟上限,無法橫向擴展。


  1. **「單執行緒」不等於「不會交錯」。**input gate 只在 storage 操作期間關閉,其他 await 一律讓出執行權 —— 實測 lost update 真的會發生。
  2. **DO 解決的是協調問題。**只需要存東西的話,第 13 篇有更便宜的選項。
  3. **切分鍵是唯一無法增量修正的決定。**寫成 ADR,並記下「什麼情況下這個切分會不夠用」。


下一篇15. DO SQLite Storage:每個 object 一顆資料庫 —— 同步 API、真正的 transaction,以及一個「cursor 跨越 await 會看到別人寫入的資料」的陷阱。