前言¶
在 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/