ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

实时协同白板开发实战:同步协议、冲突处理与渲染优化

实时协同白板开发实战:同步协议、冲突处理与渲染优化 实时协同白板这个项目前后做了快两个月推翻过两次架构。最开始的版本以为WebSocket把点坐标广播出去就完事结果连上三个人一起画就开始乱A的笔迹消失、B擦掉的东西又回来、C拖动元素时自己屏上是对的别人那里却跑偏。后来把协同同步协议和绘图引擎各重写了一遍才算稳住。这篇文章不打算教怎么画一条线重点讲协同本身——多人同时下笔数据怎么走、冲突怎么解、状态怎么收敛以及那些文档上永远不会告诉你的坑。如果你正准备上手这类项目或者已经写了几周发现光标乱跳、数据对不上这篇文章应该能帮你省下不少弯路。我个人对这种项目的评价是实时协同白板是典型的看起来简单、做起来全是细节的应用。绘图本身没什么难度难点全在实时和协同四个字上。下面按我实际开发中的决策顺序来写从架构选型到协议设计再到问题排查尽量把每一个为什么这么选都说清楚。1. 先别急着写代码协同架构与引擎选型1.1 OT与CRDT两种协同算法的真实取舍最容易让人纠结的问题就是到底用OT还是CRDT。市面上的技术文章喜欢把两者放在对立面好像选错就完蛋。实际做白板的时候它们的差别没有想象中那么大关键看你处理的数据结构长什么样。OTOperation Transformation操作变换的核心是每个操作经服务端转换后在多个客户端上以一致顺序应用它在 Google Docs 那种文本流场景非常成熟。因为文本天然是连续的字符序列插入、删除的位置必须经过转换才能对齐。CRDTConflict-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、heightimage是可访问的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画了一条线、按CtrlZ如果A本地把元素删掉再把删除操作广播出去其他端会看到这条线同时消失这就是典型的本地编辑全局生效的认知错位。我的做法是把撤销实现为一次可见性更新操作元素还在数据里只是visible字段被置为false所有端同时隐藏重做则是把visible置回true。所有状态变更仍走生成操作→服务端定序→全端应用的链路不会绕过协议直接改本地状态。这一点会在多人同时操作、有并发插入的场景里省掉大量冲突处理逻辑。还有一个容易被忽略的细节撤销栈本身要不要同步。我选择不同步。每个用户只维护自己的操作栈用户体验更符合我撤销我自己的操作的直觉。如果做了完全同步的撤销栈A撤销时把B的几个元素也一起撤销了用户会马上喊有问题。5. 做完整套系统后我想重点说的几件事这套方案下来最大的体会是协同系统的复杂度通常不是来自单点技术而是来自状态流转的边界条件。绘图、WebSocket、消息队列这些都容易上手真正花时间的是把创建、更新、删除、撤销、重连、并发这些状态的排列组合都理清楚。早期版本之所以不稳就是因为只覆盖了Happy Path掉线重连和并发修改的边缘情况全都中招。如果你正在做一个新的实时协同白板我最核心的建议是先把协议层的数据结构和应用顺序定死再写任何画布渲染代码。渲染和通信都可以后期优化数据流的确定性一旦被破坏后面所有功能都会跟着抖。可以先用一个最简单的示例——两个浏览器窗口手动模拟协同一个窗口手动触发操作、另一个窗口手动应用操作把状态流转验证清楚再上WebSocket这个习惯能帮你过滤掉大量初期的隐性bug。
返回列表