Durable Objects 使用方式与接口整理

This article is extracted from the chat log with AI. Please identify it with caution.

说明:本文由 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-123room-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 支持。

一页接口地图#

所在层最常用接口它实际解决的问题
namespacegetByName(name)用稳定业务键找到同一个对象,最常用。
namespaceidFromName()newUniqueId()idFromString()get()在确定性 ID、随机 ID 与已保存 ID 之间转换,并拿到 stub。
stubawait stub.publicMethod()RPC 调用对象 class 的 public 方法;参数与返回值必须可序列化。
stubawait stub.fetch(request)仅在需要 HTTP Request/Response 语义时调用。
当前对象ctx.idctx.storageblockConcurrencyWhile()读取对象身份、私有持久化状态,并做极短的初始化。
SQLitestorage.sql.exec()transactionSync()执行同步 SQL,或把多条同步 SQL 显式包进事务。
KVstorage.get/put/delete/liststorage.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.ctxDurableObjectState,用来访问当前对象的运行时状态和存储。

常用属性:

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() 中写 BEGINCOMMITSAVEPOINT

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 是调用对象的客户端。

参考资料#

本文共 5325 字,创建于 Jul 13, 2026

相关标签: Cloud, DevOps, TypeScript, ByAI