实时协作白板
构建多人协作白板,配备 CRDT 同步、光标感知和可扩展 WebSocket 后端。
设计与架构
选择 CRDT vs OT、传输层(WebSocket/WebRTC)和持久化模型。
为什么
白板架构被同步算法锁死。CRDT(Yjs/Automerge)vs OT 决定能否支持离线编辑、点对点和冲突语义。上线后切换意味着重写核心数据层并迁移所有文档。
何时使用
在构建画布之前。花 1-2 周在玩具文档上原型化同步,测量并发编辑下的冲突解决。
如何做
1. 选择同步算法
- CRDT(Yjs / Automerge):支持离线 + P2P,合并无需中心服务器
- OT(ShareDB):心智模型更简单,但需中心权威且需在线合并
- 需离线支持的白板选 CRDT
2. 选择传输层
- WebSocket(客户端-服务器):认证简单,穿透企业代理
- WebRTC(P2P):2-5 用户低延迟,无服务器成本,但 NAT 穿透难
- 混合:WebRTC 做光标感知,WebSocket 做持久化
3. 设计文档模型
- Y.Map 存形状属性,Y.Array 存 z 序,Y.Text 存标签
- 每个形状 = 根 Y.Map 中的嵌套 Y.Map
- 避免深嵌套——CRDT 在宽树上性能下降
4. 规划持久化
- 每 30 秒或 50 次操作存一次快照到数据库
- 快照间重放操作日志获完整历史
- 回收最新快照之前的操作
5. 原型并测量
- 建 2 客户端玩具:并发输入、断线、重连
- 测量:无丢字、无重复、1 秒内收敛
- 若原型发散,先修算法再建画布
示例
// Yjs 文档结构
const ydoc = new Y.Doc();
const shapes = ydoc.getMap("shapes"); // Y.Map<string, Y.Map>
const zOrder = ydoc.getArray("zOrder"); // Y.Array<string>
// 矩形是嵌套 Y.Map
const rect = new Y.Map();
rect.set("type", "rect");
rect.set("x", 100); rect.set("y", 50);
rect.set("w", 80); rect.set("h", 40);
shapes.set("shape-1", rect);
// 经 WebSocket 同步
const wsProvider = new WebsocketProvider(
"wss://sync.example.com", "room-42", ydoc
);
// 离线编辑在重连时自动合并——无需手动冲突解决
常见问题
- ⚠选了 OT 又要离线支持——OT 无中心服务器无法合并离线编辑
- ⚠CRDT 深嵌套——Y.Map 套 Y.Map 套 Y.Map 在大画板上合并成本二次增长
- ⚠无快照策略——重连重放全部操作日志,1 万操作文档要数秒
最佳实践
- ✓需离线或 P2P 就选 CRDT(Yjs)——唯一无需中心权威即可合并的算法
- ✓文档保持扁平:形状 Y.Map 平铺,z 序用独立 Y.Array
- ✓每 30 秒 / 50 操作快照并回收旧操作——重连加载一个快照 + 增量,非全历史
实现与实时同步
构建画布、同步引擎和光标感知(光标与选区)。
为什么
同步引擎是白板崩溃之处——丢失更新、光标抖动、重连风暴。光标感知(显示他人光标)须低延迟但限流,否则快速移动者每秒灌入 100 更新,饿死文档同步。
何时使用
同步原型验证冲突解决后。先建画布 + 同步 + 光标感知,再建形状和导出。
如何做
1. 渲染画布
- 1000+ 形状用保留模式渲染器(Konva / PixiJS)
- 每个 Y.Map 形状映射到场景节点;观察 Yjs 事件更新
- 限流重渲染到 60fps;每帧批量 Yjs 更新
2. 本地编辑接入 CRDT
- 拖动 -> rect.set("x", newX)——Yjs 处理操作、同步和合并
- 永不直接改形状对象——始终经 Y.Map
3. 用 Yjs awareness 实现光标感知
- awareness.setLocalStateField("cursor", { x, y, color })
- 光标更新限流到 30/秒(非每次 mousemove)
- 从 awareness.getStates() 渲染远程光标——断开自动清理
4. 处理重连
- wsProvider.synced = false 时显示"重连中"横幅
- 断开期间缓冲本地编辑(Yjs 自动处理)
- 重连时 Yjs 发送缓冲操作——无需手动重试
5. 加撤销/重做
- 在 shapes map 上用 Y.UndoManager
- 按用户追踪撤销栈,一人撤销不回退他人编辑
- 栈限 200 操作以限内存
示例
// 经 Yjs awareness 的光标感知(限流)
let lastCursorSent = 0;
canvas.on("mousemove", (e) => {
const now = Date.now();
if (now - lastCursorSent < 33) return; // 30fps 上限
lastCursorSent = now;
awareness.setLocalStateField("cursor", {
x: e.x, y: e.y, color: myColor, user: myName,
});
});
// 渲染远程光标
awareness.on("change", () => {
for (const [clientId, state] of awareness.getStates()) {
if (clientId === ydoc.clientID) continue; // 跳过自己
renderCursor(state.cursor);
}
});
// 按用户撤销
const undoManager = new Y.UndoManager(shapes, {
trackedOrigins: new Set([ydoc.clientID]),
});
常见问题
- ⚠每次 mousemove 发光标——每秒 200 事件洪水饿死文档同步并撞 WebSocket 帧上限
- ⚠直接改形状对象而非经 Y.Map——编辑仅本地,同步时丢失
- ⚠全局撤销栈——A 的撤销回退 B 的编辑,摧毁协作信任
最佳实践
- ✓光标感知限流 30/秒、文档编辑限 60fps——瓶颈是带宽非渲染器
- ✓始终经 CRDT API(Y.Map.set)编辑——直接修改绕过同步导致静默发散
- ✓用 Y.UndoManager 按客户端 trackedOrigins,撤销只回退自己编辑
部署与扩展
部署 WebSocket 服务器配粘性会话,经 Redis pub/sub 水平扩展,并持久化快照。
为什么
WebSocket 服务器持有内存 Yjs 文档——崩溃丢失未同步编辑。水平扩展需 pub/sub 主干(Redis),让服务器 A 的房间与服务器 B 的客户端同步。无快照持久化,重连加载全部操作日志,拖垮加载时间。
何时使用
白板支持 5 并发用户时。上线前压测到每房间 50 并发编辑者。
如何做
1. 部署同步服务器
- Node.js + y-websocket 服务器,容器化(Docker)
- Fly.io 或 Railway 易扩展;设最小实例 = 2 保高可用
- 健康检查:GET /health 返回 200;崩溃自动重启
2. 加 Redis pub/sub 多服务器同步
- y-redis 适配器:将 Yjs 更新发布到 Redis 频道
- 所有服务器订阅;房间透明跨 N 服务器
- Redis 也持久化更新——全服务器重启不丢
3. 快照存数据库
- 每 30 秒或 50 操作,编码 Y.encodeStateAsUpdate(ydoc) 并存储
- 加载房间:取最新快照 + 重放快照后操作
- 回收 7 天前快照
4. 上线前压测
- 50 客户端同房间并发编辑 5 分钟
- 测量:p99 同步延迟 < 500ms、无丢编辑、CPU < 70%
- 同步延迟飙升则增大 Redis 连接池或按房间分片
5. 监控告警
- 追踪:活跃房间、并发用户、同步延迟 p99、Redis 滞后
- 告警:同步延迟 > 2s 或房间操作日志 > 10 万(需快照)
- Grafana 看板 + 同步延迟告警接 PagerDuty
示例
// y-redis:多服务器同步 + 持久化
import { RedisPersistence } from "y-redis";
const persistence = new RedisPersistence({
redisOpts: { host: "redis.internal", port: 6379 },
});
// y-websocket 服务器配 Redis 后端
import { setupWSConnection } from "y-websocket/bin/utils";
wss.on("connection", (conn, req) => {
setupWSConnection(conn, req, { doc: ydoc, persistence });
// 更新自动发布到 Redis + 持久化
});
// 快照定时(每活跃房间每 30 秒)
setInterval(() => {
for (const [room, ydoc] of activeRooms) {
const snapshot = Y.encodeStateAsUpdate(ydoc);
db.snapshots.upsert(room, snapshot, { ts: Date.now() });
}
}, 30_000);
// 压测:50 客户端,5 分钟,期望 p99 < 500ms
// npx concurrently "node loadtest.js --clients 50 --room r1"
常见问题
- ⚠单 WebSocket 服务器无 Redis——崩溃丢失所有内存房间和未同步编辑
- ⚠无快照定时——重连 1 万操作房间重放全日志,耗时 5+ 秒
- ⚠跳过压测——3 个 demo 用户很好,30 并发编辑者崩溃
最佳实践
- ✓每个 WebSocket 服务器配 Redis pub/sub——免费获得水平扩展和重启存活
- ✓每 30 秒 / 50 操作快照,重连加载快照 + 增量——加载时间与文档年龄无关
- ✓上线前压测到每房间 50 并发编辑者——同步延迟是用户感知的指标