宝典目录
- 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
背景
除了Delta形式的优化,ORSet还有别的优化方式,准备好,更难的要来了!!!
节点B中的ORSet可能如下
A.ORSet = [
{
'element1': {
[A.id]: 2,
[B.id]: 1,
},
'element2': {
[A.id]: 3,
[B.id]: 3,
},
'element3': {
[A.id]: 2,
[B.id]: 4,
},
},
{
'element2': {
[A.id]: 5,
[B.id]: 2,
[C.id]: 1,
},
}
]
我们用图片来表示这个ORSet
它代表着:A、B、C节点对Set添加和删除操作的最新的记录
我们在实际应用中会发现两个问题:
- 实际被删除的元素,会留痕,即ORSet[1]中的元素会成为墓碑元素,ORSet[1]会在使用过程中,越来越大。
- 元信息过大
我们从这两个问题的角度,来优化这个ORSet
思维链
flowchart
A["B节点自身对Set产生的操作的序列肯定是`有序且递增`的,\n但是因为是支持弱网的分布式,其操作传播出去后,在不同节点上,可能存在`乱序`的情况"]
A --> B["也就是说,B节点对ORSet的操作序列在A节点的ORSet中,其序列可能是1,2,3,6,8,10..."]
B --> C["操作 = 元素 + 元数据,然后我们发现了一点,1,2,3,6,8,10...中,1和2操作的元素是有意义的,\n但是其元数据是没意义的,\n因为A节点把序列:3的操作应用了"]
C --> D["不难得出,B节点的操作序列在别的节点中 = 有序操作序列 + 离散操作序列"]
D --> E["与ORSet聚焦`元素的状态`不同,上面的分析都是聚焦在操作的"]
E --> F["我们尝试从操作序列的角度重新构建这个ORSet"]
某节点对集合的一个操作 = 元素 + 节点Id + 操作序列,我们用Dot来代表这个节点对集合的一个操作的序列
let aDot<[string, number]> = [A.id, counter]
然后用Map<Dot, 元素>来表示某节点对集合的一个操作,很明显,它包含了操作的元素,以及操作的序列
所以B节点对Set的所有操作可以表示为如下的结构
B.entries = {
[B.id + 1]: element1,
[B.id + 2]: element2,
[B.id + 3]: element3,
[B.id + 4]: element4,
[B.id + 5]: element5,
[B.id + 6]: element6
}
这是节点B看自身对Set的操作,但是这是支持弱网的分布式,所以节点A看节点B对Set的操作往往并非是+1式递增的,它可能如下
A.entries = {
[B.id + 1]: element1,
[B.id + 2]: element2,
[B.id + 3]: element3,
[B.id + 6]: element4,
[B.id + 8]: element5,
[B.id + 10]: element6,
[A.id + 1]: element1,
[A.id + 2]: element2,
[A.id + 3]: element3,
[A.id + 4]: element4,
[A.id + 5]: element5,
[A.id + 6]: element6
}
这个CRDT需要能表达所有节点对Set的添加、删除的操作。很明显,基于操作的分析,我们需要一个数据类型(起名为dotContext)来表达各个节点对Set的操作的序列(连续序列 + 离散序列),如下
// A节点的dotContext
A.dotContext: {
counter: GCounter,
dots: Dot[]
} = {
// counter代表不同节点对Set的连续操作序列的最大值序号
counter: {
[A.id]: 2,
[B.id]: 3,
[C.id]: 1,
},
// dots代表离散的操作序列
dots: [
"B.id + 6",
"B.id + 8",
"B.id + 10",
]
}
entries代表所有节点对Set的操作,dotContext代表所有节点对Set的操作的序列
我们组合entries和dotContext,我们就能得到一个全新的CRDT
// 组合entries和dotContext
A.CRDT:{
entries:Record<string, T>,
dotContext:dotContext
} = {
entries: {
[B.id + 1]: element1,
[B.id + 2]: element2,
[B.id + 3]: element3,
[B.id + 6]: element4,
[B.id + 8]: element5,
[B.id + 10]: element6,
[A.id + 1]: element1,
[A.id + 2]: element2,
[A.id + 3]: element3,
[A.id + 4]: element4,
[A.id + 5]: element5,
[A.id + 6]: element6
},
dotContext: {
counter: {
[A.id]: 2,
[B.id]: 3,
[C.id]: 1,
},
dots: [
"B.id + 6",
"B.id + 8",
"B.id + 10",
]
}
}
很清晰
- 元数据大小减少了很多很多
- 那么这种CRDT是如何减少墓碑数据的,解释如下
当A节点想要删除element1时,遍历A.CRDT.entries,删除value === element1的entry。即dotContext中存在这个dot,但是entries不存在这个操作,即代表这个元素被删除了,通过这个逻辑实现了删除操作且大幅度减少了墓碑数据。
实现
我们暂时不给这个CRDT起名,就叫CRDT
// A节点的数据副本
A.CRDT = {
entries: {
[B.id + 1]: element1,
[B.id + 2]: element2,
[B.id + 3]: element3,
[B.id + 6]: element4,
[B.id + 8]: element5,
[B.id + 10]: element6,
[A.id + 1]: element1,
[A.id + 2]: element2,
[A.id + 3]: element3,
[A.id + 4]: element4,
[A.id + 5]: element5,
[A.id + 6]: element6
},
dotContext: {
counter: {
[A.id]: 6,
[B.id]: 3,
[C.id]: 1,
},
dots: [
"B.id + 6",
"B.id + 8",
"B.id + 10",
]
}
}
// B节点的数据副本
B.CRDT = {
entries: {
[A.id + 1]: element1,
[A.id + 2]: element2,
[A.id + 3]: element3,
[A.id + 6]: element4,
[A.id + 8]: element5,
[A.id + 10]: element6,
[B.id + 1]: element1,
[B.id + 2]: element2,
[B.id + 3]: element3,
[B.id + 4]: element4,
[B.id + 5]: element5,
[B.id + 6]: element6
},
dotContext: {
counter: {
[A.id]: 3,
[B.id]: 6,
[C.id]: 1,
},
dots: [
"A.id + 6",
"A.id + 8",
"A.id + 10",
]
}
}
A节点往Set中添加元素element7,很明显A.CRDT.entries[A.id + (A.CRDT.dotContext.counter[A.id] + 1)] = element7,A.CRDT.dotContext.counter[A.id] += 1
A节点删除元素element6,遍历A.CRDT.entries,如果value === element6,则delete A.CRDT.entries[matched_key]
合并A.CRDT和B.CRDT,思维链如下:
flowchart
A["1. 合并entries,(entries代表各个节点对Set的添加、删除操作)"]
A --> B["2. new entries(A.CRDT.entries)"]
B --> C["3. 遍历B.CRDT.entries,如果A.CRDT.dotContext.dot中不存在这个点,并且A.CRDT.entries中不存在这个点,\n则代表A节点不知道这个添加操作,我们将这个添加操作应用在new entries中"]
C --> D["4. 遍历new entries,如果B.CRDT.entries中不存在这个点,但是B.CRDT.dotContext.dot中存在这个点,\n则代表B节点删除过这个点代表的元素,我们将这个删除操作应用在new entries中"]
D --> E["5. 合并dotContext"]
E --> F["6. dotContext.counter是一个GCounter,\n直接就是GCounter.merge(A.CRDT.dotContext.counter, B.CRDT.dotContext.counter)"]
F --> G["7. dotContext.dots是一个Set,直接Set.merge(A.CRDT.dotContext.dots, B.CRDT.dotContext.dots)"]
特别说明:
- 第3、4点,我们没有提及任何
冲突。为什么?因为强大的dot设计,我们避开冲突了,只需要将B节点中A不知道的新增操作添加进A节点,将B节点中A节点不知道的删除操作添加进A节点即可,完全没有冲突。 - 关于第6、7点,dotContext由连续的序列和离散的序列组成,当我们增加一个操作时,·、
dotContext.counter[A.id] + 1,同时离散的序列中元素可能不再是离散的,而是并入到连续序列中,比如
A.CRDT.dotContext= {
counter: {
[A.id]: 6,
[B.id]: 4,
[C.id]: 1,
},
dots: [
"B.id + 6",
"B.id + 8",
"B.id + 10"
]
}
当我们从别的节点合并CRDT时,合并了一个操作:[B.id + 5],此时的counter[B.id] = 6,而不是5,因为dots中存在B.id + 6。也就是说存在一个compact函数将离散的序列并入到连续的序列中。
代码实现如下
A.CRDT = {
entries: {
[B.id + 1]: element1,
[B.id + 2]: element2,
[B.id + 3]: element3,
[B.id + 6]: element4,
[B.id + 8]: element5,
[B.id + 10]: element6,
[A.id + 1]: element1,
[A.id + 2]: element2,
[A.id + 3]: element3,
[A.id + 4]: element4,
[A.id + 5]: element5,
[A.id + 6]: element6
},
dotContext: {
counter: {
[A.id]: 6,
[B.id]: 3,
[C.id]: 1,
},
dots: [
"B.id + 6",
"B.id + 8",
"B.id + 10",
]
}
}
B.CRDT = {
entries: {
[A.id + 1]: element1,
[A.id + 2]: element2,
[A.id + 3]: element3,
[A.id + 6]: element4,
[A.id + 8]: element5,
[A.id + 10]: element6,
[B.id + 1]: element1,
[B.id + 2]: element2,
[B.id + 3]: element3,
[B.id + 4]: element4,
[B.id + 5]: element5,
[B.id + 6]: element6
},
dotContext: {
counter: {
[A.id]: 3,
[B.id]: 6,
[C.id]: 1,
},
dots: [
"A.id + 6",
"A.id + 8",
"A.id + 10",
]
}
}
// 添加元素
// A节点添加element7
const add = (CRDT, element) => {
CRDT.entries[CRDT.id + (CRDT.dotContext.counter[CRDT.id] + 1)] = element;
CRDT.dotContext.counter[CRDT.id] += 1;
}
// 删除元素
// A节点删除element6
const delete = (CRDT, element) => {
for (let key in CRDT.entries) {
if (CRDT.entries[key] === element) {
delete CRDT.entries[key];
}
}
}
// 合并CRDT
const merge = (A, B) => {
// 1. 合并entries
const newEntries = { ...A.CRDT.entries };
// 2. 将B节点中A节点不知道的添加操作添加到newEntries中
for (let key in B.CRDT.entries) {
// 如果A的dotContext.dot中不存在这个点,并且A的entries中不存在这个点
// 则代表A节点不知道这个添加操作
if (!A.CRDT.dotContext.dot.includes(key) && !A.CRDT.entries[key]) {
newEntries[key] = B.CRDT.entries[key];
}
}
// 3. 将B节点删除的操作应用在newEntries中
for (let key in newEntries) {
// 如果B的entries中不存在这个点,但是B的dotContext.dot中存在这个点
// 则代表B节点删除过这个点代表的元素
if (!B.CRDT.entries[key] && B.CRDT.dotContext.dot.includes(key)) {
delete newEntries[key];
}
}
// 4. 合并dotContext
const newDotContext = {
// counter是一个GCounter,直接合并
counter: {
...A.CRDT.dotContext.counter,
...B.CRDT.dotContext.counter
},
// dot是一个Set,直接合并
dot: new Set([...A.CRDT.dotContext.dot, ...B.CRDT.dotContext.dot])
};
// 5. 代码很简单,我懒得写了
newDotContext.compact()
return {
entries: newEntries,
dotContext: newDotContext
};
}
我们得给这个CRDT起个名字,既然是基于dot的,我们将dot用DotKernel来表示,就叫DotKernel吧
QA
问:为什么添加操作之后不需要compact
答:因为自身节点产生的操作序列是连续的,不存在离散序列,所以不需要compact。当然你添加了也没关系。
问:这是基于ORset的优化,那基于AWORSet的优化是什么样的
答:你发现没,我们拥有了操作序列,自然你就知道同一个元素的添加、删除操作的先后顺序,自然就拥有了AWORSet。(浑然天成,代码我就不给了,嘿嘿)
问:可以和delta的理念合并吗?
答:可以的,delta是可以直接应用于state-based CRDT的。他结构如下
A.Delta-DotKernel-AWORSet:{
values:DotKernel,
delta:DotKernel
} = {
values: {
entries: {
[B.id + 1]: element1,
[B.id + 2]: element2,
[B.id + 3]: element3,
[B.id + 6]: element4,
[B.id + 8]: element5,
[B.id + 10]: element6,
[A.id + 1]: element1,
[A.id + 2]: element2,
[A.id + 3]: element3,
[A.id + 4]: element4,
[A.id + 5]: element5,
[A.id + 6]: element6
},
dotContext: {
counter: {
[A.id]: 6,
[B.id]: 3,
[C.id]: 1,
},
dots: [
"B.id + 6",
"B.id + 8",
"B.id + 10",
]
}
},
delta: {
entries: {
[A.id + 6]: element6
},
dotContext: {
counter: {
[A.id]: 6,
},
dots: []
}
}
}
总结
集合DotKernel的理念 + delta的理念 ,我们实现了一个高度优化的CRDT,它不仅减少了元数据的大小,还减少了墓碑数据的大小。
学了这么多state-based CRDT,各位可以总结出什么东西呢?
-
CRDT的发散通过各自节点的添加、删除操作
-
不同节点的CRDT收敛通过merge函数,所以它是state-based CRDT
-
我们优化这类crdt的思路目前来说有两种
3.1 基于元素的优化,比如delta
3.2 基于操作的优化,比如DotKernel
ps:你以为这就完了吗?no no no,准备好,我们要起飞了~~~~~