去年我给自己定了一个有点自虐的目标:不借助任何现成的富文本框架和协同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变体:每个字符分配一个唯一ID,ID由客户端标识加本地自增序列号组成。插入新字符时,记录它的左邻居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插入了字符"a",B在位置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最大的收获是让我理解了一个道理:实时协同系统的复杂度不在某个单点技术,而在整个链路的鲁棒性。算法选型只是第一步,网络重连、操作排序、状态重建、渲染优化,每一层都会成为瓶颈。把这些环节逐个击破,才能真正跑出一个可靠系统。如果你也在做类似的项目,建议先定一个小的目标范围,把基础链路跑通之后再加复杂度。这个过程踩坑不少,但收获确实值回票价。