宝典目录
- CRDT宝典(一): 引言
- CRDT宝典(二): 基本概念
- CRDT宝典(三): GCounter
- CRDT宝典(四): PNCounter
- CRDT宝典(五): GSet
- CRDT宝典(六): PNSet
- CRDT宝典(七): VClock
- CRDT宝典(八): LLW-Register
- CRDT宝典(九): ORSet
- CRDT宝典(十): AWORSet
前提
正如我们在PNSet文章中提出的问题所述,我们需要解决PNSet的一个问题
如果A节点里的PNSet如下
A.PNSet = [["element1", "element3"], ["element2"]]
然后A.PNSet添加了一个元素element2,即
A.PNSet = [["element1", "element2","element3"], ["element2"]]
但是在获取A节点的PNSet的值的逻辑里,element2并不会在最终的结果中,因为A.PNSet[1]中有element2,这明显不合理呀!(即便PNSet被别的节点删过element2,或者A节点昨天删过element2,但是今天A节点添加了element2,那最终的值应该要有element2)
背景
在一个分布式系统中,有一个集合(Set),每个节点都能随意往里面添加、删除元素。
请设计一个CRDT(Conflict-free Replicated Data Type)来实现一套满足最终一致性的分布式系统
为了减少要理解的概念,下文描述的CRDT同时有两层意思
- 无冲突的数据类型,即类型
- 一个CRDT实例,即实例
思维链
flowchart TD
A["1. CRDT可以在不同的节点中使用"]
A --> B["2. CRDT可以表达所有节点最终往Set中添加的元素集合"]
B --> C["3. CRDT可以让任意节点往Set中添加和删除元素"]
C --> D["4. 可合并多个节点的CRDT,且其结果与合并顺序无关,\n即 Merge(A.CRDT, B.CRDT, C.CRDT) = Merge(B.CRDT, C.CRDT, A.CRDT) = a new CRDT"]
D --> E["5. 多次合并同一个节点的CRDT,其结果等同于合并一次,\n即 Merge(A.CRDT, B.CRDT, B.CRDT) = Merge(A.CRDT, B.CRDT) = a new CRDT"]
E --> F["6. 在PNSet的基础上,解决PNSet的问题即可"]
F --> G["7. 如果我们能比较同一元素的`添加`和`删除`操作的先后顺序,就可以解决问题"]
G --> H["8. 我们给Set中的所有元素都添加一个时间戳,代表元素更新的最新时间"]
H --> I["9. 在合并时,只保留时间戳最大的元素,即最新的状态"]
特别说明:第七点和我们之前提到的LWW-Register的核心是一致的
实现
这个CRDT如下,我们暂时给它起名ORSet(稍后解释其为什么叫ORSet)
// 我们不直接使用LWW-Register,而是使用LWW-Register的思路
const ORSet[<Record<T, timestamp>>, <Record<T, timestamp>>] = [
{
'element1': 1,
'element2': 1,
'element3': 1,
},
{
'element2': 2,
}
]
很明显,这个ORSet的值为["element1", "element3"],删除element2的操作比添加element2的操作更晚
因为是分布式系统,且支持弱网环境,所以这个ORSet在不同节点里,都不一样。
比如
// 节点A的数据副本
A.ORSet = [
{
'element1': 1,
'element2': 1,
'element3': 1,
},
{
'element2': 2,
}
]
// 节点B的数据副本
B.ORSet = [
{
'element1': 1,
'element2': 2,
'element4': 3
},
{
'element2': 4
}
]
// 节点C的数据副本
C.ORSet = [
{
'element1': 1,
'element2': 2,
'element3': 3,
'element5': 4
},
{
'element2': 5
}
]
各自节点往ORSet中添加元素,比如A节点往ORSet中添加元素,通过A.ORSet[0]['element6'] = 6即可
各自节点往ORSet中删除元素,比如A节点往ORSet中删除元素,通过A.ORSet[1]['element2'] = 7即可
各自节点的ORSet的值为:删除ORSet[0]中ORSet[1]存在且timestamp较大的元素后得到的Set
合并不同节点的ORSet,比如merge(A.ORSet, B.ORSet),类似于PNSet的合并,同时更新timestamp为更大的值
// 节点A的数据副本
A.ORSet = [
{
'element1': 1,
'element2': 1,
'element3': 1,
},
{
'element2': 2,
}
]
// 节点B的数据副本
B.ORSet = [
{
'element1': 1,
'element2': 2,
'element4': 3
},
{
'element2': 4
}
]
// 添加元素
const add = (node, value: string) => {
node.ORSet[0][value] = Date.now();
}
// 删除元素
const remove = (node, value: string) => {
node.ORSet[1][value] = Date.now();
}
// 获取ORSet的值
const getORSet = (node) => {
const current = []
Object.keys(node.ORSet[0]).forEach(element => {
if (node.ORSet[1][element] <= (node.ORSet[0][element] ?? 0)) {
current.push(element);
}
});
return current;
}
// 合并两个ORSet
const merge = (ORSetA: ORSet, ORSetB: ORSet) => {
const newORSet = [...ORSetA[0], ...ORSetB[0]]
const newRemoveSet = [...ORSetA[1], ...ORSetB[1]]
return [newORSet, newRemoveSet]
}
const newORSet = merge(A.ORSet, B.ORSet)
你按随意的顺序合并A.ORSet,B.ORSet,C.ORSet,其结果都一致,即最终一致性。
QA
问:针对PNSet问题,为什么不能在添加元素时,删除掉被删除的元素呢?即
A.PNSet = [["element1", "element3"], ["element2"]]
A.PNSet[0].add("element2")
// 添加元素时,同时删除掉被删除的元素
A.PNSet[1].remove("element2")
A.PNSet = [["element1", "element2", "element3"], []]
答:因为B.PNSet可能如下
B.PNSet = [["element1", "element3"], ["element2"]]
当你合并A.PNSet和B.PNSet时,你会发现你的提问的逻辑有问题
const newPNset = merge(A.PNSet, B.PNSet) = [["element1", "element2", "element3"], ["element2"]]
好啦,这个newPNSet,依旧有删除掉element2的操作,你在A节点进行的额外操作没意义了。
问:针对PNSet的问题,为什么不能在获取PNSet的值时,排除掉被删除的元素呢?即
A.PNSet = [["element1", "element2", "element3"], ["element2"]]
A.PNSet.value = ["element1", "element2", "element3"]
答:这样的操作会让删除操作毫无意义
总结
因为这个CRDT不再是数据副本的某种状态(深深理解这句话),它可以观测到所有节点最终在这个CRDT的操作的先后顺序,并且可以删除元素,所以其叫ORSet(全称:Observed-Remove Set)