我是前端西瓜哥,今天我们来简单入门一下 CRDT。
CRDT 是什么?
CRDT,全称为 conflict-free replicated data type(无冲突复制数据类型),它是一种数据类型,或者说是方案,确保在网络中的不同副本最后数据保持一致的,可以用协同编辑领域。
CRDT 在 2011 年在论文中被正式提出,虽相比 OT 算法(1989年)起步晚了很长的时间,但实现难度低很多,且出现了高性能的 CRDT 库 Y.js,越来越多产品选择使用 CRDT 来实现协同编辑功能。
CRDT 有以下特性:
每个客户端可独自操作副本,即支持并发,不需要和其他副本协同沟通。
这是一种乐观复制(Optimistic replication)的策略。
各个副本可以独立地在本地编辑,不用把更新提交到服务器,等待服务端返回最新的状态,用这个新状态覆盖掉旧状态。即可做到离线编辑,本地更新了状态后再同步到服务端。
算法能够自动地处理不一致的问题。
一个副本和另一个副本通常是不同的,当其他副本同步过来时,有可能会出现冲突(不一致)的地方,比如两个副本同时删除和新增一个元素。
这个需要 CRDT 算法使用特定的策略去自动处理,而不是像 git merge 那样去手动处理冲突。
同一时刻不同副本的状态可能不同,但同步后它们能最终收敛(converge),达到相同的状态(最终一致性)。
CRDT 的类型
CRDT 主要分为两大类型:Operation-based 和 State-based。
Operation-based
Operation-based CRDTs,基于操作的 CRDT。
副本进行同步时,只会把 新增的本地操作(operation) 发送出去。
另一个副本拿到这个 operation 会将其应用到自己的状态上,operataion 需要满足交换律(commutative)。
交换律,指的是交换运算顺序,最后的结果不变 。比如加法就满足交换律,a+b
和 b+a
的结果是相等的。
operataion 之所以要满足交换律,是因为网络并不可知。
假设有两个副本 a 和 b 要同步过来给其他副本,你不知道到底谁先到达。对于一些副本可能是先 a 后 b,另一些可能是先 b 后 a,我们需保证在不同顺序下,结果是一致的。
通常我们是通过 Generator 函数生成新的 opreation,发送给其他副本,然后这些副本通过 Effector 函数应用这些副本。
因为交换律这个特性,Operation-based CRDTs 还有另一个名字 commutative replicated data types(CmRDTs)。
基于操作的 CRDT 的优点是, 传输的数据量较少。
State-based
State-based CRDTs,基于状态的 CRDT。
一个副本进行同步时,会将 整个完整的本地状态(state) 发送出去。另一个副本需要支持将其他副本进行合并(merge)的操作,这个 merge 方法需要满足交换律、分配律,以及幂等性。
基于状态的 CRDT 的问题是,在文档很大时,需要传输大量的数据,会耗费大量的带宽,且花费时间长。所有实际生产很少会使用它。
优点是实现更简单,如果数据量不大,是可以考虑使用的。
State-based CRDTs 同样也有另一个名字:Convergent Replicated Data Types(CvRDTs)。Convergent 是收敛的意思。
一些 CRDT
CRDT 做到数据最终一致性,是基于数学上的特性来保证的。
我们来看几个简单的 CRDT 模型。
AWSet
AWSet(Add-wins set),一种新增优先于删除的集合数据结构。
假如刚开始的时候,副本 A 和 副本 B 的状态是一致的,有一个 a 在集合中。
副本 A 删除了 a,然后再新增 a。
副本 B 删除了 a。
副本 A 的新增 a 和 副本 B 的删除 a 同时发生。
此时我们会选择新增,忽略删除,最后两个副本的状态还是 a 在集合中。
为判断两个操作是否是 "同时" 的,我们会附加一个和时序相关的元数据,比如时间戳、版本向量。
RWSet
RWSet(Remove-win set),一种删除优先新增的集合数据结构。
AWSet 类似,但对于并发的操作,会保留删除,丢弃新增。
LWW
LWW(Last-writer-wins),最后写入者优先。
所有的操作会有一个时间戳元数据,副本会对比同步操作的时间戳。
如果大于当前状态时间戳,覆盖掉原来的状态;如果小于当前状态时间戳,则忽略。
2P-Set
Two-Phase Set。
此模型会维护两个集合,一个是新增集合,保存新增的元素,另一个是删除集合,保存被删除的元素。
模型的最终状态为新增集合和删除集合的差集。
另外,集合比较特殊,它是只增集合(grow-only set),只能往集合里加元素,不能从集合里移除元素。
这意味着一个元素如果被删除了,就再也不能添加回来。所以删除集合也被叫做 tombstone set(墓碑集合),人噶不能复生。
2P-Set 也算是一种 RW-Set(删除优先),特别的点在于元素被删除后不能新增回来。
G-Counter
G-Counter,Grow-only Counter,只增计数器,一个只能增加计数的计数器。
此模型使用 n 个节点的容器(一个整数数组),每个副本会分配一个 id,某个副本给计数器 +1,其实就会给对应的数组元素 +1。
计数器的值为数组的求和。
PN-Counter
PN-Counter,Positive-Negative Counter,一个支持增减的计数器。
多个 CRDT 可以组合成一个更复杂的 CRDT。
类似 G-Counter,但 PN-Counter 使用两个 G-Counter,一个保存新增数(新增操作),另一个保存减少数(减少操作)。
计数器的值为新增数组求和减去减少数组的和。
YATA
最后我们看看复杂点的,简单介绍一下 Y.js 的 YATA(Yet Another Transformation Approach)模型。
假设我们有值为 "ABCD" 的字符串。
YATA 模型会将其拆分成一个个字符,加上元数据,然后按顺序首尾相连组成一个双链表。
kotlin
// 大概这样子
{
id: '2:0', // 客户端 ID + clock ID
val: 'B',
left: a, // 当前节点的左节点
right: c, // 右节点
origin: a, // 节点创建时的左节点
rightOrigin: c // 节点创建时的右节点
}
操作有 "插入" 和 "删除"。
假设本地在 AB 之间插入 E,此时没有发送同步,然后收到其他副本传过来的 F,也是要插入到 AB 之间。
此时 E 和 F 是冲突的,我们会对唯一的 id(某种意义上的时间戳)使用特定的规则来决定先后顺序。
至于删除操作,因为插入操作需要找到在左右节点的位置,所以节点即使被删除了也是不能从双链表中移出的。
对此,YATA 选择使用墓碑机制。YATA 将对应节点标记为删除(item.deleted
设置为 true),并将节点记录到删除集合 DeleteSet 里。
这里其实可以看到,CRDT 通常是需要加很多元数据的,尤其是复杂数据结构且数据量较大的场景,所以这也是 CRDT 起初被诟病占用内存高的原因。
但 Y.js 通过一系列手段(比如将多个节点合并为一个大节点),将性能优化到足够面对大多数场景,证明了用 CRDT 是做协同编辑的是不用担心性能问题的,如果有,一定是你没优化好。
结尾
本文只是简单介绍一些 CRDT 是什么,并感受了一些简单的 CRDT 模型,希望对你有所帮助。
我是前端西瓜哥,欢迎关注我,学习更多协同编辑知识。
相关阅读,