Skip to content
返回项目教程

实时协作白板

构建多人协作白板,配备 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 并发编辑者——同步延迟是用户感知的指标

完整开发者生命周期

从想法到维护的 14 阶段开发者路径图 — 可视化依赖关系、追踪进度。

下一步