CRDT宝典 - yata算法

宝典目录

背景

分布式系统中有一个可索引序列(用数组为例),每个节点均可以往序列里面插入、删除元素

请设计一个CRDT(Conflict-free Replicated Data Type)来实现一套满足最终一致性的分布式系统

为了减少要理解的概念,下文描述的CRDT同时有两层意思

  1. 无冲突的数据类型,即类型
  2. 一个CRDT实例,即实例

思维链

没什么难度的,借用DotKernel的思路,数组的每个元素 = 一个操作 = 一个dot,直接上代码

ts 复制代码
// A节点的数据副本
// id由节点id和操作序列counter组成
// value是值
A.DotKernel-Array: Array<{
    id: [string, number],
    value: string
}> = [
    {
        id: [A.id, 1],
        value: 1
    },
    {
        id: [A.id, 2],
        value: 4
    }
]

如果我们想在数组的某个元素的后面添加一个元素,比如A.DotKernel-Array[0]后面加一个元素,如果我们直接

ts 复制代码
A.DotKernel-Array[1] = {
        id: [A.id, 3],
        value: 2
}

然后将1以后的元素都往后偏移一位。

那么问题来了

ts 复制代码
{
        id: [A.id, 3],
        value: 2
}

表达不了其在A.DotKernel-Array[0]之后。

那简单呀,我们加一个指针

ts 复制代码
{
        id: [A.id, 3],
        value: 2,
        pointer: [A.id, 1]
}

另一个问题出现,当我们在数组的开头添加一个元素时,没有左指针只能指向空,如果是单节点的话,到没什么问题。但是分布式系统中,每个节点的第一个元素的了pointer都可能指向空,那么在合并时就蒙了。

还有另一个问题,当你想在某个元素的左侧添加元素时怎么办?

显然一个指针是不够的,我们需要两个指针。

ts 复制代码
{
        id: [A.id, 3],
        value: 2,
        leftPointer: [A.id, 1],
        rightPointer: [A.id, 2]
}

完美,这样别的节点就知道A节点是在[A.id, 1][A.id, 2] 之间插入了一个[A.id, 3]

那么我们想要删除某个位置的元素呢?

简单呀!我们直接删掉这个元素。

但是有问题呀!别人不知道这个删除操作,而且如果别的节点在这个被删除的元素的右侧/左侧添加了元素的话,那么就蒙了。

所以我们不能硬删,得软删,我们将元素的value设置为null即可,这样别人就知道这个元素被删除了。

那么合并两个节点的DotKernel-Array呢?思维链如下:(里面全是伪代码?千万不要较真!)

flowchart LR A["1. 新建一个newDotKernel-Array = \n\nA.DotKernel-Array。\n\n2. 遍历B.DotKernel-Array"] A --> B["2.1 如果B.DotKernel-Array[id].value为null\n\n且newDotKernel-Array[id].value不为null,\n\n代表B节点的操作操作,A节点不知道,\n\n则newDotKernel-Array[id].value = null"] A --> C["2.2 如果newDotKernel-Array[id]不存在,\n\n且B.DotKernel-Array[id].leftPointer\n\n和B.DotKernel-Array[id].rightPointer\n\n在newDotKernel-Array中都存在,\n\n代表B节点的新增操作,A节点不知道,\n\n则将B.DotKernel-Array[id]插入newDotKernel-Array中"] E["3. 返回新的DotKernel-Array"] B --> E C --> E

2.2步里的插入操作,我们用B.DotKernel-Array[id]leftPointerrightPointernewDotKernel-Array中找到插入位置,然后插入B.DotKernel-Array[id]

但是如果newDotKernel-ArrayleftPointerrightPointer之间就有元素怎么办,即别的节点也在这之间添加过元素,这就是冲突,我们如何解决?

针对同一个节点的来的冲突操作,因为item.id里有节点的操作序列counter,所以同一个节点的冲突操作可以组成一个有序的序列。

针对不同节点的冲突操作,如果不做操作的话,那合并后的操作序列就是乱序的(不难理解吧,我就不给例子了),那我们怎么办呢?

很简单咯,我们可以用item.id里的节点id作为比较的根据,通过这样的方式,节点id较大的操作永远在节点id较小的操作之前,不论在任何节点合并newDotKernel-Array时都一样,即最终一致性。

这两点缺一不可噢,否则无法保证最终一致性。

不知道你们发现没,我们不需要考虑A节点被删除的元素被其他的元素恢复的问题!!!!!!!!因为我们设定好不可以将值为null的元素恢复为非null的操作,如果有这样的需求,即通过重新插入一个值相同的元素即可。这帮我们避免了很多问题!!!!

实现

ps:代码里面很多没考虑性能,只是作为示例,不要较真。

ts 复制代码
A.DotKernel-Array: DotKernel-Array = [
    {
        id: [A.id, 1],
        value: 1
    },
    {
        id: [A.id, 2],
        value: 4
    },
    {
        id: [B.id, 1],
        value: 2
    }
]
B.DotKernel-Array: DotKernel-Array = [
    {
        id: [B.id, 1],
        value: 2
    },
    {
        id: [B.id, 2],
        value: 2
    }
]

// 添加元素
const addElement = (dotKernelArray: DotKernel-Array, element: {
    id: [string, number],
    value: string
}, index:number) => {
    const newDotKernelArray = dotKernelArray.slice()
   // 获取实际的index,包括被删除的元素
   const actualIndex = newDotKernelArray.findIndex((item) => {
        if(index < 0 ) {
            return true
        }
        if(item.value !== null) {
            index--
        } else {
            return false
        }
   })

   // 插入元素,代码就在下面两行
   integrate(newDotKernelArray, actualIndex, element, originLeftIndex, originRightIndex)
   return newDotKernelArray
}

// 在合适的位置集成元素
const integrate = (dotKernelArray: DotKernelArray, actualIndex: number, targetElement: {
    id: [string, number],
    value: string
}, originLeftIndex: number, originRightIndex: number) => {
    let finalIndex = originRightIndex
    for(let i = originRightIndex; i >= originLeftIndex; i--) {
        const element = dotKernelArray[i]
        if(targetElement.id[0] > element.id[0] || (targetElement.id[1] >element.id[1])) {
            finalIndex = i
        }
    }
    dotKernelArray.splice(finalIndex, 0, targetElement)
}

// 删除元素
const deleteElement = (dotKernelArray: DotKernelArray, element: {
    id: [string, number],
    value: string
}, index:number) => {
    const newDotKernelArray = dotKernelArray.slice()
    const actualIndex = newDotKernelArray.findIndex((item) => {
        if(index < 0 ) {
            return true
        }
        if(item.value !== null) {
            index--
        } else {
            return false
        }
    })
    newDotKernelArray[actualIndex].value = null
    return newDotKernelArray
}

// 合并
const merge = (dotKernelArray1: DotKernelArray, dotKernelArray2: DotKernelArray) => {
    const newDotKernelArray = dotKernelArray1.slice()
    // 将B节点中,A不知道的删除操作应用到newDotKernelArray
    dotKernelArray2.forEach((item) => {
        if(item.value === null) {
            deleteElement(newDotKernelArray, item, item.id[1])
        }
    })

    // 将B节点中,A不知道的新增操作应用到newDotKernelArray
    dotKernelArray2.forEach((item) => {
        if(!dotKernelArray1.has(item) && dotKernelArray1.has(item.leftPointer) && dotKernelArray1.has(item.rightPointer)) {
            addElement(newDotKernelArray, item, item.id[1])
        }
    })

    return newDotKernelArray
}

你按随意的顺序合并A.DotKernel-Array,B.DotKernel-Array,C.DotKernel-Array,其结果都一致,即最终一致性。

QA

问:不允许将value为null的元素恢复为非null的元素,也就是说如果用户频繁删除一个元素,然后恢复它,这样会多无意义的墓碑数据。

答:好问题噢,yjs里有介绍,如何合并墓碑数据(笑)

问:这个CRTD的场景我觉得用双向链表更好

答:你可以尝试用双向链表实现add、del、merge、integrate,看看是可索引序列性能高,还是双向链表性能高。

问:可以接入delta的理念吗?

答:自然,因为这属于state-based CRDT,如下

ts 复制代码
A.Delta-DotKernel-Array: {
    values:DotKernel-Array,
    deltas:DotKernel-Array
} = {
    values: [ 
        {
            id: [A.id, 1],
            value: 1
        }
    ],
    deltas: [
        {
            id: [A.id, 1],
            value: 1
        }
    ]
}

总结

这个可索引序列的CRDT就是yata算法的核心,恭喜,state-based CRDT快学到头啦!!!!。

后面还有operation-based CRDT。

相关推荐
王哈哈^_^4 分钟前
【数据集+完整源码】水稻病害数据集,yolov8水稻病害检测数据集 6715 张,目标检测水稻识别算法实战训推教程
人工智能·算法·yolo·目标检测·计算机视觉·视觉检测·毕业设计
Larcher8 分钟前
新手也能学会,100行代码玩AI LOGO
前端·llm·html
light_in_hand19 分钟前
内存区域划分——垃圾回收
java·jvm·算法
徐子颐20 分钟前
从 Vibe Coding 到 Agent Coding:Cursor 2.0 开启下一代 AI 开发范式
前端
小月鸭33 分钟前
如何理解HTML语义化
前端·html
jump6801 小时前
url输入到网页展示会发生什么?
前端
诸葛韩信1 小时前
我们需要了解的Web Workers
前端
brzhang1 小时前
我觉得可以试试 TOON —— 一个为 LLM 而生的极致压缩数据格式
前端·后端·架构
小安同学iter1 小时前
SQL50+Hot100系列(11.7)
java·算法·leetcode·hot100·sql50
_dindong1 小时前
笔试强训:Week-4
数据结构·c++·笔记·学习·算法·哈希算法·散列表