【文档协同编辑】(二) CRDT数据结构

历史

CRDT (Conflict-free Replicated Data Type),即“无冲突复制数据类型”,它主要被应用在分布式系统中,保证分布式应用的数据一致性。文档协同编辑可以理解为分布式应用的一种。CRDT的本质是数据结构,通过数据结构的设计保证并发操作数据的最终一致性。

CRDT 于 2011 年正式被提出,而目前最知名的基于CRDT的框架Yjs在2015年开源,在此基础上构建了许多Web应用项目,包括Monaco,Tiptap等。

CRDT的核心思想

大多数的CRDT在文档中为每个字符分配一个独一无二的ID,为了保证文档始终能够收敛,已删除的数据仍然保留元数据,只是在页面上不显示。[1]

CRDT支持各个主机副本之间数据修改的直接同步,而且数据修改的同步顺序以及同步的次数不影响最终结果,只要修改操作一致,数据的最终状态就是一致的,也就是通常大家说的 CRDT 数据的满足交换性和幂等性。

由此CRDT满足了去中心化的最终一致性。每个主机只要知道相同的操作序列,就可以得到相同的结果。

CRDT的分类:

1. 基于操作的CRDT: 也称为交换性(commutative)复制数据类型或 CmRDT。 CmRDT 副本通过仅传输更新操作来传播状态。 例如,一个单个整数的 CmRDT 可能会广播操作 (+10) 或 (−20)。副本收到更新并在本地应用它们。这些操作是可交换的。然而它们不是必然幂等的(多次同步)。因此,此时必须确保所有一个副本上的操作被传递给其他副本,没有重复,但可以是任意顺序[3]。

2. 基于状态的 CRDT: 称为convergent replicated data types或 CvRDT。 与 CmRDT 相比,CvRDT 将其完整的本地状态发送到其他副本,其中状态由必须具有交换性、关联性和幂等性的函数合并。merge函数为任何一对副本状态提供关联,因此所有状态的集合形成一个半格(semilattice)。 根据与半格相同的偏序规则,更新函数必须单调增加内部状态。

注:半格是一个数学上的概念,简单理解是满足运算幂等和交换的一个集合。这里不重要。

场景

以下内容主要来自[1], 有删节

用 CRDT 的思想解决脏路径问题

对于脏路径问题:

 

序列若不经过转换,在A客户端与B客户端得到的结果不一致。

我们使用类似于 CRDT 的方式描述数组:

左边操作:

在 Index=2 的位置插入 Access -> 在 111 之后插入 Access

右边操作:

在 Index=4 的位置插入 Testhub -> 在 333 之后插入 Testhub

冲突问题即得到解决。

CRDT 解决并发冲突

这里还是以图片设置 align 属性为例介绍,首先看看CRDT如何描述对象属性及属性修改:

左边是图片数据模型,右边是模拟 CRDT 对应的数据结构,图片对象中的每一个字段都使用结构对象去描述内容及内容的修改,这里以 align 字段的代表看它的表达

操作 ①:

最上面蓝色部分表示 align 的初始值是 center ,(140, 20)是这个初始数据结构的标识,它也是基于某一个用户的操作产生的。

这个时候一个用户执行了操作 ①,把 align 属性修改为 left,产生了一个新的结构对象,就是图中橙色部分的表示。操作完成后,Map 中的 align 字段指向了新产生的结构对象上,标识符是(141,0),因为(141,0)这个结构对象是基于(140,20)的修改,所以它的 left 指向(140,20)这个结构对象。

这个示例会有一些歧义,就是链表的数据结构本身会有 left、right 两个指针(在结构对象左右两边),然后中间部分其实是内容,但是我的内容存储的是图片的 align 属性,它的值可能是 left、center、right,跟链表在 left、right 指针在一起可能产生混淆。

操作②:

这个时候另外一个用户基于刚刚产生的结构对象(141,0)进行了操作 ②,把 align 属性修改为right,产生了一个新的结构对象,就是图中橙红色部分的表示。

图片下半部分是这两个操作之后最终的数据结构,它是一个双向链表的表达(这种表达已经很接近 Yjs 真实的数据结构了),它不仅可以描述最终的数据状态(right),还可以表达出数据修改的顺序:center -> left -> right。

这个示例其实描述的是顺序操作,每一个操作基于的状态都是最新状态,两个用户执行的操作是有确定先后顺序的。

下面看看两个用户并发的执行属性修改时产生的数据结构:

与前面最大的不同就是执行操作 ② 和执行操作 ① 所基于的状态是一致的,都是基于 align = 'center' 进行修改的,这种情况表达的就是并发数据的修改。接下来就是并发处理的逻辑了,跟前面介绍的一致,这个时候操作 ① 的对应的用户标识 141 小于操作 ② 对应用户标识 142,所以先应用操作 ①,后应用操作 ②,所以最终图片的 align 属性状态是 right。[1]


CRDT 解决 undso/redos问题

CRDT 可以理解为完全没有「脏路径」问题,然后并发冲突问题也完全可以基于 CRDT 的标识符(时间戳)去解决,那么基于 CRDT 的方案中,实现 undos/redos 应该就比较简单了,只需要根据 CRDT 的数据结构的新增或者删除去实现 undos/redos 栈就可以有效解决问题。 假如进行了一个生成结构对象的操作,那么撤回的时候可能就把它标记删除。

假如进行一个删除结构对象的操作,在执行撤回操作时可能就对应于重新执行结构对象的插入操作。

CRDT 算法说明

与 OT 不同,CRDT是一种全新的解决方案,它不依赖于编辑器实现,对于任何的编辑器数据模型都可以使用一套 CRDT 数据结构去处理冲突,也是因为数据结构的性质,它也可以不依赖中心化的服务器,而且稳定性非常高,这区别于 OT,OT可以理解为是通过算法控制保证数据一致性,CRDT 通过数据结构设计保证数据一致性,它在复杂的网络环境中的处理是更稳健的,CRDT 的代价就是要保存更多的元数据,这会带来一定内存消耗,但是这是可优化的,事实证明这个代价在协同编辑场景是完全可忽略不计的。

OT vs CRDT

 

 优势

劣势 

OT

 

1.高性能

2.保留原始的操作意图

3.容易理解

 

中心化服务器

需要OT控制算法

不同数据模型OT算法需单独实现

 

CRDT

1.去中心化

2.天然支持离线编辑操作的同步)

3.稳定性高

 

1.损失操作意图(比如yjs 就不支持split_node、move_node)

2.损耗内存及性能

3.基础数据结构实现难度大

 

数据结构

CRDT底层常用的类型,一种是List,在[1]中已经有严谨的实现。一种是YATA,在[2]中有提及。

而抽象的数据结构,包括了:G-Counter、PN-Counter,2P-Set,Grow-only Set等。

接口定义如下

G-Counter

 

2P-Set (Two-Phase Set)

 

PN-Counter

2-Phase Set

 

Reference:

[1]https://juejin.cn/post/7030327005665034247

[2]https://josephg.com/blog/crdts-are-the-future/

[3]https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type

[4]https://docs.yjs.dev/

[5]Karayel E,  Gonzalez E. Strong Eventual Consistency of the Collaborative Editing Framework WOOT.  2020. 

[6]https://josephg.com/blog/crdts-go-brrr/

posted @ 2022-04-23 23:36  stackupdown  阅读(373)  评论(0编辑  收藏  举报