多人协同编辑算法 —— OT 算法 🐂🐂🐂

2,197 阅读24分钟

OT(Operational Transformation)算法是一种用于支持实时协作编辑的技术,允许多个用户同时编辑同一文档,并确保所有用户的视图保持一致。OT 算法最初是为了解决在分布式环境中协作编辑文本时的并发冲突问题而开发的。它最早是由 Concurrency Control in Groupware Systems 的这篇 论文 中提出。在随后的许多年里,OT 算法进行了许多小分支上的改进,不过核心逻辑一直很清晰,目前主要用于在线文档编辑领域。

协同编辑存在的问题

协同编辑是指多个用户能够同时在一个共享文档中进行编辑,并且所有更改能够实时或近实时地反映到每个用户的界面上。随着协同编辑应用的普及,如 Google Docs、Notion 等,相关的技术和挑战也变得日益复杂。为了确保文档的准确性、实时性和一致性,协同编辑需要解决一系列问题。

脏路径问题

脏路径问题通常出现在多个用户同时编辑同一文档时,某些用户的修改可能被覆盖或者丢失,尤其是在 并行编辑 或 异步更新 过程中。当一个用户修改某部分内容后,系统没有及时同步其他用户的更改或冲突时,就可能出现脏路径。

假设我们编辑器中有三个段落:

20250226135807

这里用数组简单模拟上面的三个段落,下图展示了两个用户同时对数据修改产生的操作序列

20250226135838

问题的产生:

  1. 左边用户的情况:左边用户插入 "Access" 后,表格的顺序发生变化,Index=2 的位置现在是 "Access",后续数据的索引也随之调整。

  2. 右边用户的情况:右边用户在 Index=4 插入 "Testhub",但由于左边用户已经修改了数据,Index=4 位置的数据索引已经发生变化,因此右边用户的插入操作就会被错误地应用到不正确的位置。

左边用户收到右边的操作后,直接将 "Testhub" 插入到 Index=4 位置,但由于左边的插入操作已经改变了数据结构,结果会出现不一致的情况——左边用户的 "Testhub" 被插入到错误的位置,这导致左边和右边的结果不同。

这种情况被称为脏路径问题,其根本原因是 同步时操作顺序的不同,导致本应顺序一致的操作被错误地应用。具体来说,左边用户的操作改变了数据结构,而右边的操作基于旧的数据结构进行了更新,最终导致了错误的索引和不一致的结果。

并发冲突问题

并发冲突是指两个或更多用户在同一时间修改相同区域或内容时,如何解决它们之间的冲突。由于不同用户在不同时间对同一个内容进行修改,可能会发生 覆盖 或 数据丢失 的情况。

这里以前面介绍的图片数据结构为例说明并发冲突的问题,下图展示问题出现的过程,为了方便表达,图片节点仅保留 type 和 align 两个字段:

20250226140627

在分布式协同编辑中,并发冲突发生在多个用户同时修改同一数据时。最开始,两个用户的图像 align 属性都是 center。左边用户将其修改为 left(左对齐),而右边用户将其修改为 right(右对齐)。当他们各自的修改通过消息服务同步到对方时,左边的图像最终显示为右对齐,右边的图像则显示为左对齐,造成数据不一致。这是因为他们都修改了相同位置的相同属性,导致了冲突。

undos/redos 问题

undos/redos 问题本质还是前面所说的「脏路径问题」+ 「并发冲突问题」,但是问题出现的场景有些不一样,又相对复杂,所以这里单独提出来了。

还是前面「脏路径问题」的数据操作,这里只看右边部分,分析它的撤回栈:

20250226141051

以右边用户的操作为例:

  1. 初始数据中,两个用户对同一数据进行了并发修改,左边用户在 Index=2 插入了 "Access",而右边用户在 Index=4 插入了 "Testhub"。

  2. 当右边用户执行撤回操作时,其撤回栈按照如下顺序处理:

    1. 删除 Index=2 位置的节点。

    2. 删除 Index=4 位置的节点。

然而,问题在于 撤回操作应该只影响自己的修改,而不是别人的。右边用户的撤回栈试图删除左边用户的操作内容,这导致 用户会误认为自己刚刚输入的内容被删除了,这显然不符合用户的直觉和使用习惯。

为了正确处理撤销操作,撤回栈应当只包含 右边用户的操作,忽略其他用户的修改。修复后的撤回栈顺序应如下:

  1. 删除 Index=4 位置的节点。

但修复后的撤回栈引入了另一个问题。现在,Index=4 对应的节点是 "Plan",而右边用户的实际操作是插入 "Testhub"。这意味着在撤回时,“Plan” 会被删除,但 “Testhub” 实际上应被删除。这导致了 脏路径问题:由于操作的路径发生变化,撤销操作变得不准确。

总的来说,撤销操作应仅撤销用户自己的修改,忽略其他用户的操作。即使撤销栈中操作正确,脏路径问题 仍可能发生,特别是在并发编辑时,撤销路径可能与用户预期不符,导致错误内容被删除。并发冲突问题脏路径问题 在撤销操作中会产生类似影响,因此需要仔细设计撤销/重做机制,以确保数据一致性和准确性。

OT 算法

OT 算法有多个不同的分支,其最主要的分支为使用去中心化网络 + 状态向量的实现,另一个分支为 中央服务器+单一标量(只使用一个标量,一般称为文档版本)的实现,二者大同小异,主要在于是否有中央服务器存在。在维基百科上可以查看某个具体的 OT 算法查看属于哪一类。

如下图所示:

20250117172335

如需查阅更详细的信息,可以查看维基百科的 OT 算法 页面。

OT(操作转换) 是一种算法,旨在解决多用户同时操作同一文档或数据时的同步问题。由于每个用户的操作可能在不同的机器上按不同的顺序执行,直接应用这些操作可能导致结果不一致。OT 通过对操作进行转换,确保每个用户的操作能够在其他用户的编辑结果基础上正确应用,从而使所有用户最终看到一致的结果。

20250117174204

现在我们来看一个具体的例子,假设有两个用户 山鸡陈浩南,他们同时编辑同一个文档,文档的初始内容为 Hello,

山鸡在第六个位置插入了 world,文档变为 Hello, world

陈浩南在第六个位置插入了 OT,文档变为 Hello, OT

双方收到对方的操作后,如果简单地应用,那么会变成:

山鸡 的内容: hello, OTworld

陈浩南 的内容: hello, worldOT

就不一致了,所以每个端在收到操作之前,要进行操作转换,将其转换成合适的操作,这就是操作转换算法名字的由来。

操作转换(OT)算法是为了实现分布式收敛而设计的一种算法,它在分布式系统中非常常见,特别是在多个用户并发操作的协作环境下。为了确保不同用户的操作能够正确合并并保持一致性,OT 算法需要遵循一些规则,通常称为 CCI 模型。以下是这三个规则的详细说明:

因果律 (Causality)

因果律要求系统能够正确地跟踪操作之间的因果关系。在分布式环境中,不同用户的操作可能是并发的,因此需要确保如果操作 A 发生在操作 B 之前,所有用户都能一致地看到这个顺序。

在协同编辑的场景中,假设有两个用户 A 和 B,他们在相同的文档上同时编辑。用户 A 插入了某个文本,而用户 B 在另一个地方做了删除操作。为了保持一致性,系统必须确保用户 A 的操作“先”被处理,然后再处理用户 B 的操作。

操作的因果关系通常是通过时间戳或版本号来追踪的。例如,如果用户 A 的操作发生在用户 B 的操作之前,那么 OT 算法会确保用户 B 看到的内容中,用户 A 的操作优先于用户 B 的操作。

举个栗子 🌰🌰🌰,假设用户 A 插入了 World,而用户 B 在同一个位置插入了 OT。因果律要求,假如用户 A 的操作先发生,用户 B 的 OT 应该出现在 World 之后,而不是之前。

结果收敛 (Convergence)

结果收敛性要求,无论操作的顺序如何,最终所有用户的文档状态应该一致,即使用户之间的操作发生的顺序不同,最终每个用户看到的内容应该是相同的。

假设两个用户 A 和 B 分别在同一个文档上进行编辑。用户 A 的操作是插入 World,而用户 B 的操作是插入 OT。即使 A 和 B 提交的操作顺序不同,OT 算法仍然需要确保两个用户最终都能看到相同的结果。

收敛性不仅仅是保证用户最终看到相同的内容,还包括对所有可能的操作顺序进行处理,确保即便操作的顺序不同,最终的状态也不会有冲突。

举个栗子 🌰🌰🌰,如果用户 A 先插入 World,然后用户 B 插入 OT,最终的结果是 Hello, WorldOT;如果用户 B 先插入 OT,然后用户 A 插入 World,最终的结果仍然是 Hello, WorldOT。无论操作顺序如何,系统都确保两者最终的文档一致。

在实时协作编辑应用中,收敛性保证了用户无论操作顺序如何,都能最终看到相同的文档内容。例如,在 Google Docs 中,多用户同时编辑文档时,系统会自动处理并发修改,并确保文档的一致性。

意图保留 Intention Preservation

意图保留 是操作转换(OT)算法中的一个重要原则,它要求系统在合并并发操作时,尽量保留用户的原始操作意图。换句话说,系统不应该对用户的操作做出改变或失去其原始意图,即使这些操作之间存在冲突或依赖关系。OT 算法的目标是,在进行操作转换时,保证用户的操作意图得以尊重和实现。

假设用户 A 插入 hello,用户 B 插入 ot。如果 OT 算法将操作合并为 hotello,这显然违反了 意图保留,因为原本用户 A 的插入意图是希望 hello 出现在特定位置,而用户 B 的插入意图是希望 ot 加入该位置。因此,OT 算法应该确保插入内容的正确顺序和位置,使得操作结果不会违背用户的意图,最终的结果应为 hellootothello(根据操作的先后顺序),而不是 hotello

在像 Excel 表格 这样的场景中,用户 A 输入 80,用户 B 输入 90,如果最终结果是 "8090",这并不符合每个用户的意图。用户 A 和用户 B 通常希望系统选择其中一个数字作为最终值,而不是将它们拼接起来。这个情况表明,在某些场景下,意图保留 应该根据上下文进行调整。在这里,系统应该选择 80 或 90(通常是最新的操作,或者是基于某种规则的决定),而不是拼接两个数字。

因此,意图保留 是一个动态的、语境相关的原则,确保在不同的应用场景中,用户的期望和意图能够得到尊重和实现。

小结

这三个原则构成了 OT 算法的核心思想:

  1. 因果律 (Causality) 确保操作的顺序正确,用户操作之间的依赖关系得以保留;

  2. 结果收敛 (Convergence) 保证了无论操作顺序如何,所有用户最终看到的内容是一致的;

  3. 意图保留 (Intention Preservation) 确保用户的操作意图在合并时得到尽量的保留,避免被其他用户操作影响。

这三个原则协同作用,保证了在分布式和协同编辑环境下,多个用户的并发修改可以正确合并,最终确保一致性和用户体验。

为了实现因果律,就要实现一种算法,能够将分布式的操作,按照因果关系确定先后顺序。目前有两种方法确定:

  1. 使用操作发生时的时间戳进行因果 (happend-before) 判定

  2. 使用逻辑时钟,非物理时钟的判定方法

方案一,听起来最简单,但是很显然,最简单的方案往往都不是正确方案。由于分布式机器上存在时间漂移,时间戳不准,就会发生因果冲突的现象,例如 A 主机插入了一个字符发生在第 10 分钟,B 主机删除了这个字符在第 9 分钟,显然因果违逆,导致系统灾难。

方案二, 使用逻辑时钟,逻辑时钟是专门为解决分布式系统事件时间戳不一致而设计的,最早由 Leslie Lamport 在 1978 年的 Time, Clocks, and the Ordering of Events in a Distributed System 论文中提出,所以逻辑时钟又被称为是 Lamport 时钟。

因果关系的保证: Lamport 时钟和向量时钟

Lamport 时钟

Lamport 时钟(Lamport Clock)是由计算机科学家 Leslie Lamport 提出的,用于分布式系统中的 事件排序 和 因果关系 的一种机制。在分布式系统中,不同节点的时钟往往不同步,因此需要一种方法来确保各个事件的相对顺序。

Lamport 时钟为每个节点的事件分配一个 逻辑时间戳,而这个时间戳遵循以下规则:

  1. 每个节点都维护一个 单调递增的整数计数器,该计数器用作时间戳。每当节点发生一个事件时,计数器就加 1,并将该事件标记为当前时间戳。

  2. 当节点之间交换消息时,它们会传递自己的时间戳,并且接收方节点会根据接收到的时间戳来调整自己的计数器,确保因果关系得到正确维护。

首先需要定义先后关系(happened before),我们把事件 a 发生在 b 之前定义为 a -> b,以下三种条件都满足 a → b:

  1. a 和 b 是同一个进程内的事件,a 发生在 b 之前,则 a → b。

  2. a 和 b 在不同的进程中,a 是发送进程内的发送事件,b 是同一消息接收进程内的接收事件,则 a → b。

  3. 如果 a → b 并且 b → c,则 a → c。

如果 a 和 b 没有先后关系,则称两个事件是并发的,记作 a || b。

20250217155648

在上面的图片中,有两个进程 A 和 B,每个点表示一个事件,黑色点表示进程内的事件,蓝色点表示进程的发送消息事件,红色点表示进程的接收消息事件。可以从上图得出:

  • a → b → c → d

  • a → b → e

  • f → c → d

  • a || f

  • e || d

  • b || f

  • e || c

a → b 除了可以表示两个事件的先后关系,也可以理解为两个事件的因果关系,a 事件导致了 b 事件的发生,或者说 a 事件影响了 b 事件,而 a || b 也可以理解成两个事件没有因果关系,比如上图中,e 和 c 两个事件就像是从 b 开始发展出了两个平行世界,相互不再受对方的影响。

我们再引入逻辑算法时钟,分布式系统中每个进程 Pi 保存一个本地逻辑时钟值 Ci,Ci (a) 表示进程 Pi 发生事件 a 时的逻辑时钟值,Ci 的更新算法如下:

  1. 进程 Pi 每发生一次事件,Ci 加 1。

  2. 进程 Pi 给进程 Pj 发送消息,需要带上自己的本地逻辑时钟 Ci。

  3. 进程 Pj 接收消息,更新 Cj 为 max (Ci, Cj) + 1。

咋上面的图中,它的逻辑时钟值如下所示:

20250217161158

从以上算法可以很容易地得出下面两个结论:

  1. 同一个进程内的两个事件 a 和 b,如果 a → b,那么 Ci (a) < Ci (b)。

  2. a 是 Pi 进程的消息发送事件,b 是 Pj 进程该消息的接收事件,那么 Ci (a) < Cj (b)。

由以上两个结论又可以得出,对于任意两个事件 a 和 b,如果 a → b,那么 C (a) < C (b)。

问题来了,如果 C (a) < C (b),那么可以得出 a → b 吗?

答案是不能,举个反例,在逻辑时钟的图中 C (e) = 2,C (d) = 3,虽然 C (e) < C (d),但并不能得出 e → d,e 和 d 实际上是并发关系 e || d,也就是说由于并发的存在,导致反向的推论并不成立。

结果收敛的保证:transform operation 向量时钟

向量时钟(Vector Clock)是一种在分布式系统中用于追踪事件因果关系的时钟机制。与 Lamport 时钟不同,向量时钟不仅能够追踪单个事件的顺序,还能够同时记录多个事件之间的并行性。向量时钟是对事件顺序的一种更精确的描述,尤其适用于解决因果关系的问题。

在分布式系统中,多个节点可能会并行地进行操作(即同时发生)。例如,节点 A 和节点 B 都在同一时刻执行某个操作,但它们的操作可能互不依赖,并且无法根据单一时钟(如 Lamport 时钟)来完全决定哪个操作先发生。在这种情况下,如何区分“因果关系”与“并行”是一个挑战。

向量时钟为每个节点维护一个 时间向量,通过这种方式,能够更加准确地记录事件的因果关系和并行关系。

向量时钟通过为每个节点维护一个 向量 来表示时间。每个节点的向量包含多个计数器,每个计数器代表一个节点的时钟值。向量时钟的核心思想是每个节点在发生事件时会更新自己的计数器,同时在发送消息时会将自己的时间向量传递给其他节点。

向量时钟就是分布式系统的时钟,能够将所有事件按照时间轴进行排序,此时我们就可以有如下的思维模型。

20250224211013

在这个时候,我们两台电脑上分别编辑,通过网络传输后,分别传输到对方电脑上。

此时,对于一州用户而言,其收到对方操作后,可以按照因果关系排序, 发现是并发的修改 (因为二者没有通信过,或者通信过之后各自又都修改了)

20250224211154

雨珅用户接收完所有操作后,也可以对其排序,对于雨珅也是一个并发修改。对于并发修改,不能直接应用,需要进行操作转换。

此时需要解决冲突,一般常见的办法是,直接使用用户名作为操作的优先级,"yizhou" < "yushen", 所以当雨珅和一州冲突时,我们认为 一州 的事件先发生,雨珅 的事件后发生。

所以,此时这个并发修改,应该假设是一州先操作,雨珅后操作,按照操作人的意图,所以理想中的结果就是, "yushenyizhou", 为了达到结果收敛的效果,两边都要进行操作转换。转换结果如下:

20250224211256

转换函数一般为 transform , OT 算法的核心就是 transform 函数的实现。

上面转换的结果发现没有,其满足这个等式:

yizhouOp + transform(yushenOp, yizhouOp) ===
  yushenOp + transform(yizhouOp, yushenOp);

其背后所表达的意思,用大白话就是: 如果我本地先应用了 op1, 但是 op2 实际的优先级更高,那么把 op2 根据 op1 转换成,仿佛是 op2 先应用。

此时两边都应用完成之后,我们将转换后的 op 对象存入历史堆栈,转换后的 op 发生的时间是当前时刻,本例中为 [1, 1],我们发现一个神奇的现象,就是两边的 vector clock 相等了,所以向量时钟相等时,往往也意味着我们两边的状态已经同步了。(操作一旦推入历史堆栈,就再也不能更改,插入,历史堆栈只会向上增长)

20250225092617

接着假如雨珅再次编辑一次,看会发生什么:

20250225092706

此时 [1, 1] < [1, 2] ,我们可以大胆地说,没有发生并发冲突。当没有发生并发冲突时,而且是发生在当前状态之后,直接应用这次变更即可,无需做任何转换。

20250225092748

此时可以看到,向量时钟的三个状态在 OT 领域分别对应三个特殊含义:

  1. 当两个进程的时钟相等时,意味着状态已经同步了。

  2. 当两个进程的时钟并发时,意味着冲突了,需要做操作转换。

  3. 当一个操作的时钟大于另一个时钟时,意味着这个操作发生在之后,没有冲突,可以直接应用。

再看稍微复杂一点的案例,如果同时有两个操作对象,一州发送给雨珅:

20250225092904

当雨珅接收后发现 一州的第一个操作,和雨珅的这两个操作同时并发。此时会进行多次转换:

20250225112135

第一次转换完结果为 hello li yushen yizhou

收到第二个操作此时会和三个操作发生并发关系,所以要进行三次转换。

20250225094601

因为第二次转换之后,对应的向量时钟 > 后续 op, 所以无需转换了

第二次的转换结果为 hello li yushen shen yizhou

上文说到,一般情况下,由于网络通信速度尚可,只有由于在短时间网络没有通信时同时编辑才会产生并发,所以并发操作其实不会很多。

完整流程

文档的初始内容是: "hello"

用户 A 和用户 B 的操作:

  1. 用户 A 的操作(时间戳 [1, 0]):在位置 6 插入 "yizhou",即:Ins(6, "yizhou")

    • 用户 A 的操作直接在 "hello" 的末尾插入 "yizhou",文档变为:"helloyizhou"
  2. 用户 B 的操作(时间戳 [2, 0]):在位置 6 插入 "yushen",即:Ins(6, "yushen")

    • 这时发生了冲突,因为用户 A 已经在位置 6 插入了 "yizhou"。为了解决这个冲突,OT 算法需要调整用户 B 的操作。

第一次转换:

由于用户 A 的操作先发生(时间戳 [1, 0]),OT 算法会首先保持用户 A 的操作,然后调整用户 B 的操作的位置,以避免覆盖。

  1. 用户 A 的操作:Ins(6, "yizhou")

    • 用户 A 的操作不变,仍然是在位置 6 插入 "yizhou"
  2. 用户 B 的操作:Ins(6, "yushen")

    • 用户 B 的操作由于与用户 A 的 "yizhou" 操作冲突,需要被调整到新的位置 12。因此,用户 B 的操作变为:Ins(12, "yushen")

经过第一次转换后的文档内容是:"hello" + "yizhou" + "yushen" = "helloyizhouyushen"

第二次转换

在第一次转换中,用户 B 的 "yushen" 已经被调整到位置 12。接下来,用户 B 有一个新的操作。

  1. 用户 B 的第二次操作(时间戳 [0, 2]):在位置 12 插入 "li",即:Ins(12, "li")

    • 由于位置 12 现在已经包含了 "yushen",用户 B 的 "li" 会插入到 "yushen" 前面,成为 "liyushen"

    • 插入 "li" 后,文档变为:"helloyizhouliyushen"

  2. 合并第三个操作: Ins(12, "shen")

    • 用户 B 的第三次操作是:在位置 12 插入 "shen"。因为位置 12 现在是 "liyushen",所以 "shen" 会插入在 "liyushen" 后面。

    • 这样,文档最终变为:"helloyizhouliyushenshen"

最终文档的内容为:"hello liyushenshenyizhou",原因如下:

  1. 第一次转换:用户 B 的操作 "yushen" 被调整到位置 12,文档变为:"helloyizhouyushen"

  2. 第二次转换:用户 B 插入 "li",并将其合并到 "yushen" 前面,变成 "liyushen"。文档变为:"helloyizhouliyushen"

  3. 第三次转换:用户 B 插入 "shen",并将其合并到 "liyushen" 后面,文档最终变为:"hello liyushenshenyizhou"

它的关键点主要有以下几个方面:

  • 在并发编辑过程中,两个用户(A 和 B)的操作发生冲突。OT 算法通过比较时间戳并调整操作位置来解决冲突,确保所有的编辑操作都被合并到最终文档中。

  • 用户 B 的操作 "yushen""li" 被调整到了不同的位置(位置 12 和 14),避免了与用户 A 的操作冲突。

  • 最终的文档是通过这些操作调整和合并得到的,确保所有的修改都被保留,并且不会覆盖其他用户的编辑。

最终结果:"hello liyushenshenyizhou"

好了到这里,OT 算法就已经解释完毕了,如果大家有了一定的了解,那就再好不过。为了帮助回顾,我总结一下实际上做了下面三件事情:

  1. 对所有人的操作序列排序,排序的结果一定满足因果关系,因果序 (Causal order)。

  2. 排序的结果存在并发的情况,使用 transform 函数解决并发

  3. 解决的思路是依照用户名强制排序,使得结果好像是:让一个人先操作,另一个人后操作。但实际上没有撤销的操作,用户看到的都是连续的修改。

根据这个思想,只要 OP 是可被转换的,OT 算法可以推广到任意领域,例如修改对象

实际上,由于对 对象 类型的协作,比文本领域较为复杂,例如如果字段类型变了,字段重命名了,字段删除了,删除又增加了,等情况的处理较为复杂,实现难度陡增,所以早期的 OT 算法主要还是以文本编辑为主。

参考资料

总结

OT(操作转换)算法是一种解决实时协同编辑冲突的技术,通过对并发操作进行转换使所有用户最终看到一致的文档内容。它基于三个核心原则:因果律(确保操作顺序符合因果关系)、结果收敛(确保所有用户最终看到相同结果)和意图保留(尽量保持用户的原始编辑意图)。OT 算法通过向量时钟追踪操作的时序关系,并使用 transform 函数将并发操作转换为可以顺序应用的形式,从而实现多用户无缝协作编辑。