这一次,彻底搞懵 CRDT

2024年 3月 11日 76.4k 0

我是前端西瓜哥,今天我们来简单入门一下 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 模型会将其拆分成一个个字符,加上元数据,然后按顺序首尾相连组成一个双链表。

// 大概这样子
{
  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 模型,希望对你有所帮助。

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论