资讯动态

从零实现多人实时编辑:CRDT协同算法与WebSocket工程实践

发布时间:2026/10/21 4:02:13 来源:尧图企业网站定制
去年我给自己定了一个有点自虐的目标不借助任何现成的富文本框架和协同SDK从零写一个能支持多人实时编辑的在线文档工具。项目代号就叫rea全称是Realtime Editor Application本意很简单——验证自己对实时同步这块的理解到底到什么程度。rea的核心能力一句话能说清多个人打开同一个文档一个人打字其他人的屏幕上几乎同时出现两个人同时改同一行系统不会崩也不会把某一方改的吞掉。听起来是很多在线文档的基本操作但真要从底层实现涉及到的细节远比想象中多。如果你也在考虑做类似的东西或者想知道协作文档背后那条链路是怎么串起来的这篇就按我的实际开发顺序把从零到能用的完整经验拆开讲。1. 为什么一个练手项目非要从零搭编辑器1.1 现成方案和自研的边界怎么划动手之前我先是把市面上的现成方案盘了一遍。富文本编辑器可以直接用现成的组件协同后端也有现成的SDK和服务甚至有一些完整的多人编辑框架拉起来就能跑。如果只是想做一个内部工具直接拼装是最快的没有任何问题。但我做rea的出发点不是为了快速交付一个工具而是想搞清楚下面几个问题的答案多人同时编辑时两个人的操作到底是先到先得还是需要一套合并规则如果服务器收到的操作顺序和客户端本地的顺序不一致最终文档内容以谁为准如果某个客户端断网了十分钟重连之后它要怎么把断掉的内容补回来而且不能把别人在这十分钟里的修改覆盖掉这些问题用现成方案都能解决但解决过程藏在封装里出了问题很难排查。自己从零搭建的好处在于每一层逻辑都是透明的出任何问题都知道去哪个环节看。作为练手项目这个透明性比省事重要得多。1.2 rea的目标范围凭什么说这个项目可以做为了不让项目失控我一开始就把rea的范围死死框住编辑能力只支持纯文本不搞富文本格式不做图片嵌入不处理表格实时协同支持多人在线同时编辑但目标用户不超过20人同时在一个文档里所有操作通过一个中心服务器转发不做P2P。有人可能会觉得纯文本太简单但纯文本恰恰是协同算法最好的试验场。富文本涉及嵌套格式和样式覆盖难度会翻好几倍而且很多核心冲突问题在纯文本阶段就能暴露。把纯文本方案做到稳定可靠再去扩展富文本路径会清晰很多。目标用户限制在20人以内也很关键。人少了才能用相对简单的同步策略等确认方案可行再去优化并发能力。P2P模式虽然能省服务器带宽但一致性维护复杂度很高中心化服务器方案更容易控制和观测调试体验好太多。2. 技术选型先把同步协议和数据结构定下来2.1 实时通道选型为什么是WebSocket而不是轮询rea的实时通道我直接选了WebSocket没有犹豫。轮询方案每秒钟发几次HTTP请求延迟高浪费带宽而且每次请求都带完整上下文服务器处理起来很笨重。WebSocket建立一次长连接数据双向推送延迟能做到几十毫秒以内是这类场景最成熟的选择。选型时还考虑过Server-Sent Events也就是SSE。SSE是单向的服务器可以主动推给客户端但客户端往服务器推数据还得另走HTTP接口等于一套逻辑拆两套链路。rea需要客户端在编辑时不断把操作推给服务器SSE这个单向特性就成了硬伤直接排除。实际开发中还处理过WebSocket的一个细节浏览器原生API在不同网络环境下表现不太一致尤其是断线后的自动重连机制。rea在客户端封装了一层连接管理器心跳检测、断线重连、重连后的数据补齐都在这一层处理。这层封装虽然不是技术选型问题但选WebSocket就必须面对它后面第四节会展开说。2.2 协同算法的岔路口OT还是CRDT选完通道之后真正决定项目走向的是协同算法。业界两个主流路线摆在那里OT和CRDT。OT全程是Operational Transformation思路是每个操作都有明确的意图和位置服务器或客户端发现冲突时对操作做变换让后到的操作适应前一个操作造成的变化。这个方案数学味道很重Google Docs最早就是靠这个思路起步的。CRDT全程是Conflict-free Replicated Data Type思路更接近给每个字符一个世界上唯一的ID各端各自记录自己做的修改最后通过合并规则把所有人的修改汇总成一个确定的结果。Figma等产品采用过类似思路。我花了一周时间对比、实验最后选了CRDT这条路。原因有两个。第一OT需要对操作做变换不同操作类型之间还要定义如何互相适应理解和调试成本高实现错了会出现文档内容错乱这种很难复现的问题。第二CRDT的核心思想更直观每个字符有唯一ID删除或插入都针对ID操作合并不需要看操作发生的先后顺序规则简单出错率低。具体到rea的实现我参考的是论文里一种常见的CRDT变体每个字符分配一个唯一IDID由客户端标识加本地自增序列号组成。插入新字符时记录它的左邻居ID和右邻居ID。合并时根据这些邻居关系把字符放到正确位置。这个方法在数据结构上足够清晰20人并发场景下性能完全够用。下面用伪代码表示一下rea的字符ID结构// 每个字符的ID由 clientId sequence 组成 // 例如 clientId: c03, sequence: 17 // 这个字符的完整ID就是 c03:17 // 在文档中这个字符会被记录为 // { id: c03:17, value: 你, left: c02:8, right: c03:18 } // left 和 right 分别指向它在文档里的左右邻居3. 核心同步机制从操作日志到状态合并3.1 简化版的操作日志设计算法思路确定了接下来的问题是怎么把客户端的每一次编辑动作变成可以传输、可以合并的操作记录。rea的操作类型只定义了三类插入、删除、保留游标位置。插入操作携带的内容是新字符的ID、字符本身的值、它的左邻居ID、右邻居ID。删除操作携带的是被删字符的ID和值。每个操作都有一个本地时间戳客户端在本地立刻应用操作同时把操作追加到自己的操作日志里并发送给服务器。每个客户端的操作日志只存自己的操作这样设计是为了后续断线重连时能精确知道哪些操作已经同步给服务器了。日志里每个操作有一个递增的序列号服务器确认收到后客户端会在日志里给这个操作打上已确认标记。操作日志的数据结构如下// 客户端的操作日志 { operations: [ { seq: 0, type: insert, charId: c03:0, value: 你, left: null, right: null, acked: true }, { seq: 1, type: insert, charId: c03:1, value: 好, left: c03:0, right: null, acked: false }, { seq: 2, type: delete, charId: c03:1, value: 好, acked: false } ], remoteOps: [ // 这里存从服务器收到的其他客户端的操作 ] }3.2 服务端合并流程服务器端的工作说白了就两件事接收操作、转发操作。区别在于不能原样转发要在转发前把操作合并进一份全局状态。rea的服务器维护一个全局的字符链表每个字符按照ID和邻居关系排列。收到一个插入操作后服务器先检查这个字符ID是不是已经存在如果不存在就往链表里插入如果已经存在说明是重复消息直接丢弃。收到删除操作后检查字符ID是否还在在的话就移除不在就忽略。这个幂等设计很关键。因为客户端操作日志里的已确认标记是异步更新的网络抖动可能导致客户端重发同一个操作服务器必须保证同一操作处理多次和一次的效果一样。服务器收到操作后会把它广播给这个文档的其他在线客户端并且给当前客户端返回一个确认消息。确认消息里带着服务器当前的操作版本号客户端用它来推进自己的确认标记。这里遇到的一个明显问题是如果两个客户端同时插入字符到同一个位置比如都在你和好之间插入不同内容服务器该按什么顺序排CRDT的处理方式是不争论顺序而是定义一条可预测的排序规则。我的排序规则是先比较客户端ID再比较序列号。也就是说后插入的字符不一定排在后边排序完全由ID决定。3.3 一个具体的合并例子举一个实际跑通的场景。客户端A和客户端B同时打开一个空文档。A在位置0插入了字符aB在位置0插入了字符b。这两个操作的左邻居和右邻居都是null服务器先收到A的操作把a放进文档再收到B的操作发现b也有相同的邻居于是按规则比较客户端ID假设A的ID排序靠前就把b放在a后面。最终文档内容是ab还是ba取决于客户端ID的排序而不是操作到达服务器的时间。A屏幕上看到的是自己插入了一个a随后B的b出现在a右边。B进行同样的合并得到的最终结果也是一样的。这就是CRDT能保证多端最终一致的原理——各端用同一套排序规则即使操作到达顺序不同收敛结果必然相同。实际中遇到冲突处理时有个反直觉的点用户在本地立刻看到自己的操作生效了但这个生效只是本地视图服务器和其他客户端可能已经形成不同的局部顺序直到合并完成后视图才统一。为了让用户感知不到这个差异rea在本地应用操作时不等待服务器确认也就是采用了乐观更新策略用户打字完全没有延迟感。4. 真正难的不是算法是网络和工程细节4.1 断线重连和操作补偿CRDT解决了数据合并的理论问题但实战中大量时间都花在网络异常处理上。rea测试中最常遇到的场景是用户打字打到一半Wi-Fi闪断了一下然后又恢复如果没有重连补偿机制用户屏幕上反过来会出现内容回滚或丢失的现象。rea的重连流程设计为三步。第一步WebSocket连接断开后客户端心跳检测在5秒内没有收到服务器响应立刻进入重连状态。第二步重连成功后客户端把本地操作日志里所有未确认标记的操作重新发给服务器。第三步服务器对比自己的操作记录把缺失操作返回给客户端同时把这个客户端离线期间其他客户端产生的操作也一并返回。这里有个细节容易忽略重连补发操作不能只发给服务器如果某些操作其实已经到达服务器但没有收到确认服务器靠幂等规则直接丢弃重复操作但如果这个操作还没被其他客户端收到服务器需要重新广播一次确保所有端都拿到。所以客户端重连补发时服务器不仅要处理幂等还要主动广播。调试过程中还发现重连时的操作排序也要遵循CRDT的合并规则。假设客户端离线期间自己在本地输入了一堆内容其他客户端也在同一区域编辑过重连后本地和服务器状态合并时要重新按字符ID排序而不是简单地把本地内容追加在服务器内容后面。这个操作在rea里叫状态重建过程虽然不复杂但必须严格走到位否则会出现同一段文字出现两次的问题。4.2 高频输入的合并与节流编辑器的另一个工程细节是高频输入。用户在键盘上连续打字时每秒会产生十几到几十次插入操作。如果每个字符都立刻走一遍WebSocket发送加广播服务器压力大不说网络流量也会爆炸。更麻烦的是如果网络状况不好大量小操作会挤压在队列里重连时一次性补发几百个操作服务器处理不过来。rea的处理方式是把连续插入操作合并成批。具体做法是客户端设置一个50毫秒的定时器把在这段时间内产生的所有操作打包成一个批次发送。这样既保证了操作的实时性又把网络请求次数降到了原来的几十分之一。服务器端的批量处理同样重要。收到一批操作后先按客户端ID和序列号排序再逐个应用和广播。排序这一步不能省因为客户端本地操作日志的序列号是连续的同一批内的操作天然有序但服务器可能先收到后一批再收到前一批必须按序列号重新排队。4.3 大文档渲染的白屏问题文档内容超过一定规模后还有一个容易被忽略的瓶颈是DOM渲染。rea最初实现是每次收到操作就重新渲染整个文档文档只有几千字时毫无问题但塞进去一篇几万字的文档后每次插入一个字符都要重建整个DOM明显卡顿。解决方案用了虚拟滚动加内容分片。页面只渲染可视区域内的文本节点可视区域外的内容通过占位符撑开高度。每次操作后只需要找到受影响的文本节点局部更新即可。实现上需要维护一个从字符ID到DOM节点的映射表操作发生时能快速定位对应的DOM元素。这个映射表本质上就是索引插入和删除操作都会更新它。rea实测在十万字文档下单次操作后的DOM更新稳定在5毫秒以内基本感受不到卡顿。5. 怎么证明这个方案是靠谱的测试与观测5.1 随机并发模拟测试协同算法类的项目靠手工测试很难覆盖边界情况所以rea在测试上投入了不少精力。我写了一个并发模拟器脚本随机生成多个客户端每个客户端在随机时间点对文档的随机位置做插入或删除操作然后统一汇总并检查最终各端的文档内容是否一致。这个模拟器跑了几个晚上找出不少有意思的bug。其中一个典型的bug是当两个客户端在同一位置几乎同时删除同一个字符时服务器的幂等判断还不够完善导致已经删除的字符又被恢复出来。排查发现服务器收到删除操作后需要从文档链表里彻底移除该字符的引用同时把这个移除操作广播给所有人处理时不彻底就会出现恢复的现象。模拟测试的规则很简单就是无论中间过程怎么乱最终所有客户端的文档必须完全一致。一致性校验用两个维度字符内容一致性和字符顺序一致性。只要模拟器检测到任何不一致就会把触发不一致的操作序列完整打印出来方便复现。5.2 弱网环境下的表现另一个重要测试项是弱网模拟。我用专门的网络工具对WebSocket连接做了人为延迟和丢包注入模拟20%丢包、800毫秒延迟的恶劣环境。在这种条件下rea的客户端稳定性主要看断线重连机制能不能扛住高频断线。实测结果比较满意极端弱网下单次操作确认时间虽然会延长到几秒但用户不会丢失已经输入的内容。重连后操作补发和状态重建能在1秒内完成体验上就是偶尔卡顿但不丢数据。5.3 性能指标阶段测试下来的数据供参考在10个并发客户端同时编辑一份一万字的文档时服务器端操作广播延迟稳定在30毫秒以内CPU占用不超过30%。单个客户端每秒最多产生20次操作时端到端延迟在50毫秒以内。文档扩展到十万字时只有渲染层的重建延迟会上升但验证没有出现可见的白屏或卡死。测试过程中还发现一个值得注意的现象CRDT的合并操作在字符量大的情况下性能会下降因为每次插入都要在链表里做邻居查找。rea目前的处理方式是为每个文档维护一个字符ID到链表节点的哈希索引插入和删除都通过哈希索引直接定位避免了从头遍历。这个索引在十万字规模下表现足够好但如果扩展到百万字就需要考虑分片索引了。6. 后来居上的优化方向rea还能往哪走rea做到了能稳定支撑多人实时编辑之后有几个明确的扩展方向值得记录一下也都是我在做这个项目的过程中意识到下次可以改的部分。第一个方向是富文本支持。纯文本CRDT相对容易富文本则要为格式信息分配独立的ID字符ID结构要从每个字符一个ID改成每个字符和一个格式快照绑定合并时还要处理格式的覆盖优先级。这个改造会改动核心数据结构建议等文本方案的测试充分之后再做。第二个方向是操作压缩。rea目前每个字符的插入操作都携带完整的左右邻居ID网络传输中这部分开销很大。可以优化为同一个客户端连续插入的多个字符共享一个左邻居用相对位置描述传输体积能压缩到原来的三分之一左右。第三个方向是离线编辑。目前的架构要求保证在线才能协同。如果要支持离线编辑需要在本地保存一份完整文档快照和操作日志重连后把快照和日志一起提交。这个功能对CRDT来说天然友好因为CRDT的合并规则不依赖操作到达顺序只要客户端离线期间没有和其他端产生不可收敛的冲突就能顺利合并。第四个方向是多文档服务化。rea目前每个文档是独立实例文档之间没有共享逻辑。如果要做成平台需要把文档列表、权限校验、操作审计这些能力加上。核心协同引擎可以保持独立外围系统按常规业务系统设计即可。从架构角度看rea最大的收获是让我理解了一个道理实时协同系统的复杂度不在某个单点技术而在整个链路的鲁棒性。算法选型只是第一步网络重连、操作排序、状态重建、渲染优化每一层都会成为瓶颈。把这些环节逐个击破才能真正跑出一个可靠系统。如果你也在做类似的项目建议先定一个小的目标范围把基础链路跑通之后再加复杂度。这个过程踩坑不少但收获确实值回票价。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑