这个系列我一直在跟着看,到了第五十二篇,内容依然没有让人失望。这期 PaperSpace 博客把焦点放在了在线文档的实时协同机制上,讲的是多个用户同时编辑同一个文件时,后台到底怎么保证每个人的屏幕都不乱、输入不丢、最终内容还能收敛一致。老实说,“协同编辑”这四个字听起来不复杂,但真正实现过的人才知道,里面全是细节坑。这篇我一边翻译一边补了不少自己的实操体会,顺便把几个关键机制的来龙去脉给理顺了。
如果你正准备做在线协作文档、多人白板、实时笔记这类产品,或者已经在写了但遇到冲突、丢操作、光标错乱这些问题,这篇文章应该能帮上忙。就算你只是好奇“几个人同时打字为什么不崩”,也能看懂大概。
1. 协同编辑的底层逻辑:先搞清楚我们在解决什么问题
1.1 多人同时编辑的本质矛盾
PaperSpace 这期的开头提了一个特别朴素的问题:两个人在同一段文字里,一个把“苹果”改成“香蕉”,另一个把“苹果”改成“梨子”,系统到底该听谁的?
这不是一个简单的“后保存的赢”的问题。因为在线文档里没有传统意义上的“保存”,每次按键都是一个即时操作,立刻广播给其他人。也就是说,“苹果”这个位置同时收到了两个修改指令,而这两个指令都认为自己基于的是同一个旧版本。谁后到谁就覆盖谁,结果就是另一个人打了一半的字突然消失,体验极其糟糕。
这个问题本质上是分布式系统里的经典考题:多个副本同时修改同一份数据,如何保证最终一致。传统的解决思路是加锁,一个人编辑的时候别人只能看。但这对现代协作产品来说完全不可接受——谁愿意在一个文档里等别人打完字才能动鼠标?
所以协同编辑的核心目标有三个:第一,每个用户的编辑操作要实时广播;第二,任意两个用户的修改如果冲突了,要有确定的合并规则;第三,所有用户最终看到的内容必须一致,不管操作以什么顺序到达。
1.2 OT 与 CRDT:两条完全不同的路
这篇文章花了很大篇幅对比两种主流方案,我把里面的核心差异提炼成了一张表:
| 对比维度 | 操作转换(OT) | 无冲突复制数据类型(CRDT) |
|---|---|---|
| 核心思想 | 对并发操作做变换,让后到的操作适应先到的操作 | 每个操作携带足够信息,天然可以合并,不需要变换 |
| 状态存储 | 服务端单一文档状态,操作即消息 | 每个副本维护完整数据结构和元数据 |
| 合并方式 | 通过 transform 函数将操作转换为等效操作 | 通过偏序关系(如字符 ID)直接合并 |
| 复杂度 | 算法本身难,边界情况多 | 算法相对直观,但存储和同步数据量大 |
| 典型适用 | 中心化服务器架构,文档类编辑 | 点对点、离线编辑、多端同步场景 |
PaperSpace 的结论很中肯:没有哪个方案绝对更好,取决于你的产品架构。如果是中心化服务器,OT 依然是稳定且可控的;如果要做离线优先、多设备自由同步,CRDT 会更省心。
1.3 不管走哪条路,操作日志是地基
原文里反复强调一个概念:协同编辑的一切都建立在“操作日志”之上。你每打一个字、删一个字、改一个格式,不是直接改最终结果,而是先在本地生成一条操作记录,再把这条记录同步到所有节点。你的文档只是这些操作按顺序重放之后的产物。
这个思路很像银行账户和流水的关系。账户余额只是一个汇总数字,真正可靠的是每一笔存取款流水。如果两个人同时往账户里打钱,只要流水不丢、顺序不乱,最终对账一定一致。文档也一样,只要操作日志完整,任何时候都能从零重建出文档的最终状态。
这个思维转变很重要,因为很多新手实现协同编辑时,第一反应是在“最终结果”上做同步,比如直接广播整段文本。这在两人小规模编辑时好像能用,但只要稍微并发一点就崩得没法看。
2. 核心机制拆解:操作定义、版本顺序与冲突合并
2.1 一次编辑是如何变成一条操作的
要理解协同算法,必须先理解操作的表达形式。PaperSpace 借用了一个非常经典的操作模型:把文档看作一串字符序列,每个操作由若干“保留”和“插入/删除”动作组成。
举个例子,原始内容是“你好世界”,用户在“你好”后面插入“美丽”两个字。这个操作就可以表示成:先保留 2 个字符,再插入“美丽”,再保留后面的字符。严谨一点,可以表达为一串指令:
[ { type: 'retain', count: 2 }, { type: 'insert', text: '美丽' }, { type: 'retain', count: 2 } ]同理,删除操作可以表示成先保留到目标位置,再跳过若干个字符,最后保留剩下的部分:
[ { type: 'retain', count: 2 }, { type: 'delete', count: 1 }, { type: 'retain', count: 1 } ]之所以用“保留”而不是直接用绝对位置,是因为插入和删除都会改变后续所有字符的位置。如果一刀切地记录绝对位置,第二个并发操作就不知道该往哪儿落了。用相对索引的指令序列,每个操作描述的是“基于某个特定版本,我做了什么改动”,后续合入时只要按顺序执行这些指令就行。
这个设计跟很多人的直觉不一样,我当初刚接触时也觉得“直接记录 start 和 end 下标不是更简单吗”。但实测下来,用 retain/insert/delete 这套指令模型,整个协同算法的代码会干净很多,因为你在做操作转换时,本质上就是对两条指令序列做逐个指令的调整。
2.2 版本号:给所有操作排一个逻辑顺序
光有操作格式还不够,系统必须知道每个操作是建立在哪个版本之上的。PaperSpace 原文里提到了一个很关键的概念:版本号,不是时间戳,而是一个单调递增的整数。
客户端的每个本地操作,都会基于当前已知的最新版本号生成。比如服务端版本是 10,客户端收到了版本 10 的快照,用户立刻打了一个字,这个新操作的基版本就是 10。操作推给服务端后,服务端将其应用到自己的文档上,版本号变成 11,再广播给所有客户端。
这里要注意,版本号是用来判断因果关系的,不是用来决定“谁覆盖谁”的。A 操作基于版本 10,B 操作也基于版本 10,那么 A、B 是并发关系。C 操作基于版本 11(A 已经应用后的版本),那么 C 依赖于 A。有了这层依赖信息,冲突合并时才知道哪些操作可以直接按序应用,哪些操作需要特殊处理。
2.3 两个客户端同时改同一段:OT 的 transform 到底在干嘛
当两个并发操作“撞车”时,OT 的思路是:让其中一个操作保持原样,把另一个操作“变换”成既能保留操作者意图、又不会和前者冲突的新操作。这一步就是 transform。
来个最经典的例子。两个人同时基于“你好世界”编辑:
- 用户 A 的操作:在第 1 个字符后插入“美丽”,得到“你美丽好世界”
- 用户 B 的操作:在第 2 个字符后插入“的”,得到“你好 的 世界”
A 先到服务端,B 后来。如果直接把 B 的操作应用在 A 之后,B 的插入位置就有问题了。因为 A 在位置 1 插入了两个字符,B 原本想在位置 2 插入,现在位置 2 已经变成了“美”。如果把 B 的“的”硬插到第 2 个字符后面,结果会变成“你美的好世界”,与 B 的意图完全不符。
transform 的作用就是根据 A 的操作,把 B 的操作改成“在第 4 个字符后插入‘的’”,也就是把 A 造成的位置偏移算进去。这样 B 的操作应用到 A 之后,结果就是“你美丽好的世界”,两个人的插入都保留下来了,位置也符合各自直觉。
原文里给了一个非常重要的提醒:transform 必须同时处理 retain 和 insert 的边界情况。比如 A 在位置 2 插入了一个字符,B 的第 2 个字符本来要删除,那么 B 的删除目标要不要跟着偏移?这没有标准答案,取决于产品想让用户感觉“删的是哪个字”。很多早期实现都栽在这种边界细节上。
2.4 CRDT 的另一种合并思路
CRDT 的合并方式和 OT 完全不同。它不试图去“变换”操作,而是给每个字符分配一个全局唯一的 ID,合并时按 ID 的偏序关系决定字符之间的相对位置。
PaperSpace 里举了一个很直观的例子。两个用户同时在一篇文档开头插入一个字符,A 插入字符 X,ID 是 (clientA, 102);B 插入字符 Y,ID 是 (clientB, 88)。当两个操作同时到达对方时,系统只需要按规则比较这两个 ID,决定 X 和 Y 谁在前谁在后。这个规则通常是先比较时间戳或逻辑时钟,再比较客户端 ID,保证所有节点得出相同结论。
删除在 CRDT 里也有自己的处理方式:不是把字符直接从结构里抹掉,而是给它打上一个“墓碑”标记。因为直接删除会破坏其他字符的 ID 排序信息,万一另一个节点还没收到这个删除操作,就会把 ID 顺序搞乱。墓碑方案牺牲了一定的存储空间,换来了删除和其他操作可以自由并发合并的安全性。
我刚接触 CRDT 时觉得这方案有点笨重,但后来做一个离线优先的笔记工具,才发现它的好处:每个节点都是平等的,没有中心服务器也能收敛一致。这在纯中心化架构里很难实现。
3. 动手实现一个最小可运行的协同模块
理论说太多容易飘,PaperSpace 原文很清楚这一点,所以给了不少实现层面的讲解。我顺着它的思路,整理了一套精简但能跑通主流程的代码骨架,供你参考。这里的代码刻意保持简单,只体现核心机制,不包含容错和边界处理。
3.1 第一步:定义消息协议
所有客户端和服务端通过 WebSocket 通信,消息格式统一用 JSON。一次操作消息的主要字段如下:
{ "type": "operation", "clientId": "client-abc", "baseVersion": 10, "op": [ { "type": "retain", "count": 2 }, { "type": "insert", "text": "美丽" }, { "type": "retain", "count": 4 } ] }字段含义必须明确:
- clientId:标识操作来自哪个客户端,后续做操作归属和去重要用。
- baseVersion:这个操作基于哪个版本生成。
- op:操作指令数组。
服务端自身维护一个递增版本号。每当收到一个 baseVersion 等于当前版本的操作,就直接应用并广播;如果收到的 baseVersion 小于当前版本,说明是迟到或乱序操作,需要先做 OT 转换再应用。
3.2 第二步:写一个最简的 OperationQueue
每个客户端维护三块主要状态:当前文档内容、本地待确认操作队列、服务端已确认版本号。当用户输入时,先本地生成操作,立刻应用到本地文档,然后放入待确认队列,同时把操作发送给服务端。在收到服务端的确认回包之前,这个操作一直留在队列里。
队列的作用很关键。它记录的不仅是“我发了个操作”,还记录了“我的操作在服务端看来是基于哪个版本”。如果在等待确认期间又产生了新操作,新操作的 baseVersion 要往前看,不能简单用当前本地版本号。
这里我最开始犯过一个错误:只在本地应用了操作,却忘了把操作放进队列,导致后续操作转换时找不到上下文,对不上号。后来总结了一句口诀:每次本地操作,必须先入队,再广播。顺序反了就出问题。
3.3 第三步:最简单的 OT 转换函数
OT 的完整实现有很多分支,但核心可以浓缩成一个函数:给定两个基于同一版本的并发操作 op1 和 op2,把 op2 转换成基于“op1 应用之后”版本的新操作。
function transform(op2, op1) { const result = []; let i = 0; let j = 0; let retainCount = 0; while (i < op2.length || j < op1.length) { const a = op2[i]; const b = op1[j]; if (!a) { result.push(b); j++; continue; } if (!b) { result.push(a); i++; continue; } if (a.type === 'retain' && b.type === 'retain') { const common = Math.min(a.count, b.count); result.push({ type: 'retain', count: common }); a.count -= common; b.count -= common; if (a.count === 0) i++; if (b.count === 0) j++; } else if (a.type === 'insert') { result.push(a); i++; } else if (b.type === 'insert') { result.push(b); j++; } else if (a.type === 'delete' && b.type === 'delete') { const common = Math.min(a.count, b.count); a.count -= common; b.count -= common; if (a.count === 0) i++; if (b.count === 0) j++; } else { // 其他组合情况需要根据产品语义处理,这里略过 break; } } return result; }注意:这是一个高度精简的实现,只保留了核心遍历逻辑。真正的生产级 transform 还要处理同一位置插入的并列顺序、删除与插入的先后关系、零长度 retain 的丢弃等。但这套骨架能帮你理解 OT 是怎么“一碗水端平”的。
3.4 撤销重做:比想象中麻烦
单机编辑器里,撤销可以简单地“反向执行上一步操作”。协同环境里不能这么做,因为你的上一步可能已经被别人后续的操作影响过了。
PaperSpace 原文给了一个实用建议:不要用“全局栈”来管理撤销,而是用“每个客户端各自维护一个操作栈”。用户 A 的撤销操作只撤销 A 自己产生的操作,B 的操作原样保留。撤销本质上也是生成一条新的操作:找到目标操作,构造它的“逆操作”,然后像普通操作一样广播出去。
但这里有个细节:如果你的逆操作和别人基于同一版本的并发修改又冲突了,你还需要对逆操作再做一次 transform。所以从实现角度讲,“撤销”不是一件独立的事情,它就是一次特殊的编辑操作。想明白这一点,代码结构就清晰了。
3.5 远程光标:另一个容易被低估的难点
如果只想让文档内容一致,光标同步不做也可以。但一个协作编辑器如果没有别人的光标,体验就像打电话时看不到对方的表情,总感觉少了什么。
远程光标的本质是:每个用户周期性地把自己“当前聚焦的位置”广播给其他人。可问题是,文档一直在变,光标位置又会随着别人插入删除而偏移。如果你听到对方的光标停在“第 10 个字符”,等你本地文档已经变成 20 个字符时,就不知道它具体停在哪了。
常用做法是:把光标位置连带最后一次知道的操作 ID 一起广播。收到广播的人用自己的文档状态重建光标位置。这个重建逻辑可以复用前面 2.3 提到的位置转换思路,相当于把对方的光标位置也当作一个小小的“操作”来处理。实现上不复杂,但很容易被忽略,等到联调时才发现两地光标完全对不上。
4. 实战中踩过的坑与排查思路
PaperSpace 这篇花了接近三分之一的篇幅讲实现中会遇到的问题。我结合实际开发经验,把最典型的几个场景整理成速查表,从症状到排查方向一次说透。
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 编辑后几秒内容被“还原” | 本地操作和服务端操作未正确合并 | 检查客户端待确认队列与服务端版本是否对齐 |
| 光标随机跳动、输入卡顿 | 远程光标位置重建出错,或并发操作转换异常 | 检查操作的位置偏移逻辑,尤其是插入/删除叠加时 |
| 离线一段时间后合并丢内容 | 服务端未保留足够的操作历史 | 检查操作日志的持久化策略和快照频率 |
| 两个人同时插同一位置,顺序不稳定 | transform 对相同位置插入的优先级未定义 | 统一用 clientId 作为次优先级排序 |
| 撤销操作把别人的内容也撤销了 | 撤销栈设计成了全局栈而非客户端级栈 | 改为按客户端维护独立操作栈 |
4.1 编辑一秒后被“还原”的经典案例
这个现象我遇到不止一次,症状是:自己打字立刻显示在屏幕上,但大概一秒后那些字突然消失,像被系统吞了一样。
排查后发现,根因往往不在编辑逻辑,而在版本对齐。客户端的本地操作以 baseVersion 10 生成并发送。服务端此时已经应用了另一个客户端基于版本 10 的操作,所以服务端版本已经是 11 了。服务端收到这个 baseVersion=10 的操作时,本应先做 transform 再应用,但代码里如果漏掉了这一步,直接把操作应用到最新版本上,就会导致该操作插入的位置全部偏移,甚至落到文档末尾之外,最终表现为内容丢失。
解决方法是:服务端处理每个操作前,先比对 baseVersion 和当前版本。不一致时,必须把所有差值操作依次 transform 到当前版本,再应用到文档。
4.2 光标乱跳:不是玄学,是位置映射没做
多人编辑器里,光标随机跳到行尾、跳到另一个用户正在输入的位置,这些都很影响体验。我排查过的多数情况,问题并不在光标算法本身,而是操作合并时没人同步更新远程光标的位置。
具体来说,如果你收到了别人插入的操作,本地文档变长了,但你缓存里的远程光标位置还是旧的索引。下一次远端光标位置广播到达时,系统在旧索引上画光标,画出来的位置就完全对不上。正确的做法是:每当本地应用一个远端操作,就要遍历当前所有远程光标,把它们的索引按该操作做一次偏移计算。
这里没有什么捷径,就是在操作应用的管线里加上“光标位置更新”这一步。忘记加,后果非常隐蔽,因为单机测试根本发现不了。
4.3 离线合并后格式错乱:快照与操作历史缺一不可
离线编辑场景里,一个比较常见的实现是:客户端离线期间把操作记在本地的日志文件里,恢复网络后一次性同步给服务端。如果服务器在此期间也收到过来自其他人的操作,客户端和服务端的操作历史就是分叉的。
此时如果服务端没有保存足够长的操作历史,就无法判断客户端那些操作具体基于哪个旧版本。最粗暴的解决办法是:客户端上传全量快照,服务端用 CRDT 规则合并,但这样容易覆盖别人离线期间的新内容。
更稳妥的方案是双轨并行:服务端定期生成快照,并保留从快照之后的所有操作日志。客户端恢复连接后,上传离线操作及对应的 baseVersion,服务端找到最近的共同快照,重放双方操作,用 OT 或 CRDT 合并后再回传新快照。这套流程能跑通的前提,就是 1.3 里说的“操作日志不能丢”。
4.4 性能:操作合并与节流
协同编辑还容易忽略性能问题。每个按键都即时发送,思路是对的,但网络再快也有延迟。如果用户一次粘贴了 5000 字的文本,你别真的生成 5000 个插入操作逐条发送,那会把服务端和所有在线客户端都打崩。
优化手段有两个方向。一是操作折叠:把短时间内同位置连续输入的多条插入操作合并成一条;二是发送节流:隔一段时间(比如 100 毫秒)打包一次增量操作,减少网络包数量。合并操作时要小心,折叠后的操作不能改变语义,否则接收端重放时会和发送端本地状态不一致。
另一个性能陷阱是服务端保存的操作历史无限增长。即使有日志是好事,磁盘终究是有限的。需要设定一个策略:定期对当前文档做快照,把快照之前的日志清掉,客户端的同步起点改为最近的快照版本。日志只作为兜底,不作为永久存储。
5. 一点个人体会
翻译这篇 PaperSpace 过程中,我翻来覆去对比了 OT 和 CRDT 在各种场景下的表现,最大的感触是:没有哪个算法是银弹,真正决定方案质量的是对操作模型的深刻理解。哪怕你决定用 CRDT,操作日志、版本判断、位置偏移这些基本功一样也不能少;哪怕你用 OT,设计不当照样会遇到一堆合并乱掉的情况。
我个人的经验是,第一版宁可牺牲一点性能,也要把操作日志和版本机制做得非常明确。日志清晰了,后面排查问题会省掉大半力气。很多线上事故之所以难查,就是因为操作日志没存全或者没有版本号,所有人抱着最终的文档内容猜来猜去,效率极低。
最后再分享一个实用的小技巧:在做任何协同算法验证时,创建一个本地“双客户端测试环境”,用两个 WebSocket 客户端连同一个服务端,通过脚本模拟一个人疯狂输入、另一个人疯狂删除。看起来很简单,但这个暴力并发测试几乎每次都能逼出几个隐藏的边界问题。别等到联调时再让测试人员帮你踩,自己先踩一遍心里才踏实。