Serverless 服务无状态、不支持长连接,但业务偏偏需要 WebSocket。为此专门开一台常驻服务器?Cloudflare Durable Objects 提供了一种按需唤醒的有状态服务,完美填补了这个空缺。本文先讲清通用模式,再以一个在线文档协同编辑的真实案例展示具体实现。
一、背景:Serverless 的长连接困境
越来越多的应用选择 Serverless 架构部署:阿里云 FC、AWS Lambda、Vercel、Cloudflare Pages 等。Serverless 天然适合"用完即走"的 HTTP 请求——没有流量时实例缩至零,有请求时瞬间拉起。无论是何种规模的项目,这种弹性都极具吸引力:
- 独立开发者 / 个人展示案例:不为"没人访问时也得跑着"的服务器付费
- SaaS 产品初期:用户量未起来前不需要预置服务器资源,按量付费轻装上阵
- 企业内部工具:OA 审批、数据看板等使用频率低但不能下线的系统
- IoT 与数据采集:设备偶尔上报数据,大部分时间空闲
但一旦业务需要引入 实时交互 能力——协同编辑、在线客服、消息推送、多人白板、游戏房间——问题就来了:
| 需求 | Serverless | 传统服务器 |
|---|---|---|
| HTTP 请求 | ✅ 完美适合 | ✅ 当然可以 |
| WebSocket 长连接 | ❌ 无状态,不支持 | ✅ 可以 |
| 空闲时成本 | ✅ 零成本 | ❌ 一直在跑 |
| 有状态的"房间" | ❌ 实例随时可能被回收 | ✅ 进程常驻 |
实时功能需要 WebSocket 长连接 + 有状态的房间管理,这两点恰恰是 Serverless 的硬伤。
传统做法是单独开一台常驻服务器跑 WebSocket 服务。但对于并发量不高的场景——初创产品的协同编辑、企业内部的实时通知、个人项目的多人互动——7×24 小时为可能只有几条连接的服务付月费,性价比太低。
Cloudflare Durable Objects 为什么适合?
| 特性 | 说明 |
|---|---|
| 有状态 | 每个 DO 实例是一个独立的"房间",拥有自己的内存和存储 |
| 按需唤醒 | 空闲时自动休眠、有请求时自动恢复,不占资源 |
| 全球路由 | 同一个 ID 的请求无论从哪个边缘节点进来,都路由到同一个 DO 实例 |
| 原生 WebSocket | 支持 WebSocket Hibernation API,连接保持但不占内存 |
| 零运维 | 无需管服务器、容器、进程守护 |
于是方案很清晰:主应用继续跑 Serverless(处理 HTTP),WebSocket 服务单独拆到 Cloudflare Durable Objects。两者各司其职。
二、整体架构
┌───────────────────────────────────────────────────────────────┐
│ 客户端 (Browser) │
│ │
│ ┌──────────────┐ ┌────────────────────────────────┐ │
│ │ 主应用页面 │ │ WebSocket 客户端 │ │
│ │ (HTTP 请求) │ │ (实时双向通信) │ │
│ └──────┬───────┘ └────────────┬───────────────────┘ │
└─────────┼───────────────────────────────┼─────────────────────┘
│ HTTPS │ WSS
▼ ▼
┌──────────────────┐ ┌──────────────────────────────┐
│ 你的 Serverless │ │ Cloudflare Workers │
│ 主应用 │ │ │
│ ├ 页面 / API │◄─ HTTP ─►│ Worker 入口 (index.ts) │
│ ├ 数据库 │ 回调 │ └ 根据 ID 路由到 DO │
│ └ 内部 API │ │ │
│ (加载/保存) │ │ Durable Object │
│ │ │ ├ 维护房间的内存状态 │
│ │ │ ├ 管理多个 WebSocket 连接 │
│ │ │ ├ 广播消息给房间内所有人 │
│ │ │ └ 回调主应用持久化数据 │
└───────────────────┘ └──────────────────────────────┘
核心思路:
- 一个 DO 实例 = 一个"房间":所有同一 ID(文档 ID、聊天室 ID、游戏房间 ID 等)的 WebSocket 连接都路由到同一个 DO。
- DO 内存中维护实时状态:这个状态可以是文档内容、聊天记录、游戏状态……取决于你的业务。
- 持久化仍在主应用:DO 通过 HTTP 回调主应用的 API 来加载/保存数据,DO 本身不做长期存储。
三、项目创建与部署
3.1 初始化项目
mkdir my-websocket-service && cd my-websocket-service
npm init -y
npm install wrangler typescript --save-dev
# 安装你业务需要的库,例如 yjs、socket 消息解析库等
项目结构:
my-websocket-service/
├── src/
│ ├── index.ts # Worker 入口:路由请求到 DO
│ └── my-room-do.ts # Durable Object:房间逻辑
├── wrangler.toml # Cloudflare 配置
├── tsconfig.json
└── package.json
3.2 配置 wrangler.toml
name = "my-websocket-service"
main = "src/index.ts"
compatibility_date = "2024-12-01"
compatibility_flags = ["nodejs_compat"] # 如果用到 Node.js 库
placement = { mode = "smart" } # 智能就近部署(详见第六节)
# Durable Objects 绑定
[durable_objects]
bindings = [
{ name = "MY_ROOM_DO", class_name = "MyRoomDO" }
]
# DO 迁移配置(首次部署必须)
[[migrations]]
tag = "v1"
new_sqlite_classes = ["MyRoomDO"]
# 敏感环境变量通过 Dashboard 或 wrangler secret 设置,不提交到代码库
[vars]
# MAIN_APP_URL = "https://your-app.com"
# API_SECRET = "your-secret"
注意:
[[migrations]]是 Durable Objects 的必要配置,首次部署必须包含。
new_sqlite_classesvsnew_classes踩坑:Cloudflare DO 有两种存储后端:
new_classes(旧版)—— 使用 KV-style 存储 API(注意:这不是 Workers KV 产品,而是 DO 内置的键值存储接口),仅付费计划可用new_sqlite_classes(新版)—— 每个 DO 内置 SQLite,Free 套餐可用,也是 Cloudflare 推荐的方向如果你用的是 Free 套餐却写了
new_classes,部署时会报错code: 10097。改成new_sqlite_classes即可。两者在 WebSocket + 内存状态管理的场景下行为完全一致,不影响业务逻辑。
3.3 部署方式一:Wrangler CLI
npx wrangler login # 登录 Cloudflare(首次)
npx wrangler dev # 本地开发 → localhost:8787
npx wrangler deploy # 部署到线上
# 设置线上环境变量
npx wrangler secret put MAIN_APP_URL
npx wrangler secret put API_SECRET
3.4 部署方式二:关联 GitHub 自动部署(推荐)
每次 git push 自动触发构建部署,更适合正式项目。
第一步:Cloudflare 账号初始化
如果你是新注册的 Cloudflare 账号,初始引导页会让你选择起步方式:
- 选择 "Start with code or a template"(Workers Compute)—— Durable Objects 是 Workers 平台的一部分
- 另一个选项是 Pages(静态站点部署),不适合 DO 场景
- 当然也可以直接点 Skip 跳过引导,进入 Dashboard 后手动创建
第二步:关联 GitHub 仓库
- 将代码推送到 GitHub 仓库
- 进入 Cloudflare Dashboard → Workers & Pages → Create
- 切换到 Workers 标签页 → 选择 Import a Repository
- 授权 GitHub 账号,选中你的 WebSocket 服务仓库(注意不要选错成主项目仓库)
- 构建配置:
- Build command:
npm install(或留空,Cloudflare 会自动安装依赖) - Deploy command:
npx wrangler deploy(通常已自动填好)
- Build command:
- 点击 Deploy
第三步:配置环境变量
部署成功后,进入 Worker → Settings → Variables and Secrets,添加业务所需的环境变量(如 MAIN_APP_URL、API_SECRET 等)。这些变量不会出现在代码库中。
此后每次向 main 分支推送代码,Cloudflare 都会自动拉取、构建、部署,整个 CI/CD 流程通常在 30 秒内完成。
⚠️ 踩坑提醒:使用 GitHub 自动部署时,在控制面板手动修改的配置(如 Smart Placement 的 Runtime 选项)会在下次构建时被
wrangler.toml覆盖回默认值。所有配置都应落到代码中,让代码成为唯一的配置来源。⚠️
wrangler deploy会清除 Dashboard 手动设置的明文变量:如果你只在 Cloudflare Dashboard 的"Variables and Secrets"页面填写了明文变量(如MAIN_APP_URL),执行wrangler deploy后这些变量会被完全清空——Cloudflare 以本地wrangler.toml为准,[vars]中没有的就视为"已删除"。
类型 设置方式 重部署后是否保留 Variables(明文) 仅 Dashboard 手动填写 ❌ 会被清除 Variables(明文) 写入 wrangler.toml[vars]✅ 随部署生效 Secrets(加密) npx wrangler secret put KEY✅ 永远不被覆盖 正确做法:明文配置写
wrangler.toml,密钥用wrangler secret put。
四、核心实现:DO + WebSocket 通用模式
本节展示的是一个 通用的 DO + WebSocket 骨架,适用于任何需要"房间"概念的实时应用。第八节会以协同编辑为例展示如何填充具体业务逻辑。
4.1 Worker 入口:将请求路由到正确的 DO
// src/index.ts
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// 健康检查
if (url.pathname === '/' && !request.headers.get('Upgrade')) {
return new Response(JSON.stringify({ status: 'ok' }));
}
// 从 URL 中提取房间 ID(你的业务决定如何传递)
const roomId = url.pathname.split('/').filter(Boolean).pop();
if (!roomId) {
return new Response('Missing room ID', { status: 400 });
}
// 核心:idFromName 保证相同 roomId → 同一个 DO 实例
const durableId = env.MY_ROOM_DO.idFromName(roomId);
const stub = env.MY_ROOM_DO.get(durableId, { locationHint: 'apac' });
return stub.fetch(request);
},
};
idFromName(roomId) 是 Durable Objects 的关键 API:它将业务标识映射为全局唯一的 DO 实例。无论请求从全球哪个边缘节点进来,同一个 roomId 始终路由到同一个 DO。
4.2 Durable Object 骨架:WebSocket 房间管理
// src/my-room-do.ts
export class MyRoomDO implements DurableObject {
private state: DurableObjectState;
private env: Env;
constructor(state: DurableObjectState, env: Env) {
this.state = state;
this.env = env;
}
// ① 处理新连接
async fetch(request: Request): Promise<Response> {
if (request.headers.get('Upgrade') !== 'websocket') {
return new Response('Expected WebSocket', { status: 426 });
}
// 在这里可以做鉴权、加载初始数据等
// 创建 WebSocket 对
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// 关键:使用 Hibernation API 接受连接
// 这让 DO 在空闲时可以释放内存,但 WebSocket 不断开
this.state.acceptWebSocket(server);
// 可选:给新连接发送初始状态
// server.send(JSON.stringify({ type: 'init', data: this.currentState }));
return new Response(null, { status: 101, webSocket: client });
}
// ② 处理客户端发来的消息
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
// 根据你的业务协议解析消息
// 然后广播给房间内其他人
this.broadcast(message, ws);
}
// ③ 处理连接断开
async webSocketClose(ws: WebSocket) {
const remaining = this.state.getWebSockets();
if (remaining.length === 0) {
// 房间空了,执行清理/保存操作
}
}
// ④ 广播:发给房间内除发送者外的所有人
private broadcast(message: string | ArrayBuffer, exclude?: WebSocket) {
for (const ws of this.state.getWebSockets()) {
if (ws !== exclude) {
try {
ws.send(message);
} catch {}
}
}
}
}
这就是 DO + WebSocket 的完整骨架。任何实时应用——聊天室、游戏房间、协同编辑、实时看板——都是在这个骨架上填充 webSocketMessage 里的业务逻辑。
4.3 与主应用的数据桥梁
DO 本身不直接访问你的数据库。推荐通过 HTTP 回调主应用的内部 API:
// 主应用:/api/internal/route.ts
// 验证请求来源(共享密钥)
function verifySecret(request: Request): boolean {
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
return token === process.env.API_SECRET;
}
// GET → DO 启动时加载数据
export async function GET(request: Request) {
if (!verifySecret(request)) return Response.json({ error: 'Unauthorized' }, { status: 401 });
const id = new URL(request.url).searchParams.get('id');
const data = await db.findById(id);
return Response.json(data);
}
// POST → DO 将状态保存回数据库
export async function POST(request: Request) {
if (!verifySecret(request)) return Response.json({ error: 'Unauthorized' }, { status: 401 });
const body = await request.json();
await db.update(body.id, body.data);
return Response.json({ success: true });
}
安全性通过共享密钥 API_SECRET 保证,不经过用户 session 认证(这是服务间调用)。
五、Hibernation API:省钱的关键
Durable Objects 按 内存驻留时间 计费。如果 DO 实例一直占着内存等待消息,成本会很高。
Cloudflare 提供了 WebSocket Hibernation API:
// ❌ 传统方式:DO 一直在内存中等待
ws.addEventListener('message', handler);
// ✅ Hibernation API:DO 可以在空闲时释放内存
this.state.acceptWebSocket(server);
使用 acceptWebSocket 后,当没有消息时,DO 可以释放内存进入休眠。新消息到达时自动唤醒,调用 webSocketMessage 回调。WebSocket 连接不会断开,但 DO 不占内存。
大部分时间没有消息传输时,DO 处于休眠状态,几乎零成本。无论是个人项目还是初创产品的低频实时功能,都不必为空闲时间买单。
六、部署优化:Smart Placement + locationHint
问题
主应用部署在某个固定区域(例如阿里云新加坡),而 Cloudflare Workers 默认在离用户最近的边缘节点执行。如果 DO 实例分配在美国,那 DO 每次回调主应用的 HTTP 请求就要跨越太平洋——导致 连接超时 或 首次加载缓慢。
解决方案:双管齐下
1. Smart Placement(智能就近部署)
# wrangler.toml
placement = { mode = "smart" }
Cloudflare 会分析 Worker 的网络请求模式,自动将 DO 实例部署到离你后端服务最近的数据中心。
2. locationHint(区域倾向提示)
const stub = env.MY_ROOM_DO.get(durableId, { locationHint: 'apac' });
apac 提示 Cloudflare 优先在亚太区域创建 DO 实例。可选值包括 wnam(北美西部)、enam(北美东部)、weur(西欧)、eeur(东欧)、apac(亚太)等。
⚠️ 再次提醒:使用 GitHub 自动部署时,
placement必须写在wrangler.toml里,否则每次构建会被重置为 Default。
七、本地开发环境
本地开发时,两个服务形成独立闭环,互不影响线上:
| 组件 | 配置 | 来源 |
|---|---|---|
| 主应用前端 | WS_URL=ws://localhost:8787 | .env |
| Wrangler Worker | MAIN_APP_URL=http://localhost:3000 | .dev.vars |
# 终端 1:启动主应用
cd my-app && npm run dev # → localhost:3000
# 终端 2:启动 WebSocket 服务
cd my-websocket-service && npx wrangler dev # → localhost:8787
线上环境通过各平台的环境变量覆盖,本地 .env 和 .dev.vars 不影响生产。
💡 简化方案:如果你不想本地跑
wrangler dev,也可以让前端直接连线上的 Cloudflare Worker(wss://xxx.workers.dev)。这时只需确保 DO 环境变量中的MAIN_APP_URL指向线上地址即可。这种方式省去了本地启动 WebSocket 服务的步骤,适合前端开发调试。
八、实战案例:协同编辑的业务逻辑
本节是协同编辑场景的具体实现。如果你的业务是聊天室、游戏或其他场景,可以跳过本节,只需替换第四节骨架中的消息处理逻辑即可。
我的项目 入木 AI 是一个在线文档编辑器,主应用基于 Next.js 部署在阿里云 Serverless FC(新加坡),需要为其加上多人实时协同编辑能力。
为什么不把 Next.js 整体迁到 Cloudflare?
Cloudflare 有 next-on-pages 等方案可以跑 Next.js,但它运行在 Edge Runtime 上,有些限制:
- 不能用 Node.js 原生模块(
fs、net、crypto等) - Prisma ORM 需要改用 edge-compatible driver
next/image等中间件行为不一致- NextAuth、邮件服务等第三方库需要逐一适配
迁移成本远大于收益。所以我选择了最小侵入方案:只把 WebSocket 服务拆出来部署到 Cloudflare Durable Objects,主应用保持不动——这也正是本文前四节介绍的架构模式。
技术选型
| 组件 | 技术 | 作用 |
|---|---|---|
| CRDT 引擎 | Yjs | 无冲突的文档状态合并 |
| 富文本编辑器 | Tiptap (ProseMirror) | 编辑器 UI + Yjs 绑定 |
| WebSocket 客户端 | @hocuspocus/provider | 管理与 DO 的连接、Yjs 同步 |
| WebSocket 服务端 | Cloudflare DO | 文档房间管理 |
8.1 Yjs 同步协议
在第四节的骨架中,webSocketMessage 里需要处理两种消息:
| 消息类型 | 作用 |
|---|---|
| Sync 消息 (type=0) | 文档内容同步:新用户加入时的全量同步 + 之后的增量更新 |
| Awareness 消息 (type=1) | 用户状态广播:光标位置、在线头像、编辑状态 |
Yjs 的三步握手(新用户加入时):
新用户 A Durable Object
│ │
│◄──── Sync Step 1 ────────────│ (DO 发送自己的 stateVector)
│ │
│───── Sync Step 2 ───────────►│ (A 根据 stateVector 计算差异发回)
│ │
│◄──── Sync Update ────────────│ (DO 把其他用户的更新推给 A)
│ │
▼ 此后双向实时增量同步 ▼
8.2 DO 中的业务逻辑填充
在第四节的通用骨架基础上,协同编辑的 DO 需要:
export class CollaborateDO implements DurableObject {
private doc: Y.Doc; // Yjs 文档对象,维护在内存中
private docLoaded = false;
constructor(state: DurableObjectState, env: Env) {
this.doc = new Y.Doc();
// 监听文档变更 → 广播增量更新 + debounce 保存
this.doc.on('update', (update, origin) => {
this.broadcast(encodeSyncUpdate(update), origin as WebSocket);
this.debounceSave(); // 5秒内无新编辑则保存到数据库
});
}
async fetch(request: Request) {
// 首次连接时从主应用加载文档
if (!this.docLoaded) await this.loadDocument();
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
this.state.acceptWebSocket(server);
// 给新客户端发送 Sync Step 1,启动三步握手
server.send(encodeSyncStep1(this.doc));
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws: WebSocket, message: ArrayBuffer) {
const { messageType, decoder } = decodeMessage(message);
switch (messageType) {
case MESSAGE_SYNC: // 文档同步
const reply = handleSyncMessage(decoder, this.doc, ws);
if (reply) ws.send(reply);
break;
case MESSAGE_AWARENESS: // 光标/在线状态
const data = readVarUint8Array(decoder);
this.broadcast(encodeAwarenessUpdate(data), ws);
break;
}
}
async webSocketClose() {
if (this.state.getWebSockets().length === 0) {
await this.saveDocument(); // 最后一人离开 → 保存
}
}
}
8.3 前端接入
前端只需几行代码即可接入:
import { HocuspocusProvider } from '@hocuspocus/provider';
import * as Y from 'yjs';
const ydoc = new Y.Doc();
const provider = new HocuspocusProvider({
url: `wss://my-websocket-service.workers.dev/${docId}`,
name: docId,
document: ydoc,
connect: false, // 不自动连接,由 useEffect 手动管理
});
// 在合适的时机手动连接(如 React useEffect 中)
provider.connect();
// 组件卸载时断开连接
// provider.disconnect();
// provider.destroy();
Tiptap 编辑器通过两个扩展完成协同绑定:
Collaboration— 将编辑器绑定到 Y.Doc,编辑操作自动转化为 Yjs 更新CollaborationCursor— 通过 Awareness 协议同步各用户的光标位置和信息
⚠️ Yjs 重复导入警告:如果你在控制台看到
Yjs was already imported. This breaks constructor checks...,说明打包工具把 yjs 打包了多份(常见于 monorepo 或依赖树中有多个 yjs 版本)。解决方法是在 bundler 配置中添加resolve.alias,确保所有模块引用同一份 yjs。
九、DO vs Node.js 服务端:设计取舍
选择 Cloudflare DO 意味着放弃了标准 Node.js 环境。如果你的 WebSocket 服务使用 @hocuspocus/server 部署在 VPS 上,以下问题根本不会出现。但在 DO 的 Edge Runtime 中,有几个地方需要换一种思路来解决。
9.1 历史数据迁移:JSON → Yjs 转换
Node.js 方案(标准 @hocuspocus/server):
服务端在 Database.fetch Hook 中检查:如果数据库里只有 JSON 格式的文档内容(contentBinary 为空),就用 TiptapTransformer.toYdoc() 把 JSON 转成 Yjs Binary 再返回给客户端。这个过程对前端完全透明。
// Node.js 环境可以这样做
import { TiptapTransformer } from '@hocuspocus/transformer'
async fetch({ documentName }) {
const doc = await db.findById(documentName)
if (doc.contentBinary) {
return doc.contentBinary // 已有 binary → 直接返回
}
// 历史文档只有 JSON → 服务端转换为 Yjs binary
return TiptapTransformer.toYdoc(JSON.parse(doc.content), extensions)
}
DO 的问题:TiptapTransformer 底层依赖 ProseMirror 的 DOM 解析(DOMParser),Workers Runtime 没有 DOM 环境,无法运行。
DO 的解决方案——前端兜底:
DO 启动时从主应用拉取数据。如果 contentBinary 不为空(Base64),用 Y.applyUpdate 加载到内存的 Y.Doc 中;如果为空,DO 不做任何事——Y.Doc 保持空白。
前端这边,page.tsx(服务端组件)会把文档的 JSON 内容通过 props 传到客户端。编辑器初始化后,监听 Provider 的 synced 事件,发现 Y.Doc 为空时将 JSON 内容注入编辑器:
// 前端 editor 组件
const handleSynced = () => {
const yXmlFragment = provider.document.getXmlFragment('default');
if (yXmlFragment.length === 0 && window.__initialContent) {
const json = JSON.parse(window.__initialContent);
delete window.__initialContent; // 用完即删,避免泄露给后续新文档
editor.commands.setContent(json, { emitUpdate: true });
// emitUpdate: true → Yjs 感知到变更 → 自动同步到 DO → DO 保存 binary
// 下次打开时 contentBinary 已有值,不再走这条路径
}
};
provider.on('synced', handleSynced);
⚠️ 注意
emitUpdate必须为true:如果设为false,Yjs 不会感知到这次内容变更,服务端也不会保存 binary。下次打开还是空文档,陷入死循环。设为true后,这次注入会立刻通过 Yjs 同步到 DO 并触发debounceSave,将 binary 保存到数据库。一次转换,永久生效。
这种方案的代价是:历史文档第一次在协同模式下打开时,会有极短的空白闪烁(等待 synced → inject 完成)。但只要打开一次,后续加载都走 binary,体验与新文档完全一致。
9.2 全文搜索与预览:Yjs Binary → JSON 反向转换
Node.js 方案:@hocuspocus/server 的 onStoreDocument Hook 中可以同时做两件事:将 Yjs Binary 保存到 contentBinary,同时用 TiptapTransformer.fromYdoc() 将内容还原为 JSON 字符串写入 content 字段。这样全文搜索、文档预览等功能可以直接查询 JSON 字段。
DO 的问题:同样因为缺少 DOM 环境,TiptapTransformer.fromYdoc() 无法在 Workers 中运行。DO 只能保存 Binary,无法反向生成 JSON。
DO 的解决方案:
如果你需要全文搜索功能,有几个替代方案:
-
主应用内部 API 做转换:DO 保存 binary 时,主应用的 POST 接口收到 binary 后,在 Node.js 环境中做
fromYdoc()转换并更新content字段。这需要主应用安装@hocuspocus/transformer和编辑器相关扩展。 -
完全不维护 JSON 字段:如果项目初期不需要全文搜索,可以简化架构,后期需要时再补充。毕竟
contentBinary里完整保留了文档内容,数据不会丢失。 -
利用搜索引擎:如果已接入 Elasticsearch 或 Algolia 等搜索服务,可以在前端或主应用层面将编辑器内容推送到搜索索引,绕过 JSON 字段的需求。
9.3 监控与报警
Node.js 方案:通常会部署一个独立的 monitor 进程,定时(如每 15 分钟)ping WebSocket 服务的 HTTP 接口,失败时发邮件报警。服务端也会在 onError Hook 中触发邮件通知。
DO 不需要这样做:
Cloudflare 平台自带完善的可观测性工具:
| 工具 | 用途 |
|---|---|
| Workers Analytics | 请求量、错误率、P50/P99 延迟,按时间段聚合 |
| Real-time Logs | wrangler tail 实时查看线上日志(console.log 输出) |
| Durable Objects Metrics | DO 实例数、WebSocket 连接数、存储容量 |
| Notifications | 可配置告警规则(错误率超阈值 → 邮件 / Webhook / PagerDuty) |
# 实时查看线上日志(排查线上问题的利器)
npx wrangler tail
自建心跳监控在 DO 场景下反而有副作用:定时 ping 会唤醒处于 Hibernation 的 DO 实例,产生不必要的费用。Cloudflare 的监控是在平台层面运行的,不会唤醒你的 DO。
9.4 对比总结
| 问题 | Node.js @hocuspocus/server | Cloudflare DO |
|---|---|---|
| JSON → Yjs 转换 | 服务端 TiptapTransformer.toYdoc() | 前端 synced 事件兜底注入 |
| Yjs → JSON 反向转 | 服务端 onStoreDocument Hook | 主应用内部 API 补充转换(可选) |
| 监控报警 | 自建 monitor + 邮件 | Cloudflare 平台原生 Analytics |
| 优势 | 完整 Node.js 生态,库随便用 | 零运维,按需付费,全球低延迟 |
| 代价 | 需要常驻服务器 + 运维 | Edge Runtime 限制,部分库不可用 |
十、总结
| 对比维度 | 迁移前(常驻服务器) | 迁移后(Durable Objects) |
|---|---|---|
| WebSocket 服务 | 需要常驻服务器 | 按需唤醒 |
| 空闲成本 | 服务器月费 | 几乎为零(Hibernation) |
| 弹性扩容 | 手动 | 自动(每个房间独立 DO) |
| 全球延迟 | 取决于服务器位置 | 边缘网络 + Smart Placement |
| 主应用改动 | — | 仅新增一个内部 API |
最终的架构很简单:Serverless 做它擅长的事(HTTP 请求),Durable Objects 做它擅长的事(有状态长连接)。两者通过一个 HTTP 内部 API 桥接,各司其职。
无论是个人项目、初创产品还是企业内部工具,只要你的应用"大部分时间在处理 HTTP 请求,偶尔需要实时长连接",这套 Serverless + Durable Objects 的组合都值得一试——成本最低、运维最省、弹性最好。