从CRDT到Canvas:MiroFish多人实时协作画布实践 做多人实时协作画布这件事我从两年前就开始琢磨了。市面上的协作白板工具确实好用但当团队需求变得奇怪一点——比如要把画布和我们自己的任务系统打通、要私有化部署、要接入内部的权限体系——现成产品就开始处处别扭。MiroFish 就是在这样的背景下被我一点点搭出来的一个以鱼群为隐喻的多人实时协作画布项目核心目标是让一群人像鱼群一样不用指挥也能同步游动。这篇文章我会把整个项目的设计思路、技术选型、实时协同底座、画布渲染优化、数据持久化和踩过的坑尽可能完整地摊开讲。如果你也在做实时协作类的前端项目或者单纯想搞清楚多人同时编辑一块画布背后的门道这篇内容应该能让你少走不少弯路。1. 为什么我要做 MiroFish需求拆解与技术选型1.1 从一个真实的协作痛点说起最开始的需求其实特别朴素。我们团队做产品评审时习惯用白板画流程但用的工具是外部服务画完的内容要截图贴回内部文档评审意见散落在各个聊天窗口里。更要命的是涉及一些内部业务架构的内容我们并不希望它们离开自己的服务器。于是我给自己定了一个小目标做一个能在内网跑起来、支持多人同时在线编辑、数据结构可控的协作画布。MiroFish这个名字是我自己起的。Miro 取自协作画布的意象Fish 取的是鱼群的意象——一群鱼在没有任何中央指挥的情况下依然能保持队形、同步转向、规避障碍。这恰好就是多人实时协作最难也最迷人的地方每个客户端都是独立的个体却要在几百毫秒内达成视觉上的一致。理解了这个隐喻你就能理解这个项目所有技术决策的出发点——一致性、低延迟、去中心化的局部自治。这个项目适合几类人参考一是想自己搭协作白板的前端工程师二是对 CRDT、实时同步机制好奇但没机会上手的人三是需要在产品里嵌入多人同时操作同一视图能力的团队。哪怕你只是想知道两个人同时拖动同一个方块会发生什么往下看也值。1.2 技术栈选型的取舍逻辑选型这件事我的原则是先用最熟悉的轮子把核心链路跑通再针对性替换瓶颈。所以第一版我没有一上来就追求极致性能而是选了上手快、生态好的组合。模块选型选择理由备选与放弃原因前端框架React TypeScript组件化清晰类型系统能兜住复杂数据结构Vue 也合适团队更熟 React状态管理Zustand轻量适合高频更新的画布状态Redux 样板代码太多高频更新时性能吃紧画布渲染Canvas 2D 起步API 简单性能远好于 DOM 方案SVG 元素一多就卡WebGL 学习成本高协同同步YjsCRDT自动处理冲突无需中心化仲裁自研 OT 复杂度高容易写出隐蔽 bug传输层原生 WebSocket双向、低开销消息可控Socket.IO 封装重长轮询延迟高服务端Node.js ws与前端同语言I/O 密集场景合适换 Go 性能更好但迭代速度优先存储PostgreSQL Redis关系型存文档Redis 做房间状态缓存纯内存方案持久化风险高这里我想重点说两个决策背后的思考。为什么不用自研 OTOperational TransformationOT 的核心是把并发操作做变换后应用理论上很优雅但实现起来对操作顺序、上下文极其敏感稍微漏掉一个边界情况就会导致不同客户端状态分叉而且这种 bug 极难复现。CRDTConflict-free Replicated Data Type无冲突复制数据类型走的是另一条路它把数据结构本身设计成怎么合并都不冲突用空间换正确性。对个人项目来说正确性比极致省流量重要得多。为什么服务端选择 Node.js协作画布的服务端大部分时间在做一件事——把 A 的消息转发给同房间的 B、C、D。这是典型的 I/O 密集型任务不是计算密集型Node 的事件循环模型在这里表现很好。真正重的计算比如合并、冲突检测都下放到了客户端的 CRDT 里服务端本质上只是个消息中转站 状态看门狗。1.3 架构分层与模块划分整个项目的架构我分成了五层从下往上看传输层负责 WebSocket 连接、心跳、断线重连协同层封装 Yjs 文档和房间的绑定状态层负责把协同数据映射成渲染需要的视图状态渲染层负责画布绘制交互层负责鼠标、键盘、触摸事件的采集与转换。分层的核心目的只有一个——把数据一致性和视图渲染彻底解耦。协同数据变了就通知渲染层重绘渲染层完全不用关心这个变化是本地用户产生的还是远端同步过来的。这种解耦带来的直接好处是调试变得简单我可以在协同层打日志看到每一条进出的消息也可以在渲染层单独 mock 数据不启动服务端就能调样式。很多协作项目写到最后变成一团乱麻就是因为数据流动和视图渲染缠在一起改一处崩三处。2. 实时协同的底座同步机制怎么选、怎么落地2.1 OT 与 CRDT 的对比与选择要把多人同时编辑做对绕不开一个根本问题两个人同时改了同一块内容听谁的这个问题在单机时代根本不存在一旦多端并发就变成了核心难题。业界有两条主流路线我把它们的关键差异整理如下。维度OTCRDT核心思想变换操作使其可交换数据结构天然可合并是否需要中心服务器通常需要不强制可去中心实现难度高边界情况多中但概念门槛高网络开销低只传操作略高需要传元数据冲突处理运行时变换结构上避免适合场景文本编辑Google Docs 系富图形、离线优先场景我最终选 CRDT是因为 MiroFish 是图形画布而不是纯文本。图形对象的操作五花八门——移动、缩放、旋转、改色、删除、分组如果每一种都要写变换规则OT 的工作量会爆炸。而 CRDT 的思路是给每个图形对象一个全局唯一 ID每个属性用可合并的数据类型表达移动操作本质上就是把某个 ID 的 x/y 属性设成新值后来的操作自然覆盖先前的合并逻辑统一且简单。提示CRDT 的空间开销是真实存在的。每个属性位都带着版本元数据文档越大元数据越多。如果画布上元素超过几千个务必配合快照压缩否则一个房间同步下来能吃掉几十 MB 内存。2.2 WebSocket 连接层设计传输层看起来最简单实际上坑最多。我的实现里有三件事必须处理到位心跳保活、断线重连、消息协议。心跳是为了防止中间的网络设备把长时间没数据的连接悄悄掐掉。我的做法是客户端每 30 秒发一个 ping服务端回一个 pong超过 60 秒没收到 pong 就主动重连。别小看这 30 秒很多用着用着就连不上了的投诉根源都是连接被中间设备回收而客户端毫不知情。断线重连的关键是状态续传。重连不能简单地重新连上就完事因为断线期间房间里的其他人可能已经改了很多东西。我的做法是客户端记录本地的 Yjs 文档版本向量state vector重连后把它发给服务端服务端只回传这个版本之后缺失的增量更新Yjs 的 update 机制天然支持这个能力。这样重连几秒钟断线的代价只是补传那几秒的操作而不是拉全量文档。消息协议我用的是二进制格式Yjs 的 update 本身就是 Uint8Array。第一版我图省事用了 JSON结果发现光标移动这种高频消息一秒钟能产生上百条JSON 序列化的开销肉眼可见。换成二进制后同样的操作消息体积下降了大概六成CPU 占用也降了下来。// 心跳与重连的核心逻辑示意 class CollabSocket { constructor(url, roomId) { this.url url; this.roomId roomId; this.retry 0; this.connect(); } connect() { this.ws new WebSocket(${this.url}?room${this.roomId}); this.ws.binaryType arraybuffer; this.ws.onopen () { this.retry 0; this.startHeartbeat(); this.syncMissingUpdates(); // 补传断线期间的增量 }; this.ws.onmessage (e) { if (e.data.byteLength 0) return this.handlePong(); this.applyRemoteUpdate(new Uint8Array(e.data)); }; this.ws.onclose () { this.stopHeartbeat(); // 指数退避避免疯狂重连打垮服务端 const delay Math.min(1000 * 2 ** this.retry, 30000); setTimeout(() this.connect(), delay); }; } }2.3 光标与在线状态的广播看到别人在动这件事对协作体验的提升是巨大的它让人确信对面真的有人。但光标广播也是最容易把带宽吃光的地方。我的处理是节流 只广播给同房间 独立于文档。光标位置我用节流控制在每秒 20 次左右约 50ms 一次因为人眼对更细的移动也分辨不出来。光标数据走的是临时消息通道不写进 CRDT 文档——它不需要持久化也不需要一致性保证丢一两条完全无所谓。如果把它塞进文档同步一来会污染历史记录二来会让撤销栈里全是光标移动这显然不合理。在线用户列表我用的是服务端广播机制而不是 CRDT。谁进来了、谁离开了这本质上是房间成员的状态交给服务端维护更直接。每个用户分配一个稳定的用户 ID、随机但固定的颜色和昵称光标和头像都复用同一套身份信息。一个容易被忽略的细节是光标淡出。当某个用户一段时间没有移动鼠标或者直接关闭了页面他的光标不应该突然消失——会让人感觉像是被吓到。我的做法是光标在静止 5 秒后开始用 CSS 动画渐隐同时服务端检测到连接断开时广播一个用户离开事件客户端在 2 秒内平滑移除对应的光标和头像。这种小动画的投入产出比极高是那种用户说不出哪里好但就是舒服的细节。3. 画布渲染从 SVG 到 Canvas 的性能演进3.1 渲染方案对比第一版我用的是 SVG因为它太直观了——每个图形就是一个rect或path改属性就是改 DOM配合 React 的声明式更新两天就出了原型。但当我把一个包含 300 个节点的流程图导入测试时拖动变得肉眼可见地卡。原因很简单SVG 里每个元素都是真实 DOM 节点每次重绘浏览器都要走一遍样式计算、布局、合成几百个节点就顶不住了。方案优点缺点适用场景SVG元素可交互、样式好写、分辨率无损元素多了性能骤降元素少、需要精确点击Canvas 2D绘制快、控制粒度细需要手写命中检测中等规模、频繁重绘WebGL极高性能、可跑十万级图元学习曲线陡、调试难超大规模数据可视化第二版我砍掉 SVG改用 Canvas 2D 全量重绘。核心思路是把画布上所有内容当成一个状态快照每一帧根据当前状态把所有可见元素画一遍。听起来很浪费但 Canvas 的绘制速度足够快几百个简单图形重绘一帧在 16ms 以内毫无压力。真正的性能杀手不是画得不够快而是画了太多看不见的东西。3.2 视口与坐标系统设计画布类项目最容易埋雷的地方是坐标系。我一开始图省事直接把屏幕坐标当数据坐标存进文档结果发现不同窗口大小的用户看到的图形位置完全对不上——因为她那边窗口小世界坐标系的原点位置和我这边不一样。正确的做法是维护两套坐标世界坐标world和数据绑定是图形的真实归属屏幕坐标screen只用于渲染和鼠标命中的临时计算。两者之间通过一个变换矩阵相互转换这个矩阵由平移pan和缩放zoom两个量决定。// 世界坐标 - 屏幕坐标互转 function worldToScreen(world, viewport) { return { x: (world.x - viewport.panX) * viewport.zoom, y: (world.y - viewport.panY) * viewport.zoom }; } function screenToWorld(screen, viewport) { return { x: screen.x / viewport.zoom viewport.panX, y: screen.y / viewport.zoom viewport.panY }; }所有存进 CRDT 的都是世界坐标这样无论谁用什么尺寸的屏幕看到的都是同一张图。鼠标点击时先把屏幕坐标转成世界坐标再去命中检测就不会出现放大后点不中元素的经典问题。这个设计看起来只是加了两行转换但它决定了你的画布能不能支持不同设备看到同一内容是必须一开始就做对的底层假设。注意缩放要注意精度。如果允许无限放大坐标会越乘越大最后溢出精度。我给 zoom 设了 [0.1, 8] 的合理区间超出就不再响应既防溢出也更符合真实使用场景。3.3 分层渲染与脏矩形优化全量重绘有个前提——画的内容要足够少。当画布内容超过一千个元素或者引入了背景网格、缩略图、选中框等附加绘制全量重绘的开始掉帧。我用了两手优化。第一手是分层 Canvas。我把画布拆成三层叠加的canvas最底下是静态层背景网格、已确认的图形中间是动态层正在拖动、正在绘制、选中高亮最上面是交互层光标、工具栏。静态层只有在文档真正变化时才重绘动态层每一帧都重绘但内容很少交互层几乎不变。这样拖动一个图形时成百上千个静态元素根本不需要重新绘制只重绘那一层里被拖动的那个。第二手是脏矩形dirty rectangle。当只有局部区域变化时只清除和重绘那一块区域的像素而不是整个画布。Canvas 2D 提供clearRect和裁剪区域配合记录每个元素的包围盒就能算出这一帧到底哪些区域变了。这个优化在元素稀疏的画布上收益最大但实现起来需要维护每个元素的脏区域标记有一定复杂度我建议先做分层脏矩形作为第二阶段优化。4. 数据模型与持久化让协作数据可存、可查、可回滚4.1 图形对象的数据结构图形对象的数据结构设计决定了后面几乎所有事情的难度。我的原则是扁平化 强类型 属性可合并。不要用嵌套的树形结构表达图形关系因为嵌套结构在 CRDT 里合并时会非常麻烦。我把每个图形做成一个扁平对象用parentId表达分组关系用Mapid, node存所有图形。interface ShapeNode { id: string; // 全局唯一客户端生成 type: rect | ellipse | arrow | text | path; x: number; // 世界坐标 y: number; width: number; height: number; rotation: number; style: ShapeStyle; // 颜色、线宽、透明度等 parentId: string | null; zIndex: number; // 层级顺序 deleted: boolean; // 软删除标记方便撤销 }这里有两个设计我想强调。ID 由客户端生成用类似nanoid的方案而不是服务端分配。因为客户端生成 ID 才能支持离线编辑——你断网时画的图形也要有个身份等联网了直接合并进文档就行。删除用软删除deleted置位而不是真的从 Map 里移除。因为撤销操作需要知道被删的是什么如果物理删除了撤销就得靠额外的历史记录反而不如软删除简单可靠。定期做垃圾回收时再真删。4.2 快照 增量日志的存储策略持久化这件事协作场景和普通应用完全不一样。普通应用存的是最新状态协作应用还要存怎么变成这个状态的。我用的是快照 增量日志的组合拳。增量日志就是所有 CRDT 的 update按时间顺序存进 PostgreSQL。它是真正的数据来源可以精确重放每一个操作。但增量日志有个问题时间一长日志会无限膨胀一个活跃房间一天可能产生几十万条 update。所以每隔一段时间比如每 5 分钟或者每积累 1000 条 update我就把当前完整文档序列化成一份快照存下来然后把之前的增量日志标记为可清理。恢复房间时的流程是先加载最近的快照再把快照之后的增量日志按顺序应用一遍就得到了最新状态。这种设计的好处是既能快速加载靠快照又能精确追溯靠日志。同时我做了压缩Yjs 的 update 本身支持合并可以把一堆小 update 合并成一个大 update减少存储条目和加载时的应用次数。// 快照 增量的落库逻辑示意 async function persistRoom(roomId, ydoc, updateBuffer) { const shouldSnapshot updateBuffer.length 1000; if (shouldSnapshot) { const snapshot Y.encodeStateAsUpdate(ydoc); await db.saveSnapshot(roomId, Buffer.from(snapshot)); await db.clearUpdatesBefore(roomId, Date.now()); updateBuffer []; } else { await db.saveUpdates(roomId, updateBuffer); } }4.3 历史版本与撤销重做撤销重做是协作编辑里最反直觉的功能。单机应用的撤销是撤销我自己的上一步但协作场景下画布上还混着别人的操作你撤销时应该只撤销自己的操作不能把别人的东西也撤了。Yjs 提供了UndoManager可以按 origin 过滤只追踪特定来源也就是当前用户的操作。这样每个人都有一个独立的撤销栈互不干扰。版本历史我做了两条线一条是自动时间点每进行一批操作或者每隔一段时间自动打一个点另一条是手动命名版本用户可以主动存一个评审前版本。回滚的原理就是把文档状态恢复到某个快照但这里有个坑——回滚本身也会产生新的操作如果不处理回滚后所有人的撤销栈都会乱掉。我的做法是回滚时广播一个重置事件客户端清空各自的撤销栈从回滚后的状态重新开始记录。提示快照别存太频繁。早期我每 30 秒打一个快照结果数据库体积涨得飞快。后来改成操作数阈值 最小时间间隔双条件触发体积才降下来。如果你拿不准就先按操作数触发简单有效。5. 常见问题与排查实录5.1 协作冲突类问题问题一幽灵图形。现象是有时候画布上会突然出现一个谁都没画过的图形或者某个图形变成两个。排查了很久才定位到原因客户端断线重连时如果重连逻辑还没有完成、连接还没真正建立本地又产生了一个操作这个操作会被丢弃或者重复发送。解决办法是维护一个待发送队列连接未就绪时把操作暂存连接建立后再按序发送同时确保 send 前检查ws.readyState。问题二ID 碰撞。早期我用时间戳加随机数生成 ID理论上会碰撞。有一次两个人几乎同一毫秒创建图形结果 ID 相同两个图形合并成了一个场面一度很诡异。换成包含机器随机种子的 nanoid 后就再没出现过。问题三消息乱序。理论上 WebSocket 保证有序但一旦涉及到多个连接比如用户从手机切到电脑跨连接的消息就未必有序。我们的 CRDT 天然容忍乱序这也是选它的收益之一——你不用为乱序特别写逻辑。5.2 性能类问题问题四拖动时越来越卡。这个是内存泄漏的典型症状。原因是每渲染一帧都新建了 Canvas 的离屏缓存或者事件监听器没有释放。排查方法是在 Chrome DevTools 里反复操作后手动触发 GC看内存有没有回落。没回落基本就是泄漏。解决方式是把高频创建的临时对象尽量复用事件监听器注册在组件卸载时务必移除。问题五大文档加载慢。一个画了几千个元素的房间打开要好几秒。除了前面说的快照优化我还加了视口裁剪——只渲染当前视口内可见的图形。因为用户往往只关心屏幕里那一片画布外的东西没必要画。加了裁剪后大文档的打开时间从数秒降到几百毫秒。问题现象根因解决幽灵图形出现无人创建的元素重连期间操作丢失/重发待发送队列 就绪检查ID 碰撞两个图形变一个时间戳随机数撞车换用 nanoid拖动卡顿操作越久越卡内存泄漏对象复用 监听器清理大文档慢打开要好几秒全量渲染快照 视口裁剪连接掉线用着用着没反应中间设备回收连接心跳 指数退避重连5.3 部署与稳定性问题问题六多实例下房间状态不一致。单机部署时一切正常一上负载均衡就出问题。因为 WebSocket 是有状态的同一个房间的连接必须路由到同一台服务器否则 A 的消息发到了实例一B 在实例二上根本收不到。解决办法是会话粘性sticky session按房间 ID 做一致性哈希路由。跨实例的房间广播则通过 Redis 的发布订阅来做——实例一收到消息后除了发给本机连接还通过 Redis 转发给持有同房间连接的其他实例。问题七连接数一多服务端就顶不住。一台普通服务器能撑的并发 WebSocket 连接其实相当可观但每个连接都在占用内存几千个连接后 GC 压力就上来了。我的优化是把心跳间隔适当拉长、把房间状态尽量放 Redis 而不是进程内存、对不活跃房间做连接回收。真正需要水平扩展时前面说的粘性 Redis 广播架构就能平滑扩容。问题八网络抖动导致状态分叉。极少数情况下客户端和服务端的状态会对不上。我的兜底方案是服务端定期做一次状态校验——比对客户端上报的状态向量哈希不一致就触发全量同步。这个校验频率不用高比如每分钟一次但它是保证系统长期健康的安全带。6. 踩坑之后的一些真心话这套东西从原型到能勉强上线前前后后折腾了小半年有几个体会特别想分享给同样在做类似项目的人。协同的核心不是同步消息而是设计数据。我一开始把大量精力花在 WebSocket 消息协议上后来发现真正决定项目成败的是数据模型——只要数据结构本身设计成可合并的同步就变成了水到渠成的事。反过来数据结构一团糟再精巧的同步协议也救不回来。所以如果你准备动手做类似项目请把 80% 的前期设计时间花在数据模型上。先跑通再优化但别等到最后才优化渲染。渲染优化我建议在项目早期就埋下分层渲染的框架哪怕一开始只有一层。因为一旦所有绘制逻辑都写在一个大循环里后期想拆层会动到很多代码。而像脏矩形、视口裁剪这类优化则可以等到真的卡了再做它们相对独立插入成本低。离线优先是个需要提前想清楚的问题。CRDT 天然支持离线编辑但你的产品需求真的需要离线吗如果不需要直接在连接断开时禁用编辑、显示连接中会更省事。如果确实需要离线那么你要处理的东西会成倍增加——离线期间的 ID 生成、冲突回合并、本地持久化、恢复后的批量上传每一样都不简单。我做了离线编辑代价是复杂度陡增坦白说如果重来一次我会先问清楚产品到底要不要这个能力。最后留个后续扩展的念想我自己正在折腾的方向是把画布的智能对齐、自动布局和协作数据结合起来——比如一群人同时在画流程图时系统能根据当前所有图形的状态给出布局建议。这背后的挑战是布局算法必须基于所有客户端都认可的一致状态来算否则每个人看到的建议都不一样反而添乱。这块我还在摸索等有稳定结果了再跟大家分享。