多人协作编辑时,状态合并与冲突处理的核心数据结构设计

多人同时编辑同一份文档,状态合并的核心数据结构不是 OT 操作队列,也不是 CRDT 的位图,而是一个带版本向量(version vector)的键值增量映射表。这是我做了四年协同编辑器底层重构后的直接判断。

先把这个结论拆开看。协同编辑本质上要解决两个问题:多人的操作如何合并成一个一致的状态,以及合并时产生冲突怎么处理。市面上流行的 OT 算法和 CRDT 算法都在解决这两个问题,但它们的实现复杂度被严重高估了——真正决定系统健壮性的不是算法选型,而是你选了什么数据结构来承载状态变更。

版本向量:比你想象的简单,但大多数实现都用错了

版本向量是一个从客户端 ID 到逻辑时钟的映射,像这样:

{
  "client-A": 5,
  "client-B": 3,
  "client-C": 7
}

它的语义很直白:client-A 已经看到了自己产生的所有操作直到第 5 个,也看到了 client-B 的前 3 个操作,client-C 的前 7 个操作。每产生一个新操作,对应客户端的计数器加一;每收到一个远程操作,合并进自己的版本向量。

但这里有个几乎所有初级实现都会踩的坑:版本向量不是全局时钟,你不能用服务器时间戳替代它。2019 年我在处理一个文档系统的离线编辑问题时,看到前任工程师用 Date.now() 作为操作序号,理由是“毫秒级精度够用了”。结果两个用户在同一秒内编辑同一行,服务端无法判断因果顺序,整个文档状态直接撕裂。修复方案就是把时间戳换成逻辑时钟,客户端 ID 用 UUID v4 生成,版本向量的每个键值对严格遵循 happens-before 关系。

版本向量的比较规则就三条:

  1. 如果 A 的每个键的值都小于等于 B 的对应键的值,且至少有一个严格小于,那么 A 是 B 的祖先,A 的操作已经被 B 包含。
  2. 如果 A 和 B 互不为祖先(存在交叉的大于小于关系),它们就是并发操作,需要冲突处理。
  3. 如果两者完全相等,它们是同一个状态。

这三条规则用 20 行代码就能实现,但它们是整个协同系统因果一致性的基石。

增量映射表:状态不是全量快照,是键值对的增量集合

回到标题里的第二个核心结构。在实时协作中,每一次编辑操作不应该被建模为“文档的新完整状态”,而应该是“哪些属性被改成了什么值”的增量集合。我用的是这个结构:

interface StateDelta {
  clientId: string;
  sequence: number;          // 该客户端产生的第几个操作
  versionVector: Record<string, number>;  // 操作产生时的版本向量
  mutations: Map<string, any>;   // 属性路径 -> 新值
  timestamps: Map<string, number>; // 属性路径 -> 修改时间
}

mutations 是一个扁平化的键值映射,键是属性路径,比如 "layers.3.opacity""text.blocks.12.content",值就是该属性被修改后的新值。这样做的好处是合并逻辑极度简化:当收到一个远程操作,我只需要遍历它的 mutations,对每个键检查本地是否有并发修改,没有就直接覆盖,有就进入冲突处理。

这个结构比 OT 的操作序列好在哪?OT 要求你保留完整的操作历史并支持变换(transform),两个并发操作要互相变换后才能应用。而增量映射表直接把操作抽象成了“意图”(我想把这个属性设成什么值),合并时看的是最终意图而不是中间过程。对于图形编辑、表单协作、配置项协同这些场景,这比 OT 简单一个数量级。

一个真实的性能数据:在我们的设计工具中,一个包含 200 个图层属性的文档,用全量快照做同步,每次操作产生约 45KB 的 JSON 数据;换成增量映射表后,单次操作的数据量降到 200 字节到 2KB 之间,服务端合并的 CPU 时间从 12ms 降到 0.3ms。这不是优化,是数据结构选型带来的数量级差异。

冲突处理的核心:最后写入者胜出不是方案,是逃避

说到冲突处理,大多数实时协作系统的文档会告诉你“我们采用最后写入者胜出策略(LWW)”。这不是策略,这是偷懒。LWW 在纯文本协作里勉强能用,但在属性编辑场景下会导致静默数据丢失。

真正的冲突处理需要在增量映射表的每个属性上附加时间戳,然后执行逐属性合并。合并规则分三种:

规则一:无冲突直接应用。 如果远程操作的版本向量是本地版本向量的祖先或后代,说明这两个操作有因果先后关系,直接用版本向量较新的那个值。这在代码里就是一次版本向量比较。

规则二:不同属性并发修改,自动合并。 如果远程改了 layer.3.color 而本地改了 layer.3.position,这两个操作虽然并发,但修改的是不同属性,直接都保留。这正是增量映射表比文档级快照的优势——合并粒度从“整个文档”细化到了“单个属性”。

规则三:同属性并发修改,进入冲突解决器。 这是唯一需要业务逻辑介入的地方。冲突解决器是一个纯函数:

type ConflictResolver = (
  key: string,
  localValue: any,
  remoteValue: any,
  localTimestamp: number,
  remoteTimestamp: number
) => any;

对于数值类型属性,一个常用的策略是取较大值或平均值;对于颜色值,可以用混合算法;对于文本内容,这就是需要展示冲突标记让用户手动解决的地方。

关键实现细节:冲突解决器的调用必须延迟到读取该属性时才执行,而不是在收到操作时立即执行。这个惰性求值策略让合并操作变成 O(n) 而不是 O(n²)——n 是 mutation 数量。2021 年我们遇到过一个性能事故,某个文档有 800 个并发修改的属性,每次同步都触发全部冲突解决器,导致浏览器卡顿 2 秒。改成惰性求值后,用户当前视口外的属性根本不会触发冲突解决,交互延迟恢复到 16ms 以内。

完整的数据流:从操作产生到状态一致

把上面的结构串起来,整个协同编辑的数据流是这样的:

  1. 用户修改属性,产生一个 StateDelta,携带当前客户端的版本向量。
  2. 本地立即应用这个 delta 到本地状态,同时推入未确认队列。
  3. Delta 发送到服务端,服务端维护每个文档的全局版本向量。
  4. 服务端判断:如果收到的 delta 的版本向量是全局版本向量的后代(即没有并发操作),直接广播给其他客户端,更新全局版本向量。
  5. 如果存在并发(delta 的版本向量与全局版本向量互不为祖先),服务端将 delta 放入暂存区,等待缺失的操作到达后按因果序排列,然后对每个并发属性执行冲突解决,生成一个合并后的 delta 广播出去。
  6. 客户端收到远程 delta 后,与本地未确认队列做变换(如果本地有未确认操作且与远程操作修改了相同属性),然后应用到本地状态。

这个流程里服务端不存储文档的完整状态,只存储全局版本向量和最近 N 个 delta(用于新客户端快速同步)。完整状态由客户端各自维护,服务端只做操作排序和冲突解决。这让服务端变成无状态的,水平扩展只需要加机器,不需要状态迁移。

为什么不用 CRDT

我预料到会有读者问这个问题。CRDT 在理论上很优美,但在属性编辑场景下有两个工程上的致命问题:

第一,CRDT 要求每个数据类型都有对应的可交换合并操作。文本用 RGA 或 LSEQ,计数器用 G-Counter,集合用 OR-Set,映射用 LWW-Map。一个真实的图形设计文档里至少有 15 种不同数据类型的属性,你要为每一种实现 CRDT 的合并逻辑,而且它们之间的嵌套组合会导致合并复杂度爆炸。

第二,CRDT 的元数据开销太大。一个简单的 layer.opacity = 0.5 操作,在 CRDT 里需要存储操作 ID、逻辑时钟、因果关联的墓碑标记。我们实测过一个基于 Yjs 的原型,编辑一个 100 个图层的文档 10 分钟后,CRDT 的元数据积累到了 3.2MB,而增量映射表方案只有 180KB。这不是 Yjs 实现的问题,是 CRDT 为了保证最终一致性必须保留删除标记的结构性代价。

增量映射表加版本向量的方案,本质上是用中心化服务端的排序能力换取了数据结构的大幅简化。如果你的协作系统已经有服务端(绝大多数 SaaS 产品都有),这个取舍是完全合理的。CRDT 更适合纯 P2P 的去中心化场景,但在中心化架构下,它带来的复杂度远超它解决的问题。

常见问题

版本向量里如果有客户端掉线很久,它的计数器会不会拖慢整个系统的合并?

不会,因为版本向量的比较只看相对关系,不看绝对值大小。一个掉线客户端的计数器停留在 5,其他客户端已经到 200,系统仍然能正确判断这个客户端的所有操作都是其他客户端的祖先,合并时直接覆盖即可。唯一的开销是版本向量这个 map 里多了一个键值对,但实际场景中协作人数通常不超过 50 人,这个 map 的大小完全可以忽略。如果确实有海量客户端的场景,可以用剪枝策略:当某个客户端的计数器连续 1000 次操作没有增长,且它不持有任何未合并的并发操作时,从版本向量中移除该条目。

增量映射表的键值路径如果发生结构性变化怎么办,比如图层被删除了?

删除操作在增量映射表里是一个特殊的墓碑值,比如用 Symbol.for("__deleted__") 标记。当合并逻辑看到某个键的值为墓碑,它不会应用这个值,而是将该属性标记为已删除。并发情况下,如果一方删除图层而另一方修改图层的子属性,冲突解决器需要介入:要么拒绝删除(如果修改方的操作在因果上晚于删除),要么接受删除并丢弃子属性的修改。这个逻辑需要在冲突解决器里显式处理,不能依赖 LWW 的自动覆盖。

服务端不做全量状态存储的话,新加入的客户端怎么同步当前状态?

新客户端加入时,向服务端请求最近的 N 个 delta(N 取决于文档的编辑频率,我们设为 200)。服务端将这 200 个 delta 按因果序排列后发送给客户端,客户端从空白状态开始应用这些 delta,得到当前状态。如果文档编辑历史很长,服务端会定期生成一个 checkpoint(全量快照),新客户端先加载最近的 checkpoint,再应用 checkpoint 之后的增量 delta。checkpoint 的生成频率可以根据文档活跃度动态调整,活跃文档每 500 个操作生成一次,不活跃文档每 24 小时生成一次。

两个用户同时修改同一个文本框的内容,怎么处理?

文本内容在增量映射表里被建模为一个整体属性,比如 "text.block.5.content": "修改后的完整文本"。当两个用户并发修改同一个文本块的 content 时,冲突解决器不能简单地取其中一个值,而是需要启动文本 diff 算法(比如 Myers diff)找出两个版本的差异,然后尝试合并。如果 diff 的 hunks 没有重叠,可以自动合并;如果有重叠,将该属性标记为冲突状态,在 UI 上高亮显示,让用户手动选择。这个处理逻辑和 Git 的文本合并几乎一样,只是发生在实时编辑的上下文中。