实时协同白板这个项目,前后做了快两个月,推翻过两次架构。最开始的版本以为WebSocket把点坐标广播出去就完事,结果连上三个人一起画就开始乱:A的笔迹消失、B擦掉的东西又回来、C拖动元素时自己屏上是对的别人那里却跑偏。后来把协同同步协议和绘图引擎各重写了一遍,才算稳住。这篇文章不打算教怎么画一条线,重点讲协同本身——多人同时下笔数据怎么走、冲突怎么解、状态怎么收敛,以及那些文档上永远不会告诉你的坑。如果你正准备上手这类项目,或者已经写了几周发现光标乱跳、数据对不上,这篇文章应该能帮你省下不少弯路。
我个人对这种项目的评价是:实时协同白板是典型的"看起来简单、做起来全是细节"的应用。绘图本身没什么难度,难点全在"实时"和"协同"四个字上。下面按我实际开发中的决策顺序来写,从架构选型到协议设计再到问题排查,尽量把每一个"为什么这么选"都说清楚。
1. 先别急着写代码:协同架构与引擎选型
1.1 OT与CRDT:两种协同算法的真实取舍
最容易让人纠结的问题就是"到底用OT还是CRDT"。市面上的技术文章喜欢把两者放在对立面,好像选错就完蛋。实际做白板的时候,它们的差别没有想象中那么大,关键看你处理的数据结构长什么样。
OT(Operation Transformation,操作变换)的核心是每个操作经服务端转换后,在多个客户端上以一致顺序应用,它在 Google Docs 那种"文本流"场景非常成熟。因为文本天然是连续的字符序列,插入、删除的位置必须经过转换才能对齐。CRDT(Conflict-free Replicated Data Type,无冲突复制数据类型)的思路则反过来:每个副本独立接受操作,靠数据结构的数学性质保证最终一致,不需要服务端做大量转换。
白板的数据和文本有本质区别:白板里是离散的图形元素,每个元素有自己的ID,操作模型是创建、更新、删除,而不是"在某个字符偏移后面插入"。这意味着白板几乎不会出现文本编辑那种"必须OT才能解"的并发位置冲突。我的最终方案采用了服务端定序 + Lamport时间戳 + 字段级合并,本质上是CRDT思想的简化版:每个客户端生成操作时带一个本地的逻辑时钟值,服务端收到后分配一个全局单调递增的序号,所有客户端按这个序号应用操作。字段级合并用来解决两个客户端同时改同一个元素的场景,比如A改了线条颜色、B改了线条粗细,合并后两个修改都应生效;如果A、B同时改了颜色,则按时间戳和客户端ID决出后写入者。
再聊聊"为什么不用yjs这类现成的库"。yjs在协同编辑领域很好用,我也在另一个文本编辑器项目里用过,体验确实不错。但白板场景有两个问题:一是yjs对图形元素的建模偏文档导向,用它存储一个含几百个点的笔画对象,每次更新都要处理整个对象的序列化,复杂度和开销都不低;二是引入库之后服务端也要配套它的协议,出问题时排查黑盒的成本反而高。白板的操作类型本身有限,自己实现一个简化版协议其实比想象中简单,还能完全掌控消息格式和存储逻辑。
1.2 通信链路选型:WebSocket为主,WebRTC点对点为辅
实时协同的通信层,最稳妥的组合是WebSocket为主、WebRTC DataChannel为辅。初次接触的人可能觉得WebRTC延迟低、走点对点更酷,但实际公网环境下WebRTC需要信令服务器、STUN、TURN,还要处理各种NAT穿透失败后的降级。从零开始做白板,我建议老老实实先用WebSocket把协同逻辑跑通,后续再评估是否要加一条点对点加速通道。
为什么WebSocket在白板场景够用?因为白板交互的延迟敏感度不像在线游戏那么高。用户期望的是"几乎同步",不是"绝对零延迟"。我在项目里实测过,同一城市两个节点之间WebSocket消息往返延迟一般在30到80毫秒,这个量级下用户几乎感知不到对方的笔画有迟滞。只有当用户基数很大、跨地域很广、或者单条绘制消息体特别大时,才需要引入更极端的通信方案。给一个参照:在线文档的光标同步也是WebSocket实现,大家并无明显不适。
1.3 服务端连接管理:从心跳到"假连接"问题
服务端连接管理不只是"建立连接后转发消息",还要处理"连接其实已经断了"的情况。我在项目里用25秒一次的心跳、60秒未收到任何消息判定超时。心跳消息体非常轻,只包含客户端ID和房间号,服务端收到后回一个ack,用于清理死连接、防止内存泄漏和消息堆积。
这里有一个踩过的坑:客户端切到后台再回来,WebSocket底层TCP连接可能没断,但浏览器会因为保活策略降低连接的活跃度,导致消息收发阻塞但连接状态却是正常的。客户端感知不到异常,服务端也以为连接健康。我的解决办法是监听页面可见性变化:页面从后台切回前台时主动发一条带递增序号的syncCheck消息,服务端返回当前状态版本号,客户端对比后发现不一致就触发一次增量重同步。这个逻辑虽然只多了一次握手,解决的却是"假连接"这种最隐蔽的问题。
2. 绘图引擎设计:坐标映射与渲染分层
2.1 逻辑坐标系:解决多端尺寸不一致
多人白板最容易出现的视觉bug是坐标错位。A在2560x1440的显示器上画了一笔,B在1080p笔记本上打开,发现笔画位置明显偏移。原因很直接:直接把鼠标事件的坐标写入元素,而两边画布真实尺寸不一样。
解决办法是逻辑坐标系与视图坐标系分离。定义一个固定宽高的虚拟画布空间,比如1000x700,所有网络传输的坐标都使用这个逻辑坐标系。渲染时通过缩放因子把逻辑坐标映射到屏幕坐标,这个因子取决于canvas真实尺寸与逻辑尺寸的比值。所有笔画、图形、文本的位置和尺寸都存逻辑坐标,多端看到的内容位置就完全一致了。屏幕尺寸不一样时,元素在大屏上显得大、小屏上显得小,但彼此位置关系不会错。这个细节很多初次做白板的人会忽视,等到联调时就成了"为什么我画的内容到别人那里全偏了"的疑难杂症。
数据结构上,白板的一切都以元素(Element)为单位。一个元素至少需要这些字段:全局唯一ID、类型(line、rect、image、text等)、所属客户端ID、数据体、创建时间、最后修改时间、版本号。数据体与类型相关:line是一个坐标点数组,rect是x、y、width、height,image是可访问的URL。版本号是协同同步的锚点:每次更新元素时版本号递增,服务端和客户端通过版本号判断两条操作是否冲突。
2.2 Canvas分层渲染:告别卡顿与画笔闪烁
绘图引擎我选择了Canvas 2D而不是SVG。SVG在元素少、交互简单的场景更省事,但白板里元素可能上千,而且还要频繁局部重绘,DOM节点的创建更新会成为明显瓶颈。Canvas 2D的劣势是命中测试和重绘逻辑都要自己做,但这两件事在业务代码里可控。
渲染层我用的是三分层结构:背景层、已存在元素层、当前绘制层。背景层是网格和画布底色,只在初始化或缩放变化时重绘;已存在元素层存储所有同步过来的元素,状态变化时按需重绘;当前绘制层只服务本地正在画但还没提交的笔画,mousemove触发时只刷新这一层,不会重绘整个画布。分层的收益非常直观:房间里其他人同时在画时不会导致你的当前笔画闪烁,也不会因为别人画一笔就全屏重绘。
对比较复杂的白板,还可以再加一层"临时操作层",用于橡皮擦的选择框、元素拖动的虚线框等临时交互反馈,这样能进一步减少重绘面积。
2.3 requestAnimationFrame合并:降低消息洪峰
鼠标move和触摸move事件每秒钟能产生60到120次回调。如果每次回调都发一条WebSocket消息,负载会迅速膨胀。我实测过:单人在白板上快速画一条曲线,逐点发送时一秒能产生300到500条消息,两三个人同时画时服务端吞吐量立刻翻倍,客户端也会因为频繁触发网络IO而出现绘制卡顿。
解决办法是合并发送。鼠标move阶段只把点追加进内存数组,并立即由绘图引擎渲染到当前绘制层,完全不阻塞交互。每个requestAnimationFrame周期检查待发送队列,把队列里的点打包成一条消息发出去。我在项目里设置的是33毫秒间隔,大概每秒30帧。实际体验中,因为本地先渲染了,用户完全感知不到网络延迟,而服务端消息数量下降了约70%。这个套路不限于白板,任何高频实时同步功能都可以参考。
3. 同步协议设计:让所有端最终收敛到一致
3.1 消息类型与操作定义
整个协同协议能落地,靠的是一套清晰的消息约定。我的项目用JSON作为消息载体,虽然比二进制协议多一些序列化开销,但在消息频率和体量都受控的情况下,开发效率和调试体验更值。
同步协议的消息类型分四类:房间控制(加入、离开、成员列表)、操作同步(元素创建、更新、删除)、快照同步(全量数据、增量补拉)、心跳与校验(心跳、syncCheck、ack)。
操作同步消息是最核心的类型。一条标准操作消息包含:全局唯一操作ID、客户端ID、元素ID、动作类型、业务数据。动作类型有create、update、delete三种,update覆盖移动元素、修改属性、调整图层顺序等场景。每条操作还带一个Lamport时间戳,用来在服务端全序序号之外辅助判断竞争操作的先后关系。
3.2 服务端定序:为什么需要全局单调递增序号
服务端收到操作消息后只做三件事:分配序号、持久化、广播。全局单调递增的服务端序号是所有客户端收敛一致性的仲裁依据。各客户端本地生成操作的顺序可能不同,但服务端序号统一了全序,所有端按这个序号应用操作,最终状态必然一致。按照这个方案,服务端不需要做复杂的操作转换,只要能保证序号分配原子即可。
实际开发中,我用一个内存计数器维护当前房间的最大序号,每个操作进来时在事件循环里递增并分配,不会出现并发写导致重复序号。每个序号对应的完整操作会持久化到Redis列表或数据库表中,这份历史记录既用于增量补拉,也用于定时生成快照。广播阶段只是把操作连同序号转发给房间内其他客户端,不做额外加工。如果以后要加"操作回放"功能,这份历史记录也能直接复用。
3.3 快照与增量:新成员加入、断线重连的恢复路径
新成员加入房间,客户端发join消息,服务端返回当前全量元素快照和最大序号。客户端渲染快照,把本地序号推进到服务端的最大序号,之后正常接收新推送的操作。这里有一个不能省的细节:快照里每个元素必须带最新的版本号,否则客户端后续收到更新操作时无法判断自己手里的元素版本是否匹配。如果快照元素版本高于操作里的版本,说明该操作基于旧状态生成,需要丢给同步校验机制处理,而不是直接应用。
为了避免每次加入都全量拉取,我实现了增量拉取。客户端断线重连时带上自己最后成功接收的序号,服务端只返回该序号之后未消费的操作。如果离线时间过长、服务端保留的历史操作超过阈值,就放弃增量补拉,直接返回快照全量重建状态。这个阈值我设的是保留最近10分钟的操作历史,超过10分钟就退化为全量重建。10分钟这个值不是拍脑袋定的:以每分钟几百条操作的中等活跃房间来算,10分钟的操作量在几万条以内,序列化体积可接受;再长的话,增量补拉的解包和重放耗时反而比全量加载更快被拖垮。
3.4 一致性校验:如何发现"静默的脏数据"
实时协同系统最怕的状态是每个端看起来正常但内容实际不一致。这种问题往往不会立刻暴露,直到某个用户刷新后数据错乱。为了及时发现并修复,我增加了一个低频率校验机制:客户端定时发syncCheck,服务端计算当前房间所有元素版本号的哈希并返回,客户端做同样的计算再比对。不一致就触发增量补拉或全量重建。
校验哈希不需要用很重的加密算法,把所有元素的ID和版本号拼成字符串后做一次简单散列就够了,因为目标是"变化检测"而不是防篡改。这里的思路和版本号机制是一体的:版本号变了就说明有更新,校验哈希跟着变,两边一对比就知道谁落后了。
4. 协同开发中的典型问题与排查实录
4.1 "对方看不到我的光标":光标同步的高频消息与插值
光标同步看起来是最简单的功能,实现不好体验却很差。如果把每个用户每次光标移动都实时广播,房间里人一多消息量立刻失控;如果做固定节流,延迟又会大到让人明显感觉"对方的鼠标在跳"。我最后的做法是:光标移动先本地更新,用requestAnimationFrame节流发送,比如每帧只发一次;对远端光标的位置做时间戳插值,把收到的最新位置和前一位置平滑过渡过去,视觉上就很顺滑。40毫秒左右的插值窗口在体验上几乎无感,消息量则能控制在很低的水位。
4.2 "橡皮擦擦掉的东西又回来了":删除操作的时序问题
A在擦除,B在同一片区域画线,两边操作并发。如果处理不好,B画的东西可能因为先执行删除而消失,或者A擦掉的东西因为后执行的创建操作而重新出现。问题的根源是删除和创建在同步序列里的先后顺序不一致。
我的处理方式是:橡皮擦也是元素更新操作,不是直接改像素。删除操作记录的是元素ID,并携带创建/更新操作的服务端序号。每个端按序号顺序应用操作,就能保证"创建"和"删除"的相对顺序一致。如果服务端分配的序号是创建先、删除后,删除就会真正移除该元素;反过来如果删除先到,元素还不存在,客户端直接忽略删除操作,而不是记录一个"待删除标记"再等创建到达——后一种做法极易造成状态污染。这个原则贯穿所有操作类型:所有客户端必须严格按服务端序号顺序应用,每个操作都是状态机的一次转移。
4.3 断线重连后"我的元素不见了":离线期间的并发决策
用户离线期间错过了大量操作,重连时面临两种选择:增量补拉还是全量重建。这里有一个需要提前想清楚的边界:客户端离线期间的本地状态不能直接与服务器合并。因为离线期间用户可能做过本地新建、移动、删除,这些操作在服务端没有记录,重连后如果直接采用服务端数据,用户会觉得自己画的东西丢了;如果保留本地数据,又有可能与该期间其他用户对同一元素的修改冲突。
我采用的策略是将离线时间分成两段。离线少于30秒时,本地操作大概率没与其他端发生冲突,先把本地未同步的操作推给服务端,再增量拉取离线期间的新操作;离线超过30秒,直接全量重建服务端快照,丢弃本地所有离线期间的操作。这个策略虽然牺牲了极少量的本地未同步内容,但换来了协议的确定性和用户心理预期的清晰度:短时间断线几乎无感,长时间断线则明确以服务端为准。
4.4 撤销与重做在协同场景下的陷阱
白板里的撤销不能直接"删除元素",否则会变成全局撤销。A画了一条线、按Ctrl+Z,如果A本地把元素删掉再把删除操作广播出去,其他端会看到这条线同时消失,这就是典型的"本地编辑,全局生效"的认知错位。我的做法是把撤销实现为一次可见性更新操作:元素还在数据里,只是visible字段被置为false,所有端同时隐藏;重做则是把visible置回true。所有状态变更仍走"生成操作→服务端定序→全端应用"的链路,不会绕过协议直接改本地状态。这一点会在多人同时操作、有并发插入的场景里省掉大量冲突处理逻辑。
还有一个容易被忽略的细节:撤销栈本身要不要同步。我选择不同步。每个用户只维护自己的操作栈,用户体验更符合"我撤销我自己的操作"的直觉。如果做了完全同步的撤销栈,A撤销时把B的几个元素也一起撤销了,用户会马上喊有问题。
5. 做完整套系统后,我想重点说的几件事
这套方案下来,最大的体会是协同系统的复杂度通常不是来自单点技术,而是来自状态流转的边界条件。绘图、WebSocket、消息队列这些都容易上手,真正花时间的是把"创建、更新、删除、撤销、重连、并发"这些状态的排列组合都理清楚。早期版本之所以不稳,就是因为只覆盖了Happy Path,掉线重连和并发修改的边缘情况全都中招。
如果你正在做一个新的实时协同白板,我最核心的建议是:先把协议层的数据结构和应用顺序定死,再写任何画布渲染代码。渲染和通信都可以后期优化,数据流的确定性一旦被破坏,后面所有功能都会跟着抖。可以先用一个最简单的示例——两个浏览器窗口手动模拟协同,一个窗口手动触发操作、另一个窗口手动应用操作,把状态流转验证清楚再上WebSocket,这个习惯能帮你过滤掉大量初期的隐性bug。