这篇博客将简要介绍CRDTs(Conflict-free Replicated Data Types)的基本概念以及从开发者的角度着重介绍简单的 CRDTs 的实现流程与细节(基于RGA 算法),并且基于这些知识点实现一个 rust+wasm 的简单 web 文本协作 demo。
- 项目地址: https://github.com/Leeeon233/crdt-toy
- demo 地址: https://crdt-toy.zeabur.app
实现思路参考了 crdt-edit1
什么是 CRDTs
对于 CRDTs 可以优先通过看[CRDT 简介]2、[CRDT 原理]3这些文章了解。
CRDTs 是近些年开始备受关注的一种用来处理分布式系统上的协同可用性的数据结构。它在可用性
与分区容错性
的基础上,不提供完美的一致性
而是提供强最终一致性
。下面的例子可以帮助我们理解什么是强最终一致性
。
比如当前有Alice和Bob两个同学在各自的电脑上共同编辑同一份文档,Alice 写入了Hello CRDT
,Bob 写入了Hello crdt
。
Loading graph...
flowchart TB subgraph Bob id2[Hello crdt]; end; subgraph Alice id1[Hello CRDT]; end;
但在此时 Alice 其实并没有办法知晓 Bob 可能在同一时刻也写入了Hello crdt
的文本,并不像真正本地那样,完全意义上的在编辑同一份文件(一致性)。
只有当他们进行了一次同步通信后,Alice 或者 Bob 才会知晓对方编辑了什么。crdt 所提供的 强最终一致性 意义并不是让多人协同真正地像大家就在同一页纸上写字一样,而是大家可以各自地编辑自己的内容,尽管在多次同步的过程中会产生冲突,但 crdt 可以保证所有的消息都被接收后,最终的内容将会是多方一致的,哪怕可能最终冲突解决后的版本并不是真正所期待的结果。
Loading graph...
flowchart TB subgraph Editor id1-->id3 id2-->id4 direction TB %% 子图1方向 subgraph Alice direction TB id1[Hello CRDT]; id1---id4((多次同步)); end subgraph Bob direction TB id2[Hello crdt]; id2---id3((多次同步)); end end subgraph Result direction TB %% 子图2方向 idn[结果] idn--?---id5[Hello crdt]; idn--?---id6[Hello CRDTHellocrdt]; idn--?---id7[Hello CRDTcrdt]; end %% 主图 id3-->idn id4-->idn
对于 Alice 和 Bob 的例子,在输入Hello CRDT
和Hello crdt
时可能进行了多次的同步操作,最终展现在 Alice 和 Bob 眼前的文本根据不同的 crdt 算法和冲突解决策略可能变得不同。也许是Hello crdt
、Hello CRDTHellocrdt
或Hello CRDTcrdt
等等都有可能,但是最终在 Alice 和 Bob 眼前的结果将会是完全一致的其中一种结果。
CRDTs 的简单实现
我们就以多人的文本内容协作作为场景来尝试实现一个基于 op 的 crdt 的编辑器。下面是编辑器 demo 的效果展示。分别有Client1
和Client2
两个客户端在共同编辑一份文档。
Final Text的文本区域展示的当前同步后的结果。Client1
和Client2
也可以点击按钮表示进行同步。
首先我们需要确定这样一个简单的编辑器,用户会有哪些操作(op)。思考一下,非常简单地就可以得出一共有两种操作:
- 插入
- 删除
修改(可以简单地视为先删除再插入的组合)
分布式中的顺序
那么我们还是回到 Alice 和 Bob 的例子,我们提供了第一版的编辑器给他们,但是由于我们技术还未成熟,要求他们只能插入内容还不能删除。Alice 和 Bob 同意参与到我们的迭代测试中去。他们还是分别一次输入了
Hello CRDT
和Hello crdt
。这时他们发起了同步。
Loading graph...
flowchart TB subgraph Editor id1-->id3 id2-->id4 direction TB %% 子图1方向 subgraph Alice direction TB id1[Hello CRDT]; id1---id4((同步)); id4-->AliceText[Hello CRDTHello crdt] end subgraph Bob direction TB id2[Hello crdt]; id2---id3((同步)); id3-->BobText[Hello crdtHello CRDT] end end
Alice 给 Bob 打了个电话,发现自己的屏幕上结果是Hello CRDTHello crdt
而 Bob 的屏幕上结果是Hello crdtHello CRDT
。怎么会这样?他们把测试结果反馈给了我们。
原来我们第一版系统都把新接收到的插入操作当作了后发生的事情,Alice 的编辑器把 Bob 的内容加在了后面,Bob 的编辑器也是如此。这和我们一开始所强调的强最终一致性可不符。我们希望无论事件被怎样创建和接收,只要操作集合是一致的,那么最终结果应该一样。
既然已经知道了问题所在,那么就没什么问题了。我们只需要让每个用户之间有一个固定的顺序就可以了,可以为每一个编辑器客户端分配一个Client ID
,以ID的大小顺序来作为操作的顺序。我们很快发布了 v2.0 版本,让客户端 ID 越大认为操作越先发生,并且分配
- Alice 的客户端版本为
1
- Bob 的客户端版本为
2
提供给了 Alice 和 Bob 帮忙测试使用。
Loading graph...
flowchart TB subgraph Editor id1-->id3 id2-->id4 direction TB %% 子图1方向 subgraph Alice direction TB id1[Hello CRDT]; id1---id4((同步)); id4-->AliceText[Hello crdtHello CRDT] end subgraph Bob direction TB id2[Hello crdt]; id2---id3((同步)); id3-->BobText[Hello crdtHello CRDT] end end
这回没有问题了,Alice 和 Bob 的编辑器上显示的都是Hello crdtHello CRDT
文本。
但随着进一步使用,Alice 和 Bob 又发现问题了。这次
- Bob 先输入了
one
然后又输入了two
- Alice 先输入了
three
又输入了four
然后他们进行了同步,这次每个人都发送了两个插入操作。
Loading graph...
flowchart TB subgraph Editor one-->sy2 two-->sy2 three-->sy1 four-->sy1 direction TB %% 子图1方向 subgraph Bob direction TB one[one ]; one-->two; two---sy1((同步)) sy1-->BobText[one two fourthree ] end subgraph Alice direction TB three[three ]; three-->four; four---sy2((同步)); sy2-->AliceText[one two three four] end end
现在 Bob 的屏幕上显示的是one two fourthree
而 Alice 屏幕上显示的却是one two three four
,正常来说one two three four
才是他们想要的结果。
我们发现 Alice 向 Bob 同步的内容发生了顺序的改变,有着分布式经验的我们立刻想到,可能是网络延迟的原因,导致 Alice 的第二个操作早于第一个操作被 Bob 的客户端接收到造成了顺序的不一致。
既然已经知道了问题所在,那么就没什么问题了。我们可以新增一个逻辑时间戳,也就是Lamport Timestamp
Lamport Timestamp 的算法很简单4
- 每个进程维护一个
counter
- 本地每发生一个事件就将
counter + 1
,并将事件的时间戳设置为counter
值- 每当进程发送一个消息,就将本地
counter + 1
,并将最新的counter
值附带在消息上- 当进程收到消息后,让自己的
counter = max(counter, message.counter) + 1
我们在原有的ClientId
基础上进行了扩展,构建了一个EventId
,其中包括了ClientId
和Lamport Timestamp
两部分,这样事件的发生顺序就可以与接收到的顺序无关了。
我们抓紧更新了 v3.0 版本的编辑器给到 Alice 和 Bob,心想肯定没问题了。
只确定逻辑顺序就够了吗
Alice 和 Bob 拿到 v3.0 的编辑器那肯定先尝试了一下刚刚失败的例子
Loading graph...
flowchart TB subgraph Editor one-->sy2 two-->sy2 three-->sy1 four-->sy1 direction TB %% 子图1方向 subgraph Bob direction TB one[one ]; one-->two; two---sy1((同步)) sy1-->BobText[one two three four] end subgraph Alice direction TB three[three ]; three-->four; four---sy2((同步)); sy2-->AliceText[one two three four] end end
果然更新后就没问题了。但 Bob 这回想着也不能一直只在后面加字符,于是 Bob 在编辑器的最前面插入了zero
,并且同步给了 Alice。发现同步之后结果怎么又不一样了。Alice 的结果是one two three fourzero
,Bob 输入的zero
怎么在后面啊?
Loading graph...
flowchart TB subgraph Editor one-->sy2 two-->sy2 three-->sy1 four-->sy1 zero-->sy3 direction TB %% 子图1方向 subgraph Bob direction TB one[one ]; one-->two; two---sy1((同步)) sy1-->zero["zero (insert at 0 position)"] zero-->BobText[zero one two three four] end subgraph Alice direction TB three[three ]; three-->four; four---sy2((同步)); sy2---sy3((同步)) sy3-->AliceText["one two three fourzero "] end end
Alice 和 Bob 开始不耐烦了起来,你们这个编辑器到底行不行?我们听到这个消息,赶快赶过来了解情况。
之前我们给每个插入操作分配了一个EventId
,能够确定所有的插入操作的发生顺序。但是我们没有想到操作的顺序在文本这个场景下不能代表操作的位置。我们觉得不能再这样遇到什么问题解决什么问题了,需要好好想清楚编辑器这个问题,包括一直没被考虑的删除操作。
目前我们一共面临下面几个问题:
- 文本的插入和删除操作是需要确定插入和删除位置的,该如何确定
- 文本可能在任意的位置进行插入和删除,我们应该需要确定操作的最小单位
- 简单的 Op 列表可能不太容易满足我们的要求了,该使用怎样的数据结构呢
面临的问题还有些抽象,我们先通过一部分的例子来模拟一下吧。
Loading graph...
flowchart LR blank[空白]-->ia[在编辑器中插入a]-->ib[在a后面输入b]-->ic[在b后面输入c]-->db[删除字符b]-->id[在a的后面插入d] blank[空白]-->a-->ab-->abc-->ac-->adc
看到上面的图,我们的思路就清晰了很多。文本编辑里面最小操作的单位肯定是**字符
**这点毋庸置疑了。至于确定位置这点,我们观察图中第一排的全部操作描述
- 在编辑器中插入 a
- 在 a 后面输入 b
- 在 b 后面输入 c
- 删除字符 b
- 在 a 后面插入 d
那么位置就完全可以通过当前文本内容,看接下来要插入或者删除的字符是基于目前文本的哪个字符做的操作就可以确定了。这样不同的字符之间好像就有了依赖关系,那数据结构是不是可以使用树形结构了?
我们突然感觉灵感来了。
文本编辑好像就是棵树
如果我们把初始空文本当作树的根节点(root)的话,那么插入字符a
就是a
是root
的子节点。在a
后面插入b
,那b
就是a
的子节点。我们把上面的示例画一下:
Loading graph...
flowchart LR 1-->2 2-->3 3--删除?-->4 4--顺序?-->5 subgraph 1 direction TB root-->a-->b end subgraph 2 direction TB root2[root]-->a2[a]-->b2[b]-->c2[c] end subgraph 3 direction TB root3[root]-->a3[a]-->b3["b(删除)"]-->c3[c] end subgraph 4 direction TB root4[root]-->a4[a]-->c4[c] end subgraph 5 direction TB root5[root]-->a5[a]-->c5[c] a5-->d5[d] end
把输入字符的过程可视化之后,我们看到过程 1 和 2 好像都没什么问题,全部都是在按顺序插入字符,前一个字符作为后一个字符的父节点,从根节点遍历下来就能还原原始文档。但是接下来我们好像遇到两个不是太明朗的操作。
- 多个子节点时遍历顺序是否有要求
- 删除字符时是否要把节点删除
这回实在不好意思再请 Alice 和 Bob 帮我们找问题了,就我和你来自己先多测测吧。第一个问题看起来还算简单
Loading graph...
flowchart LR t1-.-g1 t2-.-g2 t3-.-g3 t4-.-g4 subgraph tree[Tree] direction LR g1-->g2 g2-->g3 g3-->g4 subgraph g1["insert a at 0"] direction TB a1[a] end subgraph g2[insert b after a] direction TB a2[a]-->b2[b] end subgraph g3[insert c after a] direction TB a3-->c3[c] a3[a]-->b3[b] end subgraph g4[insert d after a] direction TB a4-->d4[d] a4-->c4[c] a4[a]-->b4[b] end end subgraph text[Text] direction LR t1[a]-->t2[ab]-->t3[acb]-->t4[adcb] end
我们不断模拟向a
后面加入文本,结果都是越后面加入的字符,越显示在前面。在前面测试分布式同步的时候我们已经可以通过EventId
全局地确定顺序了,那么全部子节点的顺序就可以通过EventId
进行排序,越新的EventId
将在子节点中越早被遍历。
删除了字符,它就不存在了吗?
接着我们模拟了几次协作时删除字符的情况,比如这次我先输入了ab
你输入了c
,之后我们进行了一次同步,我们的结果最后都是abc
。
你可能会有疑问,为什么是abc
,而不是cab
?
这里我们使用假设
- 你的
ClientId
是1
- 我的
ClientId
是2
ClientId
越大 crdt 认为越先发生
我们详细看一下abc
的合并过程,现在我收到了全部的 crdts 的 op:
- 根节点插入
a
EventId(counter: 0, client_id: 2) a
节点插入b
EventId(counter: 1, client_id: 2)- 根节点插入
c
EventId(counter: 0, client_id: 1)
根据刚刚的结论,op1 和 op3 都是counter
为 0 的 op,但是 op1 的ClientId
更大,在假设前提下会被认为优先发生。那么整个 op 顺序就会变成 132,即结果的abc
。
紧接着我删除了b
,而你在b
的后面输入了d
,然后我们都发起了同步请求。
Loading graph...
flowchart TB subgraph Editor direction TB %% 子图1方向 b--->sy1 c-->sy2 d-->sy4 db2-->sy3 sy3-->rf["??????"] sy4-->rf subgraph 我 direction TB a["insert a at 0\n a"]-->b["insert b after a\n ab"]-->sy2((同步))-->r2["同步后\n abc"]-->db2["delete b\n ac"]-->sy4((同步)) end subgraph 你 direction TB c["insert c at 0\n c"]-->sy1((同步))-->r1["同步后\n abc"]-->d["insert d after b\n abdc"]-->sy3((同步)); end end
现在好像遇到了问题,我都已经把b
删掉了,那么该怎么同步你要把d
插入在b
后面的操作呢?
我静静地整理了一下思路,我删除了b
,但于此同时的你或者其他人仍可能在b
后面输入一些字符,这也就是b
仍可能是其他子树的父节点。其他节点仍然可能会依赖于它。所以我们在删除操作时不可能将真正的树节点删除。
再来想想文本展示时,就是一个树的遍历过程。只要不在编辑器上显示这个字符那么使用编辑器的用户就完全可以认为这个字符被删除了。所以我们只需要给每个节点增加一个标志
,表示这个节点是不是被删除就可以了。
上面提到的为了保留树的完整结构,不删除节点,而是添加标志的方式就是一种墓碑机制
现在我们编辑器的结果就都会是adc
,满足 crdts 所要求的强最终一致性
。我们来遍历一遍刚刚所形成的文本树:
Loading graph...
flowchart LR t1-.-g1 t2-.-g2 t3-.-g3 t4-.-g4 t5-.-g5 subgraph tree[Tree] direction LR g1-->g2 g2-->g3 g3-->g4 g4-->g5 subgraph g1["insert a at 0"] direction TB root1[root]-->a1[a] end subgraph g2[insert b after a] direction TB root2[root]-->a2[a]-->b2[b] end subgraph g3[insert c at 0] direction TB root3[root]-->a3[a]-->b3[b] root3-->c3[c] end subgraph g4[delete b] direction TB root4[root]-->a4[a]-->b4["b (deteled)"] root4-->c4[c] end subgraph g5[insert d after b] direction TB root5[root]-->a5[a]-->b5["b (deteled)"]-->d5[d] root5-->c5[c] end end subgraph text[Text] direction LR t1[a]-->t2[ab]-->t3[abc]-->t4[ac]-->t5[adc] end