CRDT宝典 - DotKernel

236 阅读7分钟

宝典目录

背景

除了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

whiteboard_exported_image.png

它代表着:A、B、C节点对Set添加和删除操作的最新的记录

我们在实际应用中会发现两个问题:

  1. 实际被删除的元素,会留痕,即ORSet[1]中的元素会成为墓碑元素,ORSet[1]会在使用过程中,越来越大。
  2. 元信息过大

我们从这两个问题的角度,来优化这个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",
        ]
    }
}

很清晰

  1. 元数据大小减少了很多很多
  2. 那么这种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)] = element7A.CRDT.dotContext.counter[A.id] += 1

A节点删除元素element6,遍历A.CRDT.entries,如果value === element6,则delete A.CRDT.entries[matched_key]

合并A.CRDTB.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)"]

特别说明:

  1. 第3、4点,我们没有提及任何冲突。为什么?因为强大的dot设计,我们避开冲突了,只需要将B节点中A不知道的新增操作添加进A节点,将B节点中A节点不知道的删除操作添加进A节点即可,完全没有冲突。
  2. 关于第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,各位可以总结出什么东西呢?

  1. CRDT的发散通过各自节点的添加、删除操作

  2. 不同节点的CRDT收敛通过merge函数,所以它是state-based CRDT

  3. 我们优化这类crdt的思路目前来说有两种

    3.1 基于元素的优化,比如delta

    3.2 基于操作的优化,比如DotKernel

ps:你以为这就完了吗?no no no,准备好,我们要起飞了~~~~~