CRDT宝典 - ORSet

191 阅读4分钟

宝典目录

前提

正如我们在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同时有两层意思

  1. 无冲突的数据类型,即类型
  2. 一个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)