Cloudflare Durable Objects 是什么

一句话版 · 打比方 · 原理图 · 能跑的示例 · 本仓库 apps/bot 里怎么用的

一句话:Durable Object(DO)= 一个有名字、全球唯一、自带小数据库、同一时刻只处理一件事的 JS 对象。 你按名字找它,全世界所有请求都会打到同一个实例上——所以它天然适合做「协调者」:一个聊天室、一个用户、一个游戏房间、一个计数器。

1. 打个比方:银行柜台 vs 自助取款机

普通 Worker = 自助取款机群

全球几百个机房,每个请求随便找台空闲的机器处理,处理完就忘。无状态,没法保证两个请求看到同一份内存。想共享数据只能去外部数据库,还得自己处理并发冲突。

Durable Object = 专属客户经理

每个客户(名字,比如 user_123)只有一位经理,坐在一个固定位置。谁找 user_123 都被引到这位经理面前,排队一个个办。经理手边有自己的账本(持久存储),下班(休眠)后账本还在。

2. 原理:请求是怎么流到 DO 的

手机 · 东京 网页 · 伦敦 手机 · 纽约 Worker(东京机房) Worker(伦敦机房) Worker(纽约机房) getByName("room-42") DO 实例 "room-42" 全球仅此一个 · 单线程 内存变量(会丢) 请求按顺序处理 SQLite 持久存储 首次被访问时在靠近请求方的机房「出生」,之后位置基本固定

四个关键事实

① 全球唯一

同一个名字 / ID 在任意时刻只对应一个活实例。不用分布式锁,「唯一」本身就是锁。

② 单线程

一个 DO 内 JS 是单线程跑的,且平台有 input/output gate:等存储读写时,别的请求事件不会插进来乱改状态。大部分竞态问题直接消失。

③ 自带存储

ctx.storage:推荐 SQLite 后端(ctx.storage.sql.exec(...)),也有 KV 风格 get/put。存储和计算在同一台机器,读写极快、强一致。

④ 会休眠

没请求一段时间就被回收,内存变量全丢,存储不丢。下次访问重新执行 constructor。所以:重要状态必须写 storage。

3. 最小示例:全球唯一计数器

三个文件:DO 类、Worker 入口、wrangler 配置。

src/index.ts

// DO 类:继承 DurableObject,方法可被 Worker 直接 RPC 调用
import { DurableObject } from "cloudflare:workers";

export class Counter extends DurableObject<Env> {
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    // 首次启动建表;blockConcurrencyWhile 保证建好前不处理任何请求
    ctx.blockConcurrencyWhile(async () => {
      ctx.storage.sql.exec(
        "CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v INTEGER)"
      );
    });
  }

  increment(): number {
    // 单线程:读-改-写之间不会被别的请求打断,不需要加锁
    const row = this.ctx.storage.sql
      .exec<{ v: number }>("SELECT v FROM kv WHERE k = 'count'")
      .toArray()[0];
    const next = (row?.v ?? 0) + 1;
    this.ctx.storage.sql.exec(
      "INSERT OR REPLACE INTO kv (k, v) VALUES ('count', ?)", next
    );
    return next;
  }
}

// Worker 入口:无状态,负责把请求转给对应名字的 DO
export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const name = new URL(req.url).searchParams.get("name") ?? "global";
    const stub = env.COUNTER.getByName(name);   // 按名字拿「遥控器」
    const n = await stub.increment();           // 像调本地方法一样 RPC
    return new Response(`${name}: ${n}`);
  },
};

wrangler.jsonc

{
  "name": "counter-demo",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
  "durable_objects": {
    "bindings": [{ "name": "COUNTER", "class_name": "Counter" }]
  },
  // 声明新 DO 类;用 SQLite 后端。migrations 只增不改
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }]
}

效果:东京、伦敦、纽约同时狂刷 ?name=global,数字严格 1、2、3… 不重不漏;换 ?name=alice 就是另一个独立计数器。

4. 另外两个杀手级能力

WebSocket 中枢聊天室 / 实时协作

所有连同一房间的 WebSocket 都挂在同一个 DO 上,广播就是遍历连接。用 ctx.acceptWebSocket(ws)(Hibernation API)时,连接保持而 DO 可以休眠,空闲不计时长费。

Alarm 闹钟定时任务 / 延迟重试

ctx.storage.setAlarm(Date.now() + 60_000),到点平台调用该 DO 的 alarm()。每个 DO 一个闹钟,持久化、失败自动重试。适合「每个用户自己的定时任务」。

export class Room extends DurableObject<Env> {
  async fetch(req: Request) {
    const [client, server] = Object.values(new WebSocketPair());
    this.ctx.acceptWebSocket(server);              // 可休眠的 WS
    return new Response(null, { status: 101, webSocket: client });
  }
  webSocketMessage(ws: WebSocket, msg: string) {   // 收到消息 → 广播
    for (const peer of this.ctx.getWebSockets()) peer.send(msg);
  }
}

5. 什么时候用 / 不用

适合 不适合
天然能按「某个东西」切分的状态:每用户、每房间、每文档、每订单 一个全局大热点(所有请求都打同一个 DO 会排队,单实例吞吐有限)
需要强一致 + 无锁并发:计数、限流、库存扣减、ticket 发放 大规模分析查询、跨所有用户 JOIN(用 D1 / 数仓)
WebSocket 聚合、实时协作、游戏房间 纯无状态 API(普通 Worker 就够)
每实体自己的定时任务(Alarm) 大文件(用 R2)

设计心法:「一个 DO = 一个实体」,用实体 ID 做名字。想要水平扩展,就是开更多 DO,而不是让一个 DO 更大。

6. 本仓库里的真实例子:apps/bot 的 BotOwner

Berth Bot 就是按这个心法设计的(ADR-0188):DO 名 = 用户的 user_id,这个用户的所有 bot / 线程 / Run / 凭证都存在这一个 DO 的 SQLite 里。

概念 仓库里的位置
DO 类 apps/bot/src/owner/BotOwner.ts — class BotOwner extends DurableObject<Env>
构造期建表 / 迁移 constructor 里 ctx.blockConcurrencyWhile(() => this.openDatabase()),迁移失败就抛错不服务
RPC 方法 checkIn(identity)、issueWsTicket(identity, botId)
Worker 调用 apps/bot/src/routes/connection.ts:34 — c.env.BOT_OWNER.getByName(owner.userId).issueWsTicket(...)
绑定 + 迁移 apps/bot/wrangler.jsonc — BOT_OWNER → BotOwner,migrations: [{ tag: "v1", new_sqlite_classes: ["BotOwner"] }](注释写着「只增不改」,有快照测试守着)
身份防串 ensureOwner 校验 ctx.id.name === identity.userId,路由写错直接炸

分工很清晰:Worker 管会话校验和白名单(无状态),DO 管某一个用户的全部状态(有状态、单线程、强一致)。

7. 常见坑

把状态只放内存

DO 休眠或重启后内存清零。内存只当缓存,真相在 storage。

constructor 里 await 外部请求

会卡住整个 DO。blockConcurrencyWhile 只用来做快速初始化(建表、迁移)。

改 migrations

已发布的 tag 不能改,只能追加新 tag(重命名 / 删类也走新 migration)。

一个 DO 当全局单例

所有流量串行排队。按实体拆名字。