Appearance
全局配置
Appearance
Operational Transformation(OT)是一个应用于协同编辑领域的并发控制和冲突解决系统,要解决的是“多个用户对同一个文本域的同一个版本进行编辑时如何处理”的问题。下文中简称冲突问题。
DANGER
冲突问题是:“多个用户对同一个文本域的同一个版本进行编辑时如何处理”的问题
INFO
先解释一下这里的关键词,正确理解冲突问题非常重要:
<textarea></textarea>
来说文本域的概念一定是这个文本框中的全部文本。但对于像Notion、Roam Research或者飞书这样的应用,它们的每一行都是一个Block,每个Block可以看做单独的textarea这时候“同一个文本域”这个概念也许就是文档中的每个Block,原子操作类型设计也会更加复杂(比如:Block的insert、move、delete,Block内嵌组件时,组件内如何协作),具体的定义还需要看应用的设计。但无论怎样,只有对同一个文本域进行的修改才是我们这里说的冲突。WARNING
ABA问题:一个线程把数据A变为了B,然后又重新变成了A。此时另外一个线程读取的时候,发现A没有变化,就误以为是原来的那个A。这就是有名的ABA问题。
如果有些业务不需要关心中间过程,仅关注共享变量的起始值和结束值,只要前后值一样就行,过程中共享变量是否被其他线程动过它不关心,这当然无所谓,但有些业务可能但是有些业务需求要求变量在中间过程不能被修改。
TIP
整体来看,OT解决并发编辑冲突问题的思路有以下几步:
先看看没有原子操作的世界:
INFO
对有状态的UI Component来说其实并没有操作类型的概念,每次UI操作只会生成newValue在浏览器端通过e.target.value获取。如果进行粗略的oldValue和newValue的diff,在浏览器文本框中,用户可以进行以下UI操作:
DANGER
定义某个OT算法的操作为:
定义了原子操作之后,就可以将UI操作表示为由原子操作组成的操作序列了:
// 头部插入:"a" => "ba"
insert("b");
// 尾部插入:"a" => "ab"
retain(1).insert("b")
// 中间插入:`"ab" => "acb"`
retain(1).insert("c")
// 删除并插入:`"abbbbb" => "ac"`(选中bbbbb,输入c)
retain(1).delete("bbbbb").insert("c")
调用上面的原子操作后,在当前OT算法中操作序列会维护在一个称为TextOperation的类中:
// 删除并插入:`"abbbbb" => "ac"`(选中bbbbb,输入c)
TextOperation to = new TextOperation().retain(1).delete("bbbbb").insert("c");
// to.ops = [Retain(1), Insert("bbb"), Delete("c")]
而TextOperation就是进行多端同步的最小单位,同时也实现了操作的可序列化
那么此时OT算法就是有两个作用在相同revision的文本域上的TextOperation,每个TextOperation中都带着一组原子操作类型序列。那么我们应该如何处理这两个TextOperation才能让冲突自动解决呢?
TIP
接下来用具体的例子解释通过增加操作解决冲突方法:
WARNING
// Text: ""
// opeationA: "" => "a"
operationA.ops = [Insert("a")];
// opeationB: "" => "b"
opeationB.ops = [Insert("b")];
// 冲突解决
transform(operationA, operationB)
// {newOperationA: [Insert("a")], newOperationB: [Retain(1), Insert("b")]}
假如我们只针对这一种情况编写transform方法的处理逻辑:
function transform(operationA, operationB) {
// 等待构建和返回的新操作
newOperationA = new TextOperation();
newOperationB = new TextOperation();
// operationA和operationB的原子操作序列
opsA = operationA.ops;
opsB = operationB.ops;
// 遍历opA和opB的指针
indexA = 0;
indexB = 0;
while (indexA < opsA.length && indexB < opsB.length) {
// 如果是两个插入操作的冲突
if (opsA[indexA] instanceof Insert && opsB[indexB] instanceof Insert ) {
// A直接执行插入操作
op = opsA[indexA ++];
newA.ops.push(op);
// op.value = "a"
// B先进行移动操作,再进行插入操作
newB.ops.push(new Retain(op.value.length));
newB.ops.push(opsB[indexB ++]);
}
}
return {newOperationA, newOperationB};
}
另外,假如将操作应用到文本的方法我们定义为String apply(String str, TextOperation op),经过冲突解决后生成的新操作可以满足交换律: apply(apply(str, operationA), newOperationB) === apply(apply(str, operationB), newOperationA)
目前为止我们理解了什么是冲突、如何解决冲突。那么在具体的应用中,什么时候会发生冲突呢? 这个问题其实和应用前后端数据流设计有关,在此我举出一个具体的例子进行讨论。 假如发生上面操作时,Client和Server的数据流设计方案如下所示:
在图中所有调用transform方法的地方都是冲突发生的地方。也就是说在实际的协同编辑应用中,冲突会在两种场景下发生:
1)Server接收到Client上传的操作。
2)Client接收到Server广播的操作。
TIP
在Client/Server协作时,还有几个事实我们需要意识到:
DANGER
整体来看,OT是一种基于阻塞同步的多版本并发控制方案。
CRDT 的定义为:满足「强最终一致性」的数据类型。
INFO
系统中有多个副本(replica),每个副本代表一个进程/一个计算机,副本可能会崩溃,我们用正确的副本指未崩溃的副本。 一个副本可能会崩溃之后永不恢复,或者保持内存完整性的情况下地恢复。 副本之间可能会被分区。
INFO
Eventual Delivery: 发布到一个正确的副本上的更新列表,最终将被传达至所有正确的副本。 收敛 Convergence: 收到了一样的更新列表的正确副本们,最终状态将一致。 终止 Termination: 所有执行的方法都会终止(即保证算法能在有限时间内完成计算,而不是要进行 2^128 次计算来遍历整个状态空间的这种算法)。
INFO
强最终一致性定义为:满足「最终一致性 Eventual Consistency」且具有「强收敛性 Strong Convergence」。 强收敛 Strong Convergence: 收到了一样的更新列表的正确副本们的状态一致。 「强最终一致性 SEC」和「最终一致性 EC」的区别在于: EC 有可能要求用户解决冲突,而 SEC 是不会发生冲突的。
INFO
给定集合S,“≤”是S上的二元关系,若“≤”满足:
A -> B 的箭头表示 A ≤ B。 而 {x} 和 {y} 二者之间是不可比较的。
INFO
每个非空集合都有上确界或者下确界。 对于有限的点集一定有一个最大值最小值,此时最大值和上确界相同。 但对于某些无限点集来说可能不存在最大值或最小值,如{1-exp(-n)}没有最大值,但有上确界为1,1不在这个集合中。
考虑一个实数集合M. 如果有一个实数S,使得M中任何数都不超过S,那么就称S是M的一个上界。 在所有那些上界中如果有一个最小的上界,就称为M的上确界。 一个有界数集有无数个上界和下界,但是上确界却只有一个。
考虑一个实数集合M. 如果有一个实数S,使得M中任何数都大于等于S,那么就称S是M的一个下界。 在所有那些下界中如果有一个最大的下界,就称为M的下确界。 一个有界数集有无数个上界和下界,但是下确界却只有一个。
INFO
半格是一个偏序集。 设x,y属于一个偏序集,若对于任意的{x,y}都有最小上界(并),或者对于任意的{x,y}都有最大下界(交),则称该偏序集构成一个半格。
INFO
TIP
CRDT 常被用在协作软件上,例如:
以多用户在线同时编辑同一篇文档的场景为例: 这个场景要求每个用户看到的内容都是一样的,即使在用户出现冲突编辑后(例如两个用户同时修改标题,两个请求同时到达服务器)也不会产生两个版本,这被称为一致性。(准确地说 CRDT 满足的是最终一致性)。
CRDT 让用户即使离线也可使用,并在恢复网络后能继续和所有人同步至一致的状态。
也可以和其他用户通过 P2P 的方式一起协同编辑。这被称为分区容错性。
这让 CRDT 可以很好地支持去中心化的应用:即使没有中心化服务器各端之间也能完成同步。
WARNING
根据 CAP 定理,对于一个分布式计算系统来说,不可能同时完美地满足以下三点:
CRDT 不提供「完美的一致性」,它提供了 强最终一致性 Strong Eventual Consistency (SEC) 。 这代表进程 A 可能无法立即反映进程 B 上发生的状态改动,但是当 A B 同步消息之后它们二者就可以恢复一致性,并且不需要解决潜在冲突(CRDT 在数学上就不让冲突发生)。 而「强最终一致性」是不与「可用性」和「分区容错性」冲突的,所以 CRDT 同时提供了这三者,提供了很好的 CAP 上的权衡。
INFO
下述两种方法都是 CRDT:
INFO
INFO
CRDT 有两种类型:Op-based CRDT 和 State-based CRDT
设计一个基于状态的 CRDT 中需要先定义一个 State-based Object。 当它满足一定的性质时就能成为 State-based CRDT 👏。
INFO
State-based Object 的定义包括:
TIP
假设有 Eventual delivery 和 Termination 的属性,那么任意单调半格的 State-based 对象都是有强最终一致性(Strong Eventual Consistency)的。 观察到最小上确界操作 ⊔ 符合以下性质:
如何设计 State-based CRDT:
INFO
TIP
Op-based CRDT 的思路为:
设计一个基于操作的 CRDT 中需要先定义一个 Op-based Object。 当它满足一定的性质时就能成为 Op-based CRDT。
INFO
Op-based Object 的定义包括:
INFO
根据每个 op 在副本上的执行顺序我们可以定义 op 之间的偏序关系:
INFO
构建一个 Op-based CRDT 的充分条件为:
即如果有可能冲突的 Op 都是可以交换的,那么它们在一个副本上的应用顺序就对 Object 的最终状态没有影响,那么任意两个应用了同样的 op 集合的 Op-based CRDT 就都具有一致的状态,从而满足 SEC。
如何设计 Op-based CRDT:
TIP
TIP
在形式上,Op-based CRDT 和 State-based 是可以互相转换的。思路为:
通过 Op-based CRDT 构建 State-based CRDT 的方式为:
通过 State-based CRDT 构建 Op-based CRDT 的方式为:
基于上面的 CRDT 框架我们已经有很好的理论支持我们设计一个 Last-write-wins Set,即出现同时删除和添加同一个元素时后写入的操作将覆盖先写入的操作。
但是在设计之前需要先了解在分布式系统中我们该如何判断事件的“先后”。
在中心化的系统中我们可以通过事件到达中心服务器的时间作为时间发生的时间戳来判断先后。但是在分布式环境中这种方式就不能使用了。此时我们可以使用 Lamport Timestamp。
INFO
Lamport Timestamp 的算法很简单:
从而每一个消息都有一个明确的时间戳,根据时间戳我们就能够得到消息间的全序关系。 但为了能够处理消息的 counter 相同的情况,我们还需要在消息中带上进程自己的 id,从而能够将全序关系定义为: a.counter == b.counter ? (a.pid < b.pid) : (a.counter < b.counter)
以 Op-based CRDT 的思路设计 Last-write-wins Set(LWWSet):
interface Op {
time: number;
pid: number;
type: "add" | "remove";
value: string;
}
type State = Map<string, Op>;
type ProcessState = { counter: number; state: State };
const state_zero: State = new Map();
const process_state_zero: ProcessState = {counter: 0, state: state_zero};
function apply(state:ProcessState, op: Op): ProcessState {
const new_state: ProcessState = {
counter: Math.max(state.counter, op.time) + 1,
state: new Map(state.state),
};
if (!new_state.state.has(op.value)) {
new_state.state.set(op.value, op);
} else {
const old_op = state.state.get(op.value);
if (
old_op.time < op.time ||
(old_op.time === op.time && old_op.pid < op.pid)
) {
new_state.state.set(op.value, op);
}
}
return new_state;
}
function check_state(state: State, op: Op) {
return true;
}
// 判断 LWWSet 是否含有某个值
function has(state: State, v: string): boolean {
return state.get(v)?.type === "add";
}
// 插入 v 到 LWWSet
function add(state: ProcessState, v: string): ProcessState {
return apply(state, {
time: state.counter,
pid: getPid(),
type: "add",
value: v,
});
}
// 删除 LWWSet 中的 v
function remove(state: ProcessState, v: string): ProcessState {
return apply(state, {
time: state.counter,
pid: getPid(),
type: "remove",
value: v,
});
}
你不用自己从头开始设计和实现 CRDTs 算法(CRDT 很容易被实现得很糟糕)。 你可以直接基于开源 CRDTs 项目来搭建你的应用例如 Automerge, Yjs 以及 Loro。
CRDT 和 Operation Transformation(OT) 都可以被用于在线协作型的应用中,二者的差别如下:
OT | CRDT |
---|---|
OT 依赖中心化服务器完成协作; 如果想要让它在分布式环境中工作就异常困难 | CRDT 算法可以通过 P2P 的方式完成数据的同步 |
为保证一致性,OT 的算法设计时的复杂度更高 | 为保证一致性,CRDT 算法设计更简单 |
OT 的设计更容易保留用户意图 | 设计一个保留用户意图的 CRDT 算法更困难 |
OT 不影响文档体积 | CRDT 文档比原文档数据更大 |
OT 最早的论文于 1989 年提出 | CRDT 最早的论文出现于 2006 年 |
WARNING
为什么目前在协作软件中大多数看到的还是应用 OT 算法而不是 CRDT 呢? 首先因为 CRDT 这类方法相比 OT 还比较年轻,而且有些难点近几年才被比较好地解决,例如:
TIP
而目前在以下方面的研究还有待展开: