协同编辑比你想的难
CRDT 与 OT 各有取舍,多数人没注意到的那些坑。聊聊多人实时文本编辑的工程现实,以及该怎么选。

Google Docs 为数百万用户提供实时协同编辑。看起来毫不费力:你打字,光标移动,协作者的修改瞬间出现。但这种简单是假象。在底层,协同编辑是分布式系统中最难的问题之一,解决它的两种主流方案(操作转换 OT 和 CRDT)各自的取舍,只有真正动手做过才会明白。
CRDT(无冲突复制数据类型)的宣传很有吸引力:数据结构可以自动合并、不产生冲突,不需要中心服务器,最终一致性由数学保证。但正如几个团队在为协同编辑器选择 CRDT 之后发现的那样,现实要乱得多。理论很优美,工程却很残酷。
问题所在:并发编辑
核心挑战是:两个用户同时编辑同一份文档,彼此之间存在网络延迟。用户 A 在位置 5 插入 ‘Hello’。用户 B 还没看到 A 的修改,就删除了位置 5 的字符。最终文档应该是什么样?
这个问题本身就有歧义。B 的删除应该作用于 A 插入之前位于位置 5 的字符,还是插入之后的?如果是之前,删掉的是原来的字符,最后出现的是 ‘Hello’;如果是之后,删掉的是 ‘Hello’ 里的 ‘H’。两种理解都说得通。系统必须选定一种,并确保所有客户端收敛到同一结果。一旦出现分歧,两个用户看到的文档就不一样了,这是绝对不能接受的失败模式。
The convergence problem:
Initial document: "The quick brown fox"
^ position 10
User A (at time T): Insert "very " at position 10
User B (at time T): Delete character at position 10
A sees locally: "The quick very brown fox"
B sees locally: "The quick rown fox" (deleted 'b')
Now A receives B's operation: delete at position 10
But A already inserted 5 chars at position 10...
Should B's delete now apply at position 10? (deletes 'v' from 'very')
Or at position 15? (deletes 'b' from 'brown' — the original target)
Both A and B must arrive at the same document.
This is the problem OT and CRDTs solve differently.
操作转换(OT)
OT 是较早的方案(1989 年提出),它的思路是让操作之间相互变换。当 A 收到 B 的 ‘在位置 10 删除’ 操作时,A 的系统会检查:有哪些 A 已经应用、而 B 还没见过的操作。然后对 B 的操作做变换:既然 A 在位置 10 之前插入了 5 个字符,B 的删除就应该改为作用于位置 15(原目标字符向右移动了)。
这套方案确实有效,Google Docs 就是用它的。问题在于变换函数本身。对于一个只有插入和删除的文本编辑器,你需要定义每一对操作之间如何变换,两种操作就有四种情况。再加上格式、表格、图片、列表和评论,情况数会组合式爆炸。每一种情况都必须正确,而细微的 bug 会导致文档分歧,这类问题几乎无法复现,也很难调试。
OT 传统上还需要一个中心服务器来为操作建立全局顺序。没有服务器的话,并发操作可能被不同客户端以不同顺序变换,从而得出不同结果。Google 有能力为 Docs 部署中心服务器,但点对点应用很难直接用 OT。
CRDT:理论上的承诺
CRDT 采用了一种完全不同的思路。它不去变换操作,而是使用这样一种数据结构:无论以何种顺序合并,结果都相同。这是数学上的保证,只要数据结构是合法的 CRDT,收敛就是自动的。没有变换函数,没有中心服务器,也不依赖操作顺序。
对于文本编辑,主要的 CRDT 变体是 RGA(Replicated Growable Array)及类似的序列 CRDT。它们不去追踪位置(位置会随编辑而变化),而是为每个字符分配一个全局唯一、有序的标识符。插入会在已有标识符之间生成新的 ID;删除则把某个 ID 标记为墓碑(tombstone),而不是真的移除,稍后细说。
CRDT sequence representation:
Document: "cat"
Internal representation (simplified):
ID: (A,1) → 'c' (user A, logical clock 1)
ID: (A,2) → 'a' (user A, logical clock 2)
ID: (A,3) → 't' (user A, logical clock 3)
User B inserts 'h' between 'c' and 'a':
ID: (B,1) → 'h' position: between (A,1) and (A,2)
Document: "chat"
User A (concurrently) inserts 'r' between 'c' and 'a':
ID: (A,4) → 'r' position: between (A,1) and (A,2)
Both IDs go between the same characters.
The CRDT uses a deterministic tie-breaking rule
(e.g., higher user ID wins) to order them.
Final document: "chart" or "chrat"
(deterministic — all clients get the same result)
CRDT:实践中的真实情况
CRDT 的承诺(无冲突、去中心化、数学保证收敛)是真实的,但工程上的挑战相当大。
墓碑不断累积。在 CRDT 中删除一个字符时,它不能从数据结构里真正移除。其他副本可能还没见过这次删除,仍然需要这个 ID 来正确定位它们自己的编辑。因此被删除的字符会被标记为墓碑:不可见,但仍然存在。一份经过大量编辑的文档会积累成千上万的墓碑。一篇 1000 字符的文档,编辑历史里可能产生 50,000 个墓碑。这会让内存膨胀,操作也变慢。
元数据开销巨大。每个字符都需要一个唯一 ID(用户标识加逻辑时间戳)、指向相邻 ID 的指针以及墓碑标记。每个字符的元数据可能达到 50–100 字节。一份 100KB 的文档,其 CRDT 表示可能达到 5–10MB。这对同步影响很大,把完整的 CRDT 状态通过网络发送,成本非常高。
性能随历史增长而下降。序列 CRDT 上的操作并不是 O(1)。插入一个字符需要在按 ID 排序的序列中找到正确位置,而这取决于 ID 的总数(包括墓碑)。对于编辑历史很长的文档,速度会明显变慢。
意图保持很难。CRDT 保证收敛,所有副本最终会达到相同状态。但它们是否达到了正确的状态?当两个用户同时编辑同一个词时,CRDT 会按确定性规则交错两人的字符。结果是收敛的,但可能毫无意义。OT 可以设计成保留一个用户的编辑完整,再把另一个用户的编辑绕过去应用。CRDT 的合并则是机械式的。
Yjs 与实践中的折中方案
Yjs 是 JavaScript 生态中最流行的 CRDT 库,许多 Web 应用的协同功能都基于它。它的工程质量很高,通过紧凑的二进制编码降低了元数据开销,并在所有客户端都在线时可以对墓碑进行垃圾回收,从而缓解了不少理论上的 CRDT 问题。
但 Yjs 并不是银弹。采用它的团队会发现,CRDT 库解决的是收敛问题,而不是协作问题。你仍然需要:用于节点发现的信令服务器、用于离线修改的持久化层、高层操作的冲突解决(比如两个用户同时重组同一个章节怎么办?)、在线状态追踪(光标、选区)、尊重其他用户编辑的撤销与重做,以及权限管理。
有些团队在基于 Yjs 构建协同编辑器之后得出结论:CRDT 带来的复杂度超出了他们的需要。如果你的应用有中心服务器(而大多数应用都有),OT,甚至更简单的方案(如带冲突检测的最后写入获胜),可能更实际。CRDT 真正的用武之地是纯点对点场景,比如离线优先的应用、本地优先(local-first)软件,以及无法假设存在中心权威的系统。
大多数应用应该怎么做
如果你要为应用添加协同编辑,这里有一个务实的决策框架。
- 如果你有中心服务器,且文档较小:用 OT。Google 的方案行之有效。ShareDB 等库为 Node.js 实现了 OT。中心服务器让排序、持久化、冲突解决和权限管理都简化了很多。
- 如果你需要离线支持或点对点:用 CRDT(Yjs 或 Automerge)。CRDT 是唯一能正确处理真正离线编辑和点对点同步的方案。把元数据开销和墓碑累积视为去中心化的代价,并接受它。
- 如果你的协作者很少同时编辑同一章节:你可能两者都不需要。简单的加锁(每个章节同一时间只允许一个编辑者),或者配合良好冲突提示 UI 的最后写入获胜,就能覆盖许多实际的协作场景,而无需 OT 或 CRDT 的复杂度。
- 如果你要做一个 Google Docs 竞品:你需要一支分布式系统工程师团队,以及数年的开发时间。这不是周末项目。协同编辑表面上的简单,掩盖了极大的复杂性。
坦率的评估
协同编辑是那种难度不那么显而易见的问题。顺利路径(两个用户做互不重叠的编辑)几乎任何方案都能应付。困难场景(对同一区域的并发编辑、离线编辑后的延迟同步、复杂的文档结构如表格、嵌套列表、嵌入对象)则需要深入理解 OT 与 CRDT 之间的取舍。
两种方案都谈不上绝对更优。OT 有服务器时更简单,没有服务器时则更难;CRDT 不需要服务器,但要承担元数据开销和墓碑累积。两者都需要在核心算法之外做大量细致的工程工作,而那些工程(在线状态、持久化、权限、撤销)往往比收敛问题本身更难。
业界正在慢慢收敛到一种务实的混合方案:用 CRDT 作为数据模型(自动合并保证),再用服务器负责协调(在线状态、权限、垃圾回收)。这样既能得到数学上的收敛,也能获得实用的基础设施。但代价是两个世界的复杂度都要承担,这正是协同编辑在 35 年的研究之后依然是难题的原因。


