1. Workers 的「状态短板」与 Durable Objects
到这一步为止,我们写的 Hono + Workers 代码都是无状态的:每个请求落到哪个节点是随机的,两个相邻的请求之间没有共享内存。
这个模型简单、好扩展,但它也有明显的盲区:
- AI 会话记忆:多轮对话需要一个「始终保留同一份对话历史」的地方
- WebSocket 长连接:连接本身是有状态的,不能被随机路由
- 实时协作:多个用户同时在线编辑文档、在同一个会议室里,需要协调
- 精确计数/限流:按秒/毫秒级限流的计数器,KV 的最终一致性搞不定
Durable Objects(DO) 就是为这些场景准备的。它是 Cloudflare 提供的「有身份的 Worker」:
- 每个 Object 有一个全局唯一的 ID,相同 ID 一定路由到同一个实例
- 实例有内存状态(重启之前一直保留)
- 有强一致的持久化存储(
ctx.storage),写入立即可读 - 支持 WebSocket 长连接,而且有专门的 Hibernation API 降低成本
普通 Worker 你可以理解成纯函数——进来一个请求,处理完就没了。DO 更像一个类的实例——有自己的 ID、有内存、有持久化存储,每次找它都是同一个。
2. 第一个 Durable Object:计数器
我们从一个最小的例子开始——一个按「房间 ID」分片的计数器。
2.1 定义 DO 类
01// cloudflare:workers 是 Cloudflare Workers 的内置模块,不是 npm 包02import { DurableObject } from 'cloudflare:workers'0304export class Counter extends DurableObject {05async increment(): Promise<number> {06// ctx.storage 是这个 Object 独占的强一致存储07const current = (await this.ctx.storage.get<number>('count')) ?? 008const next = current + 109await this.ctx.storage.put('count', next)10return next11}1213async get(): Promise<number> {14return (await this.ctx.storage.get<number>('count')) ?? 015}16}
几个要点:
DurableObject是 Cloudflare 提供的基类,从cloudflare:workers导入this.ctx.storage是这个实例独占的持久化存储(不是全局共享的)- 类上的方法可以被 Worker 远程调用(Cloudflare 把这叫 RPC,Remote Procedure Call),后面会看到调用方式
2.2 在 wrangler.jsonc 里配置
01{02"durable_objects": {03"bindings": [04{05"name": "COUNTER",06"class_name": "Counter"07}08]09},10"migrations": [11{12"tag": "v1",13"new_sqlite_classes": ["Counter"]14}15]16}
durable_objects.bindings告诉 Worker 这个名字对应哪个类migrations.new_sqlite_classes声明这是个新的 SQLite-backed DO 类(推荐的新格式,比旧的 KV-backed 更便宜)
2.3 在 Hono 里调用
01import { Hono } from 'hono'02import { Counter } from './rooms'0304type Bindings = {05COUNTER: DurableObjectNamespace<Counter> // Workers 内置类型,不需要 import06}0708const app = new Hono<{ Bindings: Bindings }>()0910app.post('/rooms/:roomId/click', async (c) => {11const roomId = c.req.param('roomId')1213// 1. 从 roomId 得到一个稳定的 DO ID14const id = c.env.COUNTER.idFromName(roomId)15// 2. 拿到这个 DO 的 stub(可以理解成远程引用)16const stub = c.env.COUNTER.get(id)17// 3. 调用它的方法,和调用本地对象一样18const count = await stub.increment()1920return c.json({ roomId, count })21})2223app.get('/rooms/:roomId/count', async (c) => {24const roomId = c.req.param('roomId')25const stub = c.env.COUNTER.get(c.env.COUNTER.idFromName(roomId))26const count = await stub.get()27return c.json({ roomId, count })28})2930// 必须 export DO 类,Workers 运行时才认得31export { Counter }32export default app
关键机制:idFromName(roomId) 对同一个字符串永远返回同一个 ID,也就永远路由到同一个实例。所以 rooms/abc/click 和 rooms/abc/count 一定落到同一个 Counter 实例,计数不会错乱。
stub.increment() 底层是一次跨节点的远程调用,但写法和调本地对象一样——你不需要关心这个 Counter 实例跑在全球哪个节点上。
3. WebSocket:AI 聊天的长连接
WebSocket 是 DO 最有代入感的用法。流式响应用 SSE 已经够用,但有些场景离不开双向通信:
- 语音对话(前端流式上传音频,后端流式推送识别结果 + 回答)
- 多端同步(用户在手机上发的消息,桌面同时更新)
- 协作式 AI(多个用户一起和 AI 对话)
Workers 的普通 fetch handler 不处理 WebSocket,必须用 DO。
3.1 Hibernatable WebSocket:一定要用的 API
Cloudflare 对 WebSocket 有两种 API:传统的 accept() 和 Hibernatable 版本 acceptWebSocket()。两者的关键差别:
- 传统版:连接期间 DO 实例必须常驻内存,空闲也按时间计费
- Hibernatable:DO 可以在没消息时进入休眠,休眠期间不计费,有消息到来时自动唤醒
对于「用户打开着一个聊天页面但 20 分钟没说话」这种场景,Hibernatable 每月能省掉绝大部分费用。新项目直接用 Hibernatable 就行。
3.2 最小的聊天 DO
01import { DurableObject } from 'cloudflare:workers'0203type Env = { AI: Ai }0405interface ChatMessage {06role: 'user' | 'assistant'07content: string08}0910export class ChatRoom extends DurableObject<Env> {11async fetch(request: Request): Promise<Response> {12// 1. 必须是 WebSocket upgrade 请求13if (request.headers.get('Upgrade') !== 'websocket') {14return new Response('Expected WebSocket', { status: 426 })15}1617// 2. 创建一对 WebSocket18const pair = new WebSocketPair()19const [client, server] = Object.values(pair)2021// 3. Hibernatable 模式接收服务端一侧22this.ctx.acceptWebSocket(server)2324// 4. 返回客户端一侧给上游25return new Response(null, { status: 101, webSocket: client })26}2728// Hibernatable 版本的消息处理器(注意不是 addEventListener)29async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {30if (typeof message !== 'string') return3132const userMsg: ChatMessage = { role: 'user', content: message }3334// 取出历史消息35const history = (await this.ctx.storage.get<ChatMessage[]>('history')) ?? []36history.push(userMsg)3738// 调 LLM39const result = await this.env.AI.run('@cf/meta/llama-3.1-8b-instruct', {40messages: history,41})4243// Workers AI 文本生成返回 { response: string },类型声明比较宽泛所以要断言一下44const assistantMsg: ChatMessage = {45role: 'assistant',46content: (result as { response: string }).response,47}48history.push(assistantMsg)4950// 保存历史51await this.ctx.storage.put('history', history)5253// 把回答推给客户端54ws.send(JSON.stringify(assistantMsg))55}5657async webSocketClose(ws: WebSocket, code: number) {58// 连接关闭时的清理逻辑59console.log(`WebSocket closed with code ${code}`)60}61}
这个 DO 做到了三件事:
- 接受 WebSocket 连接(Hibernatable 模式)
- 每条消息都存进持久化的历史
- 每条消息都触发一次 LLM 调用,把回答发回去
没有内存里的事件监听器——Hibernatable 模式下 Cloudflare 运行时帮你处理,实例可以随时被换出内存。
3.3 Hono 侧的 upgrade 入口
01import { Hono } from 'hono'02import { ChatRoom } from './chat-room'0304type Bindings = {05CHAT_ROOM: DurableObjectNamespace<ChatRoom>06AI: Ai07}0809const app = new Hono<{ Bindings: Bindings }>()1011app.get('/chat/:userId', async (c) => {12// 每个用户一个独立的 ChatRoom13const userId = c.req.param('userId')14const id = c.env.CHAT_ROOM.idFromName(userId)15const stub = c.env.CHAT_ROOM.get(id)1617// 把 upgrade 请求直接转给 DO,DO 自己处理 101 响应18return stub.fetch(c.req.raw)19})2021export { ChatRoom }22export default app
前端用标准的 WebSocket API 就能连:
1const ws = new WebSocket('wss://your-worker.workers.dev/chat/alice')23ws.onmessage = (e) => {4const msg = JSON.parse(e.data)5console.log('AI:', msg.content)6}78ws.send('你好,介绍一下 Cloudflare Workers')
4. 实战:多人聊天室
单用户的场景其实用不上「多连接」。真正发挥 DO 价值的是「多个人在同一个房间里」——比如几个人一起和一个 AI Agent 开会。
01import { DurableObject } from 'cloudflare:workers'0203type Env = { AI: Ai }0405export class MeetingRoom extends DurableObject<Env> {06async fetch(request: Request): Promise<Response> {07const url = new URL(request.url)08const username = url.searchParams.get('user') ?? 'anon'0910const pair = new WebSocketPair()11const [client, server] = Object.values(pair)1213// serializeAttachment 把数据附加到这个连接上14// DO 休眠再唤醒后,用 deserializeAttachment 能拿回来15server.serializeAttachment({ username })1617this.ctx.acceptWebSocket(server)1819return new Response(null, { status: 101, webSocket: client })20}2122async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {23if (typeof message !== 'string') return2425const { username } = ws.deserializeAttachment() as { username: string }26const payload = JSON.stringify({ from: username, text: message })2728// 广播给房间里所有其他连接29for (const client of this.ctx.getWebSockets()) {30if (client === ws) continue31try {32client.send(payload)33} catch {34// 客户端掉线时可能报错,忽略即可35}36}37}38}
关键 API:
serializeAttachment/deserializeAttachment:给每个连接挂自定义数据。因为 DO 可能休眠再唤醒,内存变量会丢失,但 attachment 会被序列化保留ctx.getWebSockets():拿到这个 DO 当前所有活跃的 WebSocket 连接,遍历一下就能做广播
5. Alarms:给 DO 加定时器
DO 还有一个相当实用的功能:Alarms。它允许你给这个实例设一个「几分钟/几小时后叫醒我」的闹钟,到点了 Cloudflare 会调用 alarm() 方法。
典型用途:AI 会话的「不活跃自动归档」。
01export class ChatRoom extends DurableObject<Env> {02async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {03// ... 处理消息 ...0405// 每次有消息,就把「闹钟」推到 30 分钟后06await this.ctx.storage.setAlarm(Date.now() + 30 * 60 * 1000)07}0809async alarm() {10// 30 分钟没消息了,归档历史并重置11const history = await this.ctx.storage.get('history')12if (history) {13await archiveToR2(history) // 保存到 R214await this.ctx.storage.delete('history')15}16}17}
Alarms 可以替代很多 cron 的小场景——它精确到单个 DO 实例,比全局定时任务更灵活。
6. 计费和注意事项
DO 的计费模型和普通 Worker 不同:
- 请求费:每百万请求 $0.15(付费版),和 Worker 一个量级
- Duration(时长):DO 在内存里活跃时按 GB-s 计费。Hibernatable WebSocket 在休眠期间不计时长
- Storage(存储):SQLite-backed DO 按行和字节计费,比老的 KV-backed 便宜一个量级
新建 DO 一律用 SQLite-backed 类(new_sqlite_classes),原因:便宜 + 未来迁移友好 + 支持 SQL 查询(ctx.storage.sql)。
6.1 什么时候不要用 DO
DO 不是什么都适合做,有几个明显不该用的场景:
- 全局单例计数器:DO 同一时间只能有一个实例,一个 Object 每秒几千请求就会变成瓶颈。要做「全站访客统计」用 Analytics Engine,不是 DO
- 任意键值缓存:用 KV 或 Cache API,DO 比它们贵
- 大规模数据存储:单个 DO 的 storage 有上限(10 GB),大库用 D1
怎么判断用不用 DO?看你的数据有没有自然的分片键。房间 ID、用户 ID、会话 ID、文档 ID——这些都适合。没有自然分片键的场景(比如全站汇总统计)通常不适合。
7. 小结
DO 的核心就三件事:
idFromName(string)让相同名字永远路由到同一个实例- 实例有内存状态 + 强一致的持久化存储(
ctx.storage) - Hibernatable WebSocket 让长连接在空闲时不花钱
对 AI 应用来说,最常见的用法是:每个用户或每个房间一个 DO 实例,存对话历史,用 WebSocket 做双向通信,用 Alarms 做会话超时归档。