渡劫code 的终端房间是这个产品里最「魔法」的功能:浏览器里打开一个分享链接,几个人同时连同一个终端会话,A 输入的命令 B 实时看到,输出广播给所有人,断线重连后历史回放。这个功能的核心难点不是 WebSocket 本身——WebSocket 谁都会连——而是怎么让散落在全球的连接共享同一份有序状态。我们的答案是 Durable Objects。这篇从最小实现讲起,包含 hibernation 省钱的细节、单 DO 的广播上限,和三次翻车复盘。

一、为什么实时协作非 DO 不可

先算账。协作式终端的本质是「一个权威真相源 + N 个订阅者」,候选方案逐个枪毙:

方案死法
前端轮询 D1/KV延迟秒级,KV 免费读额度 10 万/天被几百个房间轮轰炸
无状态 Worker 里广播WebSocket 连到哪个实例纯随机,A 和 B 大概率不在同一实例,消息根本传不过去
自建 WebSocket 服务器独立开发者没有 24 小时在线的机器,宕机全断,违背边缘架构初衷
Durable Objects单实例、同地域、持久化存储、WebSocket 原生支持——唯一全都要的

DO 的模型刚好就是房间本身:一个房间 ID 对应一个 DO 实例,实例是单线程串行执行的,所有消息按顺序处理,天然不需要应用层加锁。加上每实例自带的持久化存储,历史回放也不用另找地方。唯一的门槛:DO 需要 Workers Paid 订阅($5/月起),免费计划用不了——这是我们对渡劫code 做付费化的第一个理由。

二、最小可用房间

wrangler.toml 里声明绑定和迁移:

[[durable_objects.bindings]]
name = "TERMINAL_ROOM"
class_name = "TerminalRoom"

[[migrations]]
tag = "v1"
new_classes = ["TerminalRoom"]

DO 类本体。注意这里用的是 WebSocket hibernation API(state.acceptWebSocket + 类方法处理器),不是 addEventListener——区别是后面「最值钱的优化」:

export class TerminalRoom {
  constructor(private state: DurableObjectState, private env: Env) {}

  async fetch(request: Request) {
    const url = new URL(request.url);
    const roomId = url.pathname.split('/').pop();
    if (!roomId) return new Response('missing room', { status: 400 });

    if (request.headers.get('Upgrade') !== 'websocket') {
      return new Response('expected websocket', { status: 426 });
    }

    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);

    // acceptWebSocket:纳入 hibernation 管理,附上用户标签
    this.state.acceptWebSocket(server);
    server.serializeAttachment({ joinedAt: Date.now(), roomId });

    // 新房客先补发历史(storage 里的滚动缓冲区)
    const history = (await this.state.storage.get<string[]>('history')) ?? [];
    for (const line of history.slice(-100)) server.send(line);

    this.broadcast({ type: 'presence', count: this.state.getWebSockets().length });
    return new Response(null, { status: 101, webSocket: client });
  }

  // 休眠友好的事件处理器:实例被逐出后由运行时唤醒再回调
  async webSocketMessage(ws: WebSocket, msg: string) {
    const event = JSON.parse(msg);
    // 串行保证:同一房间同时只有一个事件在处理,无需应用层锁

    // 先广播后落盘:实时性优先,容忍最后几条丢失
    this.broadcast(event);

    const history = (await this.state.storage.get<string[]>('history')) ?? [];
    history.push(msg);
    await this.state.storage.put('history', history.slice(-500)); // 环形缓冲
  }

  async webSocketClose(ws: WebSocket) {
    this.broadcast({ type: 'presence', count: this.state.getWebSockets().length });
  }

  private broadcast(msg: unknown) {
    const data = JSON.stringify(msg);
    for (const ws of this.state.getWebSockets()) {
      if (ws.readyState === WebSocket.OPEN) ws.send(data);
    }
  }
}

路由层从普通 Worker 进来,按房间 ID 拿 DO stub:

const id = env.TERMINAL_ROOM.idFromName(roomId);
const stub = env.TERMINAL_ROOM.get(id);
return stub.fetch(request); // 转发 WebSocket 升级请求

前端就是标准 WebSocket,加上断线重连时带 lastSeq 补发即可。整套最小实现两百行不到,但跑得起 2–8 人的真实协作。

三、最值钱的优化:WebSocket hibernation

第一版我们没用 hibernation,用 ws.addEventListener('message') 那套。上线一周看账单:DO 的 Duration GB-s 曲线是 24 小时不间断的阶梯——房间空闲时 DO 实例也不释放,按活跃时长持续计费,月账 $8 多。开启 hibernation 后:DO 在没有消息时被逐出内存,WebSocket 连接由运行时托管,空闲时长不计费、只按请求计费。同样流量,时长费用降到 $1 出头。

hibernation 的代价是编程模型变了,坑都在细节里:

  • 实例被逐出后,构造函数里的内存状态全丢。我们第一版在 constructor 里初始化 this.users = new Map(),休眠唤醒后变成空 Map,在线人数错乱。正解:连接列表用 state.getWebSockets() 取,用户信息用 ws.serializeAttachment() 挂在连接上,什么都不放实例字段。
  • 事件必须走类方法处理器(webSocketMessage/webSocketClose/webSocketError)。休眠中的连接再 addEventListener 会直接抛错——这是运行时的硬约束,不是代码风格问题。
  • DO 存储有单条上限:key 2KB、value 128KB。我们把历史缓冲区切成 log:{seq} 逐条存(key 小、value 小),用 storage.list({ prefix: 'log:', limit: 100, reverse: true }) 分页回放,别用一个 value 塞整个历史。
  • WebSocket 消息上限 1MiB。终端输出天然小,但用户粘贴大段文本时前端要先截断,否则发送失败且报错不明确。

四、三次翻车复盘

  1. 广播放大:早期每条终端输出都广播,某个跑着 tail -f 的房间一秒 50 条输出 × 6 个连接 = 每秒 300 条消息,DO CPU 和消息配额一起飘红。解法:前端做 100ms 合帧(多条输出合成一帧渲染),服务端按帧广播,输出量降一个数量级。终端的实时感来自合帧后的「跟手」,不是逐字节。
  2. 重连风暴:一次 DO 迁移(运行时升级)导致同房间 6 个连接同时断,客户端立即重连,6 个请求同时打到迁移中的实例,前几个失败、触发客户端更快重试,差点打成 DDoS。解法:客户端重连加指数退避 + 随机抖动(1s、2s、4s,±500ms 抖动),服务端迁移期间返回 503 让客户端退避而非报错弹出。
  3. 历史回放丢尾巴:断线重连的补发基于 lastSeq,但「先广播后落盘」的顺序意味着广播成功、落盘前崩溃的几条会丢。用户看到输出中间缺了一行。解法:落盘也带上 seq,补发时从存储读,接受「最多丢最后一条」的语义——终端场景用户能理解,协作文档场景就不能这么将就。

五、规模边界:一个 DO 能带多少连接

实测数据(单个终端房间 DO):

  • 并发 WebSocket:稳定 100+,我们线上房间 2–8 人,从没摸到上限
  • 广播扇出:100 连接 × 每秒 10 帧 = 每秒 1000 次 send,DO CPU 占用约 15%,没有压力
  • 存储增长:一个活跃房间每天约 2MB 日志,配 7 天保留(alarm 定时清理 log: 前缀旧 key)

再往上扩的正确姿势不是硬扛单实例,而是按房间 ID 分对象——DO 的 idFromName 就是天然分片键,一万个房间就是一万个独立实例,各自串行、互不干扰。真正会撞墙的是「一个房间里一万人」的场景(比如直播弹幕),那时候该上的是 fan-out 专型架构,而不是 DO。我们的渡劫code 是协作工具不是直播间,DO 的甜点区刚好全覆盖。

顺带一提,AI Agent 的房间和终端房间共用同一套 DO 底座,只是消息类型不同——Agent 循环的状态机细节在 AI 编程工作台架构 里展开,这篇讲的是实时通道本身。

小结

DO 做实时协作的三个共识:单实例串行就是免费的「应用层锁」,idFromName 就是天然的分片键,hibernation 是必开的省钱开关。反过来,内存状态不放实例字段、存储 key/value 守住 2KB/128KB 上限、重连必须退避加抖动——这些是拿三次翻车换的。终端房间只是起点,同一套底座可以长成协作画板、共享白板、多人游戏房间——DO 是独立开发者在边缘上玩实时协作的唯一正确答案。