Cloudflare Durable Objects 是什么
一句话版 · 打比方 · 原理图 · 能跑的示例 · 本仓库 apps/bot 里怎么用的
一句话:Durable Object(DO)=
一个有名字、全球唯一、自带小数据库、同一时刻只处理一件事的 JS 对象。
你按名字找它,全世界所有请求都会打到同一个实例上——所以它天然适合做「协调者」:一个聊天室、一个用户、一个游戏房间、一个计数器。
1. 打个比方:银行柜台 vs 自助取款机
普通 Worker = 自助取款机群
全球几百个机房,每个请求随便找台空闲的机器处理,处理完就忘。无状态,没法保证两个请求看到同一份内存。想共享数据只能去外部数据库,还得自己处理并发冲突。
Durable Object = 专属客户经理
每个客户(名字,比如
user_123)只有一位经理,坐在一个固定位置。谁找
user_123
都被引到这位经理面前,排队一个个办。经理手边有自己的账本(持久存储),下班(休眠)后账本还在。
2. 原理:请求是怎么流到 DO 的
四个关键事实
① 全球唯一
同一个名字 / 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 当全局单例
所有流量串行排队。按实体拆名字。