前言¶
在 Cloudflare Workers 上做無狀態接口很直接:一個 fetch 處理請求,數據丟給 KV、D1 或 R2。一旦業務變成「同一間聊天室裏的人必須看到同一份消息」「兩個用戶不能訂同一時段」「對局裏的分數和回合順序不能各算各的」,這套寫法就會開始打架。普通 Worker 實例之間沒有共享內存,外部存儲又常常是最終一致;要自己做鎖、排隊和會話粘滯,成本不低。
Durable Objects(下面簡稱 DO)就是 Cloudflare 給這類問題準備的原語:每個實例有全局唯一名字、一份跟着實例走的存儲,以及可以掛 WebSocket、鬧鐘和 RPC 的執行上下文。難處在於約定多、接口還在變。getByName() 是 2025 年 8 月才進文檔的;新命名空間已經要求走 SQLite 後端;兼容日期 2024-04-03 之後官方更推薦 RPC,而不是在 DO 上繼續寫 fetch()。AI 編程助手如果只靠訓練數據,很容易寫出「一個全局 DO 扛全部流量」「關鍵狀態只放內存」「每次請求都包一層 blockConcurrencyWhile()」這類反模式。
Cloudflare 官方在 cloudflare/skills 倉庫裏維護了一條專項 Skill,名字就叫 durable-objects。它的作用不是再抄一遍產品介紹,而是在創建、審查、測試 DO 時,把分片方式、存儲、併發、RPC、鬧鐘和 Wrangler 配置約束進 Agent 的工作流,並且要求先查現行文檔,不要死記預訓練知識。
這是什麼¶
durable-objects 是 Cloudflare 官方出品的 Agent Skill,目錄在:
https://github.com/cloudflare/skills/tree/main/skills/durable-objects
YAML 頭裏的定位很直接:創建與審查 Cloudflare Durable Objects;在做有狀態協調(聊天室、多人遊戲、預訂系統)、實現 RPC、SQLite 存儲、鬧鐘、WebSocket,或按最佳實踐審查已有 DO 代碼時使用。覆蓋 Workers 集成、Wrangler 配置,以及用 Vitest 測試。同時寫明:偏向從 Cloudflare 文檔檢索,而不是依賴模型的預訓練知識。
它解決的是兩件很具體的事:
- Agent 知道「邊緣上要有狀態」,但不知道該按「一個協調單元一個 DO」來建模,還是誤用成全局單例。
- DO 的正確寫法分散在配置、存儲、併發和測試裏,訓練數據裏的
idFromName()+fetch()骨架已經不是官方首選。
同一倉庫裏還有總索引 Skill cloudflare,以及更偏部署的 wrangler。任務已經收斂到「寫 DO / 審 DO / 測 DO」時,應走 durable-objects 這一條。
Skill 目錄同樣是入口加按需參考:
skills/durable-objects/
├── SKILL.md
└── references/
├── rules.md # 分片、存儲、併發、RPC、鬧鐘、WebSocket
├── testing.md # Vitest、單元/集成測試、鬧鐘測試
└── workers.md # Worker 調用側、類型、wrangler、可觀測性
正文要求實現功能前先拉官方頁面,而不是把參考文件當最終 API 手冊:
| 資源 | 地址 |
|---|---|
| 文檔 | https://developers.cloudflare.com/durable-objects/ |
| API | https://developers.cloudflare.com/durable-objects/api/ |
| 最佳實踐 | https://developers.cloudflare.com/durable-objects/best-practices/ |
| 示例 | https://developers.cloudflare.com/durable-objects/examples/ |
核心約定¶
Skill 把「什麼時候用 DO」寫成一張對照表,和官方產品頁是對齊的:每個 DO 有全局唯一名字,存儲和計算在一起,因此可以在多個客戶端之間做協調,而不必自建序列化和鎖。
適合用 DO 的需求:
| 需求 | Skill 給的例子 |
|---|---|
| 協調 | 聊天室、多人遊戲、協作文檔 |
| 強一致 | 庫存、預訂、回合制對局 |
| 按實體存儲 | 多租戶 SaaS、按用戶拆數據 |
| 長連接 | WebSocket、即時通知 |
| 按實體定時 | 訂閱續期、對局超時 |
明確不要用 DO 的情況:
- 無狀態請求處理(用普通 Workers)
- 需要儘量鋪開到全球、而不是粘在同一個實例上
- 高扇出、彼此獨立的請求
核心規則寫在 SKILL.md 裏,references/rules.md 再展開:
- 按協調原子建模:一間聊天室、一局遊戲、一個用戶對應一個 DO,不要一個全局 DO。
- 用
getByName()做確定性路由:同一輸入落到同一實例。Cloudflare 在 2025-08-21 的 changelog 里正式加入該方法,官方入門文檔現在也用它,不必先idFromName()再get()。 - 用 SQLite 存儲:遷移裏配置
new_sqlite_classes。官方 changelog 也寫明:新 DO 命名空間必須走 SQLite 後端,不再允許新建 KV 後端命名空間。 - 只在構造函數里初始化:
blockConcurrencyWhile()只用來建表 / 跑 schema,不要套在每次請求上。 - 用 RPC 方法,而不是 DO 上的
fetch():兼容日期>= 2024-04-03。這和 Rules of Durable Objects 一致。 - 先持久化,再改內存緩存:實例被驅逐或崩潰時,內存會丟,SQLite 還在。
- 每個 DO 只有一個鬧鐘:
setAlarm()會覆蓋已有鬧鐘。
對應的反模式,Skill 寫成 NEVER:
- 單個全局 DO 處理全部請求(瓶頸)
- 每次請求都
blockConcurrencyWhile()(吞吐被打掉;參考文件按約 5ms 一次估算,上限大約 200 次/秒) - 關鍵狀態只放內存
- 相關的多次存儲寫入之間插入
await(打斷寫合併,不再是一次原子提交) - 在
blockConcurrencyWhile()裏做fetch()或其它外部 I/O
安裝與啓用¶
官方 README 寫明:這套 Skill 面向支持 Agent Skills 標準的助手,包括 Claude Code、Cursor、OpenCode、OpenAI Codex 和 Pi。安裝方式按工具分開,不要混用。durable-objects 在倉庫的 Skills 表裏,和 cloudflare、wrangler、agents-sdk 並列。
1、用 npx skills(跨工具通用)
安裝整個 Cloudflare Skills 集合:
npx skills add https://github.com/cloudflare/skills
只裝這一條時,officialskills.sh 上的頁面 和 skills.sh 給出的命令是:
npx skills add https://github.com/cloudflare/skills --skill durable-objects
2、Claude Code(插件市場)
/plugin marketplace add cloudflare/skills
/plugin install cloudflare@cloudflare
3、Cursor
倉庫 README 的寫法是:從 Cursor Marketplace 安裝,或在 Settings > Rules > Add Rule > Remote Rule (Github) 裏填 cloudflare/skills。Cloudflare 的 Cursor 接入文檔 另外給出斜槓命令 /add-plugin cloudflare,效果是裝上整套 Cloudflare Skills(其中包含 durable-objects)並註冊 MCP。Marketplace 上也能搜到同名 Skill。
4、克隆後按目錄拷貝
| 工具 | Skill 目錄 |
|---|---|
| Claude Code | ~/.claude/skills/ |
| Cursor | ~/.cursor/skills/ |
| OpenCode | ~/.config/opencode/skills/ |
| OpenAI Codex | ~/.codex/skills/ |
| Pi | ~/.pi/agent/skills/ |
拷進去的應是 skills/durable-objects/ 這一整個文件夾,保證 SKILL.md 和 references/ 的相對路徑還在。Agent 一般按描述自動啓用;提示詞裏出現聊天室、預訂、DO 綁定、鬧鐘或「幫我審查這段 Durable Object」時,就會對上這條 Skill。
典型用法¶
下面幾段都來自官方 SKILL.md 和三份參考文件,用來說明 Agent 加載之後應當怎麼寫,而不是另編一套教程。
1、先讓 Agent 按規則建模,再寫代碼¶
可以直接把觸發條件寫進提示詞:
請按 durable-objects Skill 幫我做一個按房間隔離的聊天后端。
一個房間一個 DO,用 getByName(roomId) 路由,SQLite 存消息,RPC 發消息。
不要用全局單例 DO,不要把關鍵狀態只放內存。
寫 wrangler 配置和測試前,先對照 Cloudflare Durable Objects 現行文檔。
Skill 自己列的觸發場景還包括:給已有 DO 做最佳實踐審查、配 wrangler.jsonc / wrangler.toml 的綁定和遷移、用 @cloudflare/vitest-pool-workers 寫測試、設計分片和父子 DO 關係。
2、Wrangler 綁定與 SQLite 類¶
Skill 入口給出的最小配置是:
// wrangler.jsonc
{
"durable_objects": {
"bindings": [{ "name": "MY_DO", "class_name": "MyDurableObject" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["MyDurableObject"] }]
}
references/workers.md 裏更完整的一份會同時寫 compatibility_date(RPC 需要 >= 2024-04-03)和多個綁定。wrangler.toml 的等價寫法是 [[durable_objects.bindings]] 加 [[migrations]]。
這裏有一處需要按 Skill 自己的「檢索優先」規則處理:官方 Getting started 現在用 exports 聲明 DO 類和 SQLite 存儲,並把舊的 migrations 數組標成 legacy。Skill 倉庫裏的示例仍是 new_sqlite_classes。寫新項目時,應讓 Agent 再拉一次現行文檔,而不是把參考文件裏的字段當唯一正確答案。
Worker 側要用綁定名拿到 stub。Skill 推薦的三種創建方式:
// 確定性路由,大多數場景優先用這個
const stub = env.MY_DO.getByName("room-123");
// 已有 ID 字符串
const id = env.MY_DO.idFromString(storedIdString);
const stub = env.MY_DO.get(id);
// 新的唯一 ID,映射關係要存到外部
const id = env.MY_DO.newUniqueId();
const stub = env.MY_DO.get(id);
對延遲敏感的場景,references/rules.md 還提到創建時可以帶 locationHint(如 wnam、enam、weur、apac)。具體參數以 API 文檔爲準。
3、SQLite + RPC 的基本骨架¶
Skill 給的最小可運行模式如下。類從 cloudflare:workers 的 DurableObject 繼承;構造函數里用 blockConcurrencyWhile() 建表;對外只暴露 RPC 方法。
import { DurableObject } from "cloudflare:workers";
export interface Env {
MY_DO: DurableObjectNamespace<MyDurableObject>;
}
export class MyDurableObject extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
data TEXT NOT NULL
)
`);
});
}
async addItem(data: string): Promise<number> {
const result = this.ctx.storage.sql.exec<{ id: number }>(
"INSERT INTO items (data) VALUES (?) RETURNING id",
data
);
return result.one().id;
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const stub = env.MY_DO.getByName("my-instance");
const id = await stub.addItem("hello");
return Response.json({ id });
},
};
幾點是參考文件反覆強調的:
- SQL API 是同步的:
this.ctx.storage.sql.exec(...),讀多行用.toArray(),讀一行用.one()。 - KV 風格的
storage.put/storage.get仍然可用,但是異步;新代碼優先 SQL。 - 相關寫入不要在中間
await,讓運行時把它們合併成一次提交。例如轉賬的扣款、入賬、流水三條sql.exec應連着寫。 - Worker 的
fetch可以保留,用來做 HTTP 入口;DO 類上的業務方法用 RPC。官方入門教程裏的sayHello()也是同一模式。
聊天室場景在 rules.md 裏寫成「一個房間一個 stub」:
const stub = env.CHAT_ROOM.getByName(roomId);
const msg = await stub.sendMessage("user-123", "Hello!");
需要層級時,父 DO 只記引用,子 DO 管自己的狀態。例如 GameServer.createMatch() 往自己的表裏插入 matchId,再 this.env.GAME_MATCH.getByName(matchId) 去初始化子對象。
Schema 演進不要用 PRAGMA user_version,DO 的 SQLite 不支持。參考文件給出的做法是自建 _sql_schema_migrations 表,在構造函數的 blockConcurrencyWhile() 裏按版本往前遷。生產項目可以再看 durable-utils 或 Cloudflare Actors 裏的同類工具,這是 Skill 原文點名的參考實現,不是這條 Skill 自帶的代碼。
4、鬧鐘、WebSocket 和測試¶
每個 DO 只能掛一個鬧鐘,適合「這個房間 / 這個租戶到期了再醒過來」:
await this.ctx.storage.setAlarm(Date.now() + 60_000);
async alarm(): Promise<void> {
// 處理到期任務;若還有後續,再 setAlarm
}
await this.ctx.storage.deleteAlarm();
失敗會自動重試,所以 handler 要做成冪等。WebSocket 走 Hibernation API:this.ctx.acceptWebSocket(...),再實現 webSocketMessage / webSocketClose,廣播時遍歷 getWebSockets()。
測試用 @cloudflare/vitest-pool-workers,在 Workers 運行時裏測,而不是拿普通 Node 測試去模擬 DO。Skill 的 testing.md 當前寫的安裝命令是:
npm i -D vitest@~3.2.0 @cloudflare/vitest-pool-workers
版本號以該參考文件和 npm 現行版本爲準,不要當成永久鎖定。最小用例如下,直接對 stub 調 RPC:
import { env } from "cloudflare:test";
import { describe, it, expect } from "vitest";
describe("MyDO", () => {
it("should work", async () => {
const stub = env.MY_DO.getByName("test");
const result = await stub.addItem("test");
expect(result).toBe(1);
});
});
同一份參考還覆蓋了:用 SELF.fetch 做 HTTP 集成測試、用 runInDurableObject() 看實例內部存儲、用 runDurableObjectAlarm() 立刻觸發鬧鐘、用 listDurableObjectIds() 列出命名空間裏的 ID。每個測試的存儲是隔離的,前一個用例創建的 DO 不會漏到下一個。跑測試:
npx vitest # watch
npx vitest run # 單次
適用場景與注意事項¶
比較適合這條 Skill 的情況:
- 要在 Workers 上做聊天室、多人協作、預訂/庫存、按租戶 SQLite、或帶共享狀態的 WebSocket。
- 已有 DO 代碼,想按官方規則查分片、併發和持久化問題。
- 需要同時寫出
wrangler綁定、RPC 方法和 Vitest。 - 助手經常把 DO 寫成「全局單例 + 內存狀態 + fetch 路由」,希望它改成現行約定。
不太適合、或需要換 Skill 的情況:
- 接口完全無狀態,用普通 Workers(或同倉的
cloudflare/workers-best-practices)即可。 - 任務是部署、綁定排錯、KV/R2/D1 資源管理,用
wrangler更直接。 - 目標是用 Agents SDK 做有狀態 AI Agent,用同倉的
agents-sdk。 - 這條 Skill 是給編碼助手的工作流,不會替你開通 Cloudflare 賬號,也不包含計費決策。
使用時幾條已經能交叉覈實的坑,值得寫進提示詞:
- 不要一個全局 DO。
getByName("global")會把所有協調壓到單實例上。 blockConcurrencyWhile()只用於初始化。參考文件寫明:不要在每次請求上用,也不要在持有它的時候做外部 I/O。- 相關寫入之間不要
await,否則寫合併被拆開,轉賬一類操作會丟原子性。 fetch()等非存儲 I/O 會放開交錯。從存儲讀出「pending」、打外部 API、再寫回「completed」之間,別的請求可以插進來;需要樂觀鎖或transaction()。- 未捕獲異常可能幹掉當前 DO 實例。內存狀態丟失,SQLite 仍在。
- 鬧鐘會覆蓋、失敗會重試,handler 必須冪等。
- 配置字段以現行文檔爲準。Skill 示例仍是
migrations+new_sqlite_classes;入門文檔已推薦exports裏"type": "durable-object"、"storage": "sqlite"。 - 身份怎麼拿到,也要再核一次。
rules.md仍建議 DO 不知道自己的 ID,用顯式init()傳入;changelog 後來寫明,經idFromName()/getByName()訪問時,對象內部可以通過ctx.id.name拿到同一個名字。Skill 自己的規則是:和文檔衝突時信文檔。
小結¶
durable-objects 這條 Skill 的價值,不在於把 Durable Objects 文檔再複製一遍,而在於給 Agent 一條可執行的約束:按協調原子分片、用 getByName() 路由、用 SQLite 持久化、用 RPC 而不是 DO 上的 fetch()、只用 blockConcurrencyWhile() 做初始化,並用 Vitest 在 Workers 運行時裏驗證。Cloudflare 把有狀態邊緣計算收成一種原語之後,真正容易寫錯的是模型邊界和併發,而不是「能不能 new 一個類」。
官方目錄:https://github.com/cloudflare/skills/tree/main/skills/durable-objects
倉庫說明與各工具安裝方式:https://github.com/cloudflare/skills
產品文檔:https://developers.cloudflare.com/durable-objects/