Part 2: Algorithm Techniques
假设您按照第二部分的风格选择了协作应用程序的语义。以下是将这些语义转化为实际协作应用程序的简单协议:
每个用户的状态都是一个字面的操作历史记录,即一组操作,每个操作都有一个唯一 ID(UID)。当用户执行一项操作时,他们会生成一个新的 UID,然后将操作对(id, op)添加到他们的本地操作历史记录中。为了同步他们的状态,用户可以随意共享操作对(id, op)。
例如,用户可以在配对创建后立即广播它们,定期点对点共享整个历史记录,或者运行一个巧妙的协议,只向点对点发送它缺少的配对。接收者总是会忽略多余的配对(重复的 UID)。每当用户的本地操作历史被更新(无论是通过本地操作还是远程消息)时,他们就会将语义(纯函数)应用到新的历史中,从而生成当前应用程序可见的状态。
技术问题
- 在操作历史记录中存储并通过网络发送的是翻译后的操作。例如,在存储和发送之前将列表索引转换为列表 CRDT 位置。
- 操作历史还应包括因果排序元数据——第二部分操作历史中的箭头。共享操作时,也要共享其接收到的箭头,即其直接因果前辈的 UID。
- 可以选择强制执行因果顺序交付,在添加完所有直接因果前辈操作后,再将接收到的操作添加到本地操作历史记录中。
一、Prerequisites 先决条件
1. Replicas and Replica IDs 副本和副本 ID
副本是单个设备上单个线程中协作应用程序状态的单个副本。对于基于网络的应用程序,每个浏览器标签页通常有一个副本;当用户(重新)加载一个标签页时,就会创建一个新的副本。
副本的重要性在于,副本内的所有操作都是按顺序进行的,其自身操作之间没有任何并发性。在创建副本时,通过生成一个随机字符串,为每个副本分配一个唯一的副本 ID 会比较方便。在统一协作状态的所有副本(包括同时创建的副本)中,副本 ID 必须是唯一的,这就是为什么副本 ID 通常是随机的,而不是在最终的副本 ID+1。
2. Unique IDs: Dots
要引用一段内容,应为其分配一个不可变的唯一 ID(UID)。UUID 可以使用,但它们很长(32 个字符),压缩效果不好。取而代之的是使用点 ID:形式为(replicaID, counter)的对,其中 counter 是每次递增的本地变量。因此,ID 为 “n48BHnsi” 的副本使用点 ID(“n48BHnsi”, 1)、(“n48BHnsi”, 2)、(“n48BHnsi”, 3)等。
为了灵活分配 counter 值,可以使用 Lamport 时间戳,这样 UID 也是 LWW(Last-Write-Wins)的逻辑时间戳。
3. Tracking Operation: Vector Clocks
Vector clocks 是一种具有多种用途的理论技术。在本节中,我将重点介绍最简单的一种:跟踪一组操作。
将来,副本可能想知道它已经知道哪些操作。例如:当副本收到网络信息中的新操作时,它会查询是否已经收到过该操作,如果是,则忽略它。当副本与另一个协作者(或存储服务器)同步时,它可能会首先发送它已经知道的操作的描述,这样协作者就可以跳过发送多余的操作。追踪操作历史记录的一种方法是将操作的唯一 ID 存储为一个集合: {("A84nxi", 1), ("A84nxi", 2), ("A84nxi", 3), ("A84nxi", 4), ("bu2nVP", 1), ("bu2nVP", 2)}
。
但是,存储 "压缩 "表示法的成本更低:
{
"A84nxi": 4,
"bu2nVP": 2
}
这种表示法称为 vector clock。形式上,vector clock 是一个 Map<ReplicaID, number>,它将副本 ID 发送给从该副本接收到的最大计数器值,其中每个副本按从 1 开始的顺序(就像点 ID)为自己的操作分配计数器。缺失的副本 ID 隐含地映射为 0:我们没有收到来自这些副本的任何操作。上述示例表明,vector clock 可以有效地汇总一组操作 ID。
与 Dots ID 一样,vector clock 也很灵活。例如,可以存储从每个副本接收到的最新逻辑时间戳,而不是每个副本计数器。如果每个操作都已包含 LWW 的逻辑时间戳,这就是一个合理的选择。
二、Sync Strategies 同步策略
现在我们来谈谈同步策略:让合作者之间保持同步,从而最终看到相同状态的方法。
1. Op-based
基于操作(op-based)的 CRDT 通过在操作发生时广播操作,使协作者保持同步。这种同步策略尤其适用于实时协作,因为用户希望快速看到对方的操作。
在本文章开头的简单协议中,用户在处理单个操作时会将其添加到操作历史记录中,然后重新运行语义函数来更新应用程序的可见状态。而基于操作的 CRDT 所存储的状态(通常)比完整的操作历史记录要小,但仍包含足够的信息来呈现应用程序可见状态,而且可以根据接收到的操作进行增量更新。
2. State-Based CRDTs
基于状态的 CRDT 通过偶尔交换整个状态,"合并"用户的操作历史,使用户保持同步。这个同步策略适用于点对点网络(点对点偶尔交换状态,以便互相更新)以及客户端和服务器之间的初始同步(客户端将其本地状态与服务器的最新状态合并,反之亦然)。
在本文顶部的简单协议中,"整个状态"就是字面意义上的操作历史,合并只是操作的集合联合(使用 UID 过滤重复操作)。基于状态的 CRDT 所存储的状态(通常)比完整的操作历史要小,但它仍然包含足够的信息来呈现应用程序可见的状态,并且可以与另一个状态 "合并"。
3. Other Sync Strategies
在实际的协作应用程序中,选择基于操作的同步还是基于状态的同步并不总是合适的。相反,在同一会话中同时使用这两种同步方式可能更为有效。例如,当用户启动应用程序时,首先与存储服务器进行基于状态的同步,以实现同步,然后通过 TCP 使用基于操作的消息保持同步,直到连接中断。
因此,同时支持这两种同步策略的基于操作和基于状态的混合 CRDT 在实践中非常流行。这些 CRDT 要么看起来像基于状态的 CRDT,并附加了基于操作的消息(如 Yjs 和 Collabs),要么就是使用基于操作的 CRDT 和完整的操作历史(如 Automerge)。
在后一种方法中,要执行基于状态的合并,你需要在接收状态的历史记录中查找历史记录中还没有的操作,然后将这些操作提交给基于操作的 CRDT。
三、Misc Techniques 其他技术
1. LWW: Lamport Timestamps
请记住,您应该使用逻辑时间戳来表示最后写入时间 (LWW) 值,而不是挂钟时间。Lamport 时间戳是一种简单而常用的逻辑时间戳,其定义如下:
每个副本都存储一个时钟值 time,一个初始值为 0 的整数。
每次执行 LWW 设置操作时,都会递增时间,并将其新值作为时间对(time, replica ID)的一部分附加到操作中。这个对称为 Lamport 时间戳。
无论何时从另一个副本接收 Lamport 时间戳作为操作的一部分,都要设置 time = max(time,received time)。
2. LWW: Hybrid Logical Clocks
Hybrid logical clocks 是另一种逻辑时间戳,它结合了 Lamport 时间戳和挂钟时间的特性。关于这些内容,有很多详细的介绍,可以参考相关文献和资源。
3. Querying the Causal Order: Vector Clocks 2
要查询因果顺序,我们可以使用向量时钟。在操作中附加一个向量时钟,用来标识发送时的状态。这种方法可以使我们在后续操作中查询因果顺序,并进行有效的数据同步。
## 四、Optimized CRDTs 优化的 CRDT
在这部分,我们讨论一些优化方法,这些方法通过改变存储状态和访问方式来提高CRDT的效率。通常,这些优化可以减少需要存储在内存和磁盘中的元数据量,至少在大多数情况下是这样。
### 1. List CRDTs 列表CRDT
以下是一种显著减少元数据开销的优化方法:
1. 当副本从左到右插入一串字符时(常见情况),不要为每个字符创建一个新的 UID,而只为最左边的字符创建一个 UID。
2. 将整个序列存储为一个对象 `(id, parentId, [char0, char1, ..., charN])`。因此,状态不再是每个字符一个树节点,而是每个序列一个树节点,并存储一个字符数组。
3. 要处理单个字符,请使用列表 CRDT 位置,其形式为 `(id, 0)、(id, 1)、...、(id, N)`。
### 2. Formatting Marks(Rich Text)格式化标记(富文本)
回顾一下第二部分中的内联格式化 CRDT。它的内部 CRDT 状态是格式化标记的仅附加日志:
```typescript
type Mark = {
key: string;
value: any;
timestamp: LogicalTimestamp;
start: { pos: Position, type: "before" | "after" }; // type Anchor
end: { pos: Position, type: "before" | "after" }; // type Anchor
}
其应用程序可见的状态是该日志的视图,即:对于每个字符 c,对于每个格式键 key,找出时间戳最大的标记,满足要求:
mark.key = key,且
区间 (mark.start, mark.end) 包含 c 的位置。
然后 c 在 key 处的格式值是 mark.value。
3. State-Based Counter 基于状态的计数器
在协作应用程序中,对事件进行计数的简单方法是将事件存储在仅附加的日志或唯一集中。这比单独计数要占用更多空间,但你通常都需要这些额外信息,例如,除了显示 "喜欢" 计数外,还要显示谁点赞了某个帖子。
计数器的语义与前面的乘客计数示例相同:其值是历史中 +1 操作的次数,与并发无关。显然,你可以通过只存储 +1 操作的附加日志来实现这种语义。要合并两个状态,可以取日志条目的联合,跳过重复的 UID。
4. Delta-State Based Counter 基于增量状态的计数器
您可以修改基于状态的计数器 CRDT,使其也支持基于操作的消息:
当用户执行 +1 操作时,广播其 Dot ID (r, c)。
接收者通过设置 map[r] = max(map[r],c) 将此点添加到已压缩的日志地图中。
技术上,基于 delta 状态的计数器 CRDT 假设基于操作的信息按因果顺序传递。如果没有这一假设,副本的未压缩日志可能会包含间隙。
5. State-Based Unique Set 基于状态的唯一集合
以下是一个直接的基于状态的唯一集合的 CRDT:
Per-user state:
一个元素集 (id, x),这是集合的字面(应用程序可见)状态。
一个墓碑集 id,它们是所有删除元素的 UID。
App-visible state:
返回元素。
Operation add(x):
生成一个新的 UID id,然后将 (id, x) 添加到元素中。
Operation delete(id):
删除元素集中具有给定 id 的对,并将 id 添加到墓碑集中。
State-based merge:
合并来自其他用户的状态 other = { elements, tombstones }。
6. Delta-State Based Unique Set 基于增量状态的唯一集合
类似于第二种基于增量状态的计数器 CRDT,您可以创建一个基于增量状态的唯一集合,它是混合操作基和状态基的 CRDT,允许非因果顺序消息传递。