说明:本文由 Codex 根据作者提供的主题、对话素材与 Cloudflare 官方文档辅助生成,属于 AI 生成/整理内容,非作者原创。请读者自行甄别并交叉验证。
Cloudflare Durable Objects 是 Workers 体系里用来处理“有状态协调”的能力。普通 Worker 更像无状态函数:请求来了执行一段逻辑,执行完就结束;Durable Object 则像一个带身份、带内存状态、带私有存储的小型服务实例。
本文已按 2026-08-07 的官方文档更新;尤其要注意,新的 Wrangler exports 配置已取代新项目中旧式 class lifecycle migration 的推荐写法。
它最适合解决这类问题:
- 一个聊天室里的消息和连接需要被同一个协调者管理。
- 一个游戏房间里的玩家状态需要按顺序更新。
- 一个库存项、座位、限流桶、锁,需要避免并发请求互相覆盖。
- 一个用户、租户、文档、任务队列,需要拥有自己的状态和定时任务。
先把一次调用的路径画清楚#
把 DO 理解为“一个可以按业务键精确找到的协调者”,而不是一张共享数据库表。调用端并不直接操作 class;它依次得到 namespace、ID 或 stub,再通过 RPC 或 HTTP 抵达某一个具体实例。
flowchart LR C["Client"] --> W["Worker:认证、校验、路由"] W --> N["ROOM namespace"] N --> I["DurableObjectId"] N --> S["DurableObjectStub"] S -->|"RPC 或 fetch()"| D["Room DO 实例"] D --> SQL["私有 SQLite"] D --> A["一个 Alarm"] D --> WS["可休眠 WebSocket"]
同一个 namespace 内,getByName(“room-123”) 总会定位到同一个逻辑对象;room-456 则是另一个对象,拥有自己的内存、SQLite 数据和 alarm。真正的并发串行化边界是这个“单个对象”,而不是整个 Worker。
一个 DO 对象是什么#
Durable Object 的代码是一个 class:
export class ChatRoom extends DurableObject<Env> {
// ...
}这个 class 不是一个具体对象,而是对象的模板。真正的 DO 对象来自 namespace:
const room = env.CHAT_ROOM.getByName("room-123");这里的 "room-123" 对应一个具体的 DO 对象。如果再调用:
const anotherRoom = env.CHAT_ROOM.getByName("room-456");那就是另一个 DO 对象。
可以这样理解:
Durable Object class: ChatRoom
├─ DO 对象: room-123
│ └─ 私有存储
│ └─ messages 表
│
└─ DO 对象: room-456
└─ 私有存储
└─ messages 表所以 DO 对象和数据库表不是一回事。DO 对象是一个可被寻址的运行实例;表是这个对象私有 SQLite 存储里的数据结构。
如果 room-123 和 room-456 都执行了同一段建表 SQL,它们都会有一张叫 messages 的表,但这两张表不共享数据。每个 Durable Object 的 storage 都是自己的私有存储,不能被其他 DO 对象直接访问。
基本配置#
新项目应在 Wrangler 配置中声明 binding 和 class lifecycle。当前推荐的声明式方式是 exports,并为新 namespace 选择 SQLite storage:
{
"name": "do-demo",
"main": "src/index.ts",
"compatibility_date": "2026-07-13",
"durable_objects": {
"bindings": [
{
"name": "CHAT_ROOM",
"class_name": "ChatRoom"
}
]
},
"exports": {
"ChatRoom": {
"type": "durable-object",
"storage": "sqlite"
}
}
}几个概念要分清楚:
class_name是代码里的 Durable Object class 名称。name是 Worker 里访问这个 namespace 的 binding 名称,例如env.CHAT_ROOM。exports.ChatRoom声明 class 当前是 live Durable Object,且 storage 是 SQLite。exports负责 class 的创建、改名、转移、删除等生命周期;它与旧的migrations数组不能在同一个 Worker 中混用。- 已在使用
migrations的 Worker 可以按官方迁移指南转换到exports,不需要搬运对象数据;一旦部署了exports,不能再回退到旧 migration 流程。
这和“给每个对象的私有 SQLite 表做 schema 升级”是两件事。前者由 Wrangler 管 namespace/class;后者应在 class 内用自己的 schema migration table 管理。不要依赖 PRAGMA user_version,该 pragma 不受 Durable Object SQLite 支持。
一页接口地图#
| 所在层 | 最常用接口 | 它实际解决的问题 |
|---|---|---|
| namespace | getByName(name) | 用稳定业务键找到同一个对象,最常用。 |
| namespace | idFromName()、newUniqueId()、idFromString()、get() | 在确定性 ID、随机 ID 与已保存 ID 之间转换,并拿到 stub。 |
| stub | await stub.publicMethod() | RPC 调用对象 class 的 public 方法;参数与返回值必须可序列化。 |
| stub | await stub.fetch(request) | 仅在需要 HTTP Request/Response 语义时调用。 |
| 当前对象 | ctx.id、ctx.storage、blockConcurrencyWhile() | 读取对象身份、私有持久化状态,并做极短的初始化。 |
| SQLite | storage.sql.exec()、transactionSync() | 执行同步 SQL,或把多条同步 SQL 显式包进事务。 |
| KV | storage.get/put/delete/list、storage.kv | 使用异步 KV;SQLite 对象也有同步 KV 子 API。 |
| 定时 | getAlarm()、setAlarm()、deleteAlarm() | 每个 DO 一个、至少一次执行的延后任务。 |
| 长连接 | acceptWebSocket()、getWebSockets() | 让连接随 DO 休眠并在新消息到来时唤醒。 |
Worker 侧接口:找到对象#
Worker 侧拿到的是 DurableObjectNamespace。它负责把业务 ID 转成某个具体 DO 对象的 stub。
常用接口如下:
env.CHAT_ROOM.getByName("room-123");
const id = env.CHAT_ROOM.idFromName("room-123");
env.CHAT_ROOM.get(id);
const uniqueId = env.CHAT_ROOM.newUniqueId();
const restoredId = env.CHAT_ROOM.idFromString(uniqueId.toString());
env.CHAT_ROOM.get(restoredId);getByName(name) 是最常用的方式。只要 name 相同,就会路由到同一个 DO 对象,适合聊天室 ID、用户 ID、租户 ID、订单 ID、资源 ID 这类天然稳定的业务键。
newUniqueId() 会生成随机唯一 ID。它适合“创建一个全新对象”的场景,但需要把 id.toString() 保存到外部地方,否则以后无法再找到同一个对象。
注意:创建 ID 不等于创建对象。Durable Object 是懒加载的,只有通过 stub 访问时,运行时才会真正启动对应对象。
Stub 接口:调用对象#
getByName() 或 get(id) 返回的是 stub。stub 是 Worker 调用具体 DO 对象的客户端。
现代 Durable Objects 推荐使用 RPC,也就是直接调用 class 上定义的 public 方法:
const stub = env.CHAT_ROOM.getByName("room-123");
const message = await stub.sendMessage("u1", "hello");对应的 DO class:
export class ChatRoom extends DurableObject<Env> {
async sendMessage(userId: string, content: string) {
return {
userId,
content,
createdAt: Date.now()
};
}
}这里的 sendMessage() 不是 Cloudflare 内置接口,而是业务自己定义的 RPC 方法。Cloudflare 会通过 stub 调用这个对象实例上的方法。
如果业务确实是 HTTP request/response 模型,也可以使用:
await stub.fetch(request);
await stub.fetch("https://do.internal/path");但对于普通业务逻辑,RPC 会更直接,也更容易获得 TypeScript 类型提示。
DO class 内部接口#
Durable Object class 继承自 DurableObject。它可以实现一些 handler 方法:
export class ChatRoom extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
}
async fetch(request: Request): Promise<Response> {
return new Response("ok");
}
async alarm(alarmInfo?: AlarmInvocationInfo): Promise<void> {
// alarm 到期后执行
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
// WebSocket 收到消息
}
async webSocketClose(
ws: WebSocket,
code: number,
reason: string,
wasClean: boolean
) {
// WebSocket 关闭
}
async webSocketError(ws: WebSocket, error: unknown) {
// WebSocket 错误
}
}这些 handler 的用途:
constructor:初始化对象,常用于建表、加载缓存。- public method:RPC 方法,例如
sendMessage()、getMessages()。 fetch():让 DO 像一个私有 HTTP server 一样响应请求。alarm():定时任务触发时执行。webSocketMessage():WebSocket Hibernation API 收到消息时执行。webSocketClose():连接关闭时执行。webSocketError():连接异常时执行。
ctx 接口:对象运行时状态#
在 DO 内部,this.ctx 是 DurableObjectState,用来访问当前对象的运行时状态和存储。
常用属性:
this.ctx.id;
this.ctx.storage;常用方法:
this.ctx.blockConcurrencyWhile(async () => {
// 初始化期间阻塞其他事件
});
this.ctx.acceptWebSocket(ws, ["room:123"]);
const sockets = this.ctx.getWebSockets();
const taggedSockets = this.ctx.getWebSockets("room:123");
this.ctx.setWebSocketAutoResponse(
new WebSocketRequestResponsePair("ping", "pong")
);
this.ctx.abort("reset object");blockConcurrencyWhile() 常见用法是在 constructor 里做初始化,例如建表或从 storage 恢复内存缓存。不要在每个普通请求上都套它,否则会降低吞吐。
acceptWebSocket() 和 getWebSockets() 属于 WebSocket Hibernation API。它们让 DO 在保持 WebSocket 连接的同时休眠,从而降低空闲连接成本。
waitUntil() 虽然在 DO 里存在,但和 Workers 里的语义不同。Durable Object 会在有进行中的工作或 I/O 时保持活跃,waitUntil() 不会额外延长它的生命周期。
Storage 接口:私有持久化存储#
SQLite-backed Durable Object 的 storage 在 this.ctx.storage 下。
SQL API#
SQL 是结构化数据最常用的接口:
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL
)
`);
const row = this.ctx.storage.sql
.exec<{ id: number }>(
"INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?) RETURNING id",
userId,
content,
Date.now()
)
.one();
const rows = this.ctx.storage.sql
.exec("SELECT * FROM messages ORDER BY created_at DESC LIMIT ?", 50)
.toArray();sql.exec() 返回 cursor,常用读取方式包括:
cursor.one(); // 取一行
cursor.toArray(); // 转成数组
cursor.next(); // 迭代读取
cursor.raw(); // 原始数组形式
SQL API 是同步的。连续的同步 SQL 写入在中间没有 await 时会被原子提交;需要明确的多语句事务时使用 this.ctx.storage.transactionSync(() => { … })。不要在 sql.exec() 中写 BEGIN、COMMIT 或 SAVEPOINT。
cursor 不应跨 await 持有。需要在异步操作后继续处理结果时,先调用 toArray() 得到普通数组;否则不能把 cursor 当作稳定快照。
SQLite-backed storage 也支持 databaseSize、PITR,也就是 point-in-time recovery,可以恢复过去一段时间内的对象存储状态。
KV API#
简单键值也可以直接放 storage:
await this.ctx.storage.put("count", 1);
const count = await this.ctx.storage.get<number>("count");
await this.ctx.storage.delete("count");
const entries = await this.ctx.storage.list({ prefix: "session:" });SQLite-backed Durable Objects 还支持同步 KV API:
this.ctx.storage.kv.put("count", 1);
const count = this.ctx.storage.kv.get<number>("count");一般建议:
- 结构化业务数据用 SQL 表。
- 少量简单配置、缓存、游标可以用 KV。
- 关键状态必须写 storage,不要只放内存。
Alarms 接口:对象自己的定时任务#
每个 DO 对象可以设置一个 alarm:
await this.ctx.storage.setAlarm(Date.now() + 60_000);
const alarmTime = await this.ctx.storage.getAlarm();
await this.ctx.storage.deleteAlarm();到时间后,运行时会调用这个对象的 alarm():
export class TaskQueue extends DurableObject<Env> {
async alarm(alarmInfo?: AlarmInvocationInfo) {
const tasks = this.ctx.storage.sql
.exec("SELECT * FROM tasks WHERE due_at <= ?", Date.now())
.toArray();
for (const task of tasks) {
await this.processTask(task);
}
}
}Alarm 是至少执行一次语义,失败后会重试,所以 alarm() 里的逻辑要尽量幂等。比如处理任务时先检查状态,处理完再标记完成,避免重试时重复扣款、重复发送通知。
WebSocket 接口:实时连接协调#
Durable Objects 很适合做聊天室和实时协作,因为同一个房间的连接可以集中到同一个对象里。
export class ChatRoom extends DurableObject<Env> {
async fetch(request: Request): Promise<Response> {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
this.ctx.acceptWebSocket(server);
return new Response(null, {
status: 101,
webSocket: client
});
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
for (const socket of this.ctx.getWebSockets()) {
socket.send(message);
}
}
}这个例子里,一个 DO 对象就是一个聊天室。所有连到这个聊天室的 WebSocket 都挂在这个对象上,收到消息后可以广播给同一个对象里的其他连接。
如果连接上需要保存用户 ID、房间角色、订阅信息等轻量状态,可以结合 WebSocket attachment 使用。需要持久化的重要业务状态仍然应该写入 storage。
完整示例:聊天室消息#
下面是一个最小的 TypeScript 示例,包含配置、RPC、SQL 存储和 Worker 入口。
import { DurableObject } from "cloudflare:workers";
export interface Env {
CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}
export class ChatRoom extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.ctx.blockConcurrencyWhile(async () => {
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL
)
`);
});
}
async sendMessage(userId: string, content: string) {
const createdAt = Date.now();
const row = this.ctx.storage.sql
.exec<{ id: number }>(
"INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?) RETURNING id",
userId,
content,
createdAt
)
.one();
return {
id: row.id,
userId,
content,
createdAt
};
}
async getMessages(limit = 50) {
return this.ctx.storage.sql
.exec(
"SELECT * FROM messages ORDER BY created_at DESC LIMIT ?",
limit
)
.toArray();
}
}
export default {
async fetch(request, env): Promise<Response> {
const url = new URL(request.url);
const roomId = url.searchParams.get("room") ?? "lobby";
const stub = env.CHAT_ROOM.getByName(roomId);
if (request.method === "POST") {
const body = await request.json<{ userId: string; content: string }>();
const message = await stub.sendMessage(body.userId, body.content);
return Response.json(message);
}
const messages = await stub.getMessages();
return Response.json(messages);
}
} satisfies ExportedHandler<Env>;这里最重要的是这句:
const stub = env.CHAT_ROOM.getByName(roomId);它决定了请求会进入哪个 DO 对象。room=lobby 是一个对象,room=dev 是另一个对象。它们执行同一份 ChatRoom 代码,但有不同的内存状态和私有 SQLite 存储。
设计原则#
Durable Objects 的设计重点不是“把所有数据放进一个对象”,而是找到合适的协调粒度。
比较好的粒度:
一个聊天室 -> 一个 DO
一个游戏房间 -> 一个 DO
一个用户会话 -> 一个 DO
一个租户 -> 一个 DO
一个库存项 -> 一个 DO
一个限流 bucket -> 一个 DO不好的粒度:
整个系统 -> 一个全局 DO
所有用户 -> 一个全局 DO
所有订单 -> 一个全局 DO单个全局 DO 会变成瓶颈,也会让故障影响范围变大。更合理的做法是按业务实体分片,让每个对象只协调自己负责的那部分状态。
常见坑#
把 DO 当成一张全局表#
DO 不是一张表,也不是数据库的一行。它更像“一个带代码的小型私有数据库实例”。如果要跨多个对象做全局查询,Durable Objects 本身不是最舒服的选择,可以考虑把索引或聚合数据写到 D1、R2、Analytics Engine 或其他外部系统。
只把关键状态放在内存#
内存状态只在对象没有被驱逐、没有重启、没有重新部署时保留。关键状态要先写 storage,再更新内存缓存。
在 constructor 里做太重的工作#
constructor 适合做轻量初始化,例如建表、恢复少量缓存。不要在 blockConcurrencyWhile() 里做长时间外部请求,否则会阻塞对象处理其他事件。
忘记 RPC 也需要 await#
stub 调用是跨对象调用,要 await:
await stub.sendMessage("u1", "hello");不要把它当成本地同步函数。
Alarm 不是 cron 的完全替代品#
Alarm 是某个 DO 对象自己的唤醒机制,并且一个对象同时只有一个 alarm。它适合每个实体自己的延迟任务,不适合一个全局复杂调度系统。
和 D1、KV、R2 的关系#
Durable Objects 解决的是“有状态协调”和“每个对象私有强一致存储”。
D1 更像一个 serverless SQL 数据库,适合全局业务数据、查询、报表、跨实体关系。KV 适合读多写少、最终一致的键值缓存。R2 适合对象存储,比如图片、文件、归档数据。
一个常见组合是:
Durable Object 负责协调和串行化写入
|
├─ 私有 SQLite 存当前实体状态
├─ D1 存全局索引和可查询数据
├─ R2 存大文件
└─ Queues / Workflows 做异步后台处理如果问题的核心是“同一个资源的请求必须按顺序协调”,DO 很合适。如果问题的核心是“我要对很多实体做复杂查询”,应该优先考虑数据库。
小结#
Durable Objects 可以按四层接口理解:
Worker 侧 namespace
getByName / idFromName / get / newUniqueId
Stub 调用层
RPC public methods / fetch
DO class 层
constructor / fetch / alarm / webSocketMessage / webSocketClose
State + Storage 层
ctx.id / ctx.storage / blockConcurrencyWhile / SQL / KV / Alarm / WebSocket Hibernation它的关键价值在于:为某个业务实体提供一个全局可寻址、可持久化、可协调的运行实例。
理解了“一个 DO 对象不是一张表,而是一个带私有存储的实例”之后,很多设计会自然清晰:表是对象内部的数据结构;对象是业务协调边界;namespace 是找到对象的入口;stub 是调用对象的客户端。