宝典目录
- CRDT宝典(一): 引言
- CRDT宝典(二): 基本概念
- CRDT宝典(三): GCounter
- CRDT宝典(四): PNCounter
- CRDT宝典(五): GSet
- CRDT宝典(六): PNSet
- CRDT宝典(七): VClock
- CRDT宝典(八): LLW-Register
- CRDT宝典(九): ORSet
- CRDT宝典(十): AWORSet
- CRDT宝典(十一): Delta-state-AWORSet
- CRDT宝典(十二): DotKernel
- CRDT宝典(十三): Multi-Value-Register
背景
分布式系统中有一个可索引序列(用数组为例),每个节点均可以往序列里面插入、删除元素
请设计一个CRDT(Conflict-free Replicated Data Type)来实现一套满足最终一致性的分布式系统
为了减少要理解的概念,下文描述的CRDT同时有两层意思
- 无冲突的数据类型,即类型
- 一个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
呢?思维链如下:(里面全是伪代码?千万不要较真!)
2.2步里的插入操作,我们用B.DotKernel-Array[id]
的leftPointer
和rightPointer
在newDotKernel-Array
中找到插入位置,然后插入B.DotKernel-Array[id]
。
但是如果newDotKernel-Array
在leftPointer
和rightPointer
之间就有元素怎么办,即别的节点也在这之间添加过元素,这就是冲突,我们如何解决?
针对同一个节点的来的冲突操作,因为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。