在本系列的第一部分中,我们考察了order理论的基础知识,以便探究 join semi-lattice 的概念,这是Convergent CRDT(或CvRDT)的基础。如果您还没有阅读过上篇文章,我强烈建议在继续本文之前阅读上一篇,因为我们将在此基础上继续。在这篇文章中,我们将详细介绍CvRDTs,在实现一个简单的增长分布计数器示例之前,首先介绍它们的工作原理。
Convergent CRDTs
Convergent CRDT(或CvRDT)是复制的数据结构,合并后会趋于一个值。我们需要牢记 CvRDT 的两个基本组成部分。首先,我们有state.我们将所有可能的状态视为一个集合的元素。对于 CvRDT,该集合必须按某种二元关系排序。例如,想象一下,我们的状态是一个计数器。简单来看,可以将我们计数器的所有可能状态视为所有整数,并且我们的顺序小于或等于这些整数。
CvRDT 的另一个关键组成是 merge() 函数。CvRDTs 的全部重点是复制节点之间的状态。我们需要一个合并函数来最终保持该状态同步。 对于 CvRDT,合并函数充当我们秩序的 join 。
如果我们看一些例子就会更清楚。回想一下,对于全部 order,任何两个元素的 join 将成为这两个元素中的一个。对于小于或等于整数,join 总是两个整数中较大的一个。这意味着相应的合并函数将是 max() 。这里有一些例子:
merge(1,3)=3
merge(9,5)=9
merge(8,8)=8
在每个示例中,两个整数之间的最大值是这些整数的最小上限值。
现在想象一下,我们的状态集不是整数,而是矢量时钟时间戳。在这种情况下,我们可以使用坐标最大值作为我们的合并函数。这里有一些例子:
merge((1,0,0),(0,1,1))=(1,1,1)
merge((0,0,0),(2,0,2))=(2,0,2)
merge((5,3,1),(1,9,2))=(5,9,2)
最后,思考一下我们的状态集合是 location 并且我们的 order 是located-in。在这种情况下,我们可以用“least-common-enclosing-location”作为我们的合并函数。通过least-common-enclosing-location,我只是指包含合并的两个位置的最小位置。这里有一些例子:
merge(Seattle,Mumbai)=Earth
merge(Bronx,NYC)=NYC
merge(Mumbai,Delhi)=India
如果这一点不清楚,也不要担心。我们将继续以下这些相同的例子。但要记住的重要一点是,在定义 CvRDT 时,我们必须确定一组 state,一个关系 ≤ 表示该集合的顺序,以及一个合并函数,该函数就像该顺序的 join 一样。
Systems
在得到 CvRDTs 之前,引入一些概念将有助于我们更好地理解它们。首先,我们称一组可用的states 为 System 。下面的例子,是我们到目前为止讨论过的 state:
[2,5,7]
[Seattle,Delhi,Mumbai]
[(0,0,1),(1,0,0),(1,1,0)]
区分当前状态集(我称之为Systems)和 background sets 很重要。在上面的第一个例子中,我们的系统由三个整数组成,2 , 5 和 7 。我们的 background sets是所有整数的集合。为了清楚这一点,我将从现在开始讨论 Systems 和 background sets。
以下是一个有趣的事实:对于具有合并操作的任何 states 系统,为系统中的所有对定义充当 join,我们可以绘制 join semi-lettice。让我们依次看看我们的三个示例系统。对于三个整数的系统,我们可以画出下图:
回想一下,对于图中的任何两个元素成为 join semi-lattice,我们都可以找到它们两个的最小值。在这种情况下,任何两个元素可以通过小于或等于直接相关,并且 join 只是两者中的最大值。对于一个更有趣的例子,我们需要看看分序,就像 located-in 一样。所以我们来看看三个位置的系统。如果您单独绘制了这三个位置的图表,它将如下所示:
这些元素在定位方面都不能直接相互比较。所以如果这个系统也是整个 background sets,那么我们就不能画出一个 semi-lattice 。幸运的是,我们在这里设置的background sets是一组更大的locations(如第一部分所定义)。所以我们可以通过逐步地取元素对的 join 来把这个图转换成semi-lattice 。我们稍后会强调,我们接受这些连接的顺序并不重要。因此,我们开始在图表中添加孟买和德里的连接:
注意,我们正在从 background sets 中获取一个原本不属于我们系统的部分,并将其添加到我们的图中。如印度,我标记了它不同的颜色,以表明它实际上不是我们系统的一部分。
现在我们继续选择另一对,西雅图和孟买。Join 是地球:
最后,让我们 join 西雅图和印度。好吧,这又是地球,这意味着我们不再在图上增加任何locations,但我们可以添加从印度到地球的新箭头:
现在,如果仔细观察我们的图表,就会发现无论选择哪两个元素,都可以在图表中找到 join。这是因为地球是图中所有其他东西的最大值,所以我们至少可以达到上限。
只要我们的 background sets 和我们的≤关系形成 join semi-lattice,那么对于任何系统,我们总是可以通过从 background sets 选取元素通过 join 来绘制相应的 semi-lattice 。这引出我们接下来一个重要概念。我们将系统的 Value 定义为相应 semi-lattice 的最大值。以下是一些考虑系统 Value 的例子:
Value([2,5,7])=7
Value([Seattle,Delhi,Mumbai])=Earth
Value([(0,0,1),(1,0,0),(1,1,0)])=(1,1,1)
关键在于系统中的 states 在合并它们时汇集成系统的 value 。设想从系统中随机选择一对状态并合并它们,每次将合并结果添加到系统中。这个过程最终应该将 Value 加到系统中。现在,每个合并都起到 join 作用。join 的属性确保了两件重要的事情:
1.合并顺序无关紧要。这由 join 的结合性和交换性来保证。
2.不管重复多少次特定的合并都不重要。这由 join 的幂等性保证。
Implementing a CvRDT
我们现在有实现首个 CvRDT 所需的一切条件。将系统与节点网络相对应,每个节点都包含其自己的全局 state 版本。如,这是一个与上面的整数系统相对应的网络:
如果我们的节点随机地传递 states,合并任何进入的 states,它们都会趋向于系统的 value。在这种情况下,我们系统的 value 是 5.这是因为 5 是系统中三种状态的最大值。当我们从节点到节点之间传递整数时,我们通过将状态更新为本地整数和传入整数的最大值来执行合并。下面的动画应该有助于明确这一点:
CvRDT 的伟大之处在于允许我们抽象出这些网络/系统细节。我们可以来实施一个计数器来说明这个想法。
我们的计数器有一个简单的界面:
increment():增加计数器
value():获取计数器的值
我们希望将这个计数器复制到三个节点上。其思想是,用户能够与这三个节点中的任何一个进行交互,但只要它们仍然连接到同一个节点,就会看到一致的结果。此外,我们需要这些节点随着时间的推移保持同步(最终)。
抽象地说,当用户在任何三个节点上调用 increment(),它都应该增加我们系统的 value 。这是因为我们的复制计数器跟踪所有用户的所有 increment() 调用。这说明我们可以绘制系统如下图,对系统作为一个整体进行抽象地调用(即使实际上它们总是对特定节点进行调用):
我们的系统的起始值是 0.现在想象一下,increment()在节点 X 上调用一次,在节点 Y 上调用两次,在节点 Z 上调用三次。该值应该等于 6。但是请记住关于系统 value 的重要一点:该值不一定存在于任何一个节点上。相反,它是我们 join semi-lattice 的最大值。也就是说,这是我们合并收敛的价值。
其主旨是,value 将最终反映在所有节点。除了传递状态之外,不需要任何协调。合并顺序无关紧要。而且我们重复特定合并的次数也不重要。这意味着我们就可以在方便的时候传递状态。没有必要追溯到过去发生的合并或发生的顺序。
试着执行我们的计数器。我们将从所谓的 G-counter 开始,也就是增长计数器。我们的界面只是 increment() 和 value() 。 回想一下,我们需要两件事情:
1. states 类型 S 按某种≤关系排序。
2. merge() 操作,作为我们的 order join
每个节点将有一个 value,我们将称之为 local state,表示该节点对系统当前 value 的记录。我们需要能够通过该节点上的 increment()调用来更新 local state 。而且我们还需要能够通过该节点上的 value() 调用读取 local state。首先,我们将简单地将本地状态表示为一个整数。
现在我们需要考虑 merge()。请记住,节点可以随时从另一个节点接收 states 。它从哪个节点接收并不重要,它是否已经被接收到相同的状态,也不重要。无论如何,我们的 local state 应该总是趋于系统的 value,在本例中,这个值就是系统中任何地方调用的 increment() 的总次数。
merge() 的一个简单实现是将传入状态的值添加到我们的本地值中。 但这种做法肯定会失败。问题是添加不是幂等的。如果我们合并5次,我们会继续在当地总计中加5。这意味着我们很快就会超越系统的价值。merge() 的这种实现不作为 join 。
在我们上一篇文章中,我们看到整数上的 max() 充当 join 。因此,另一种简单的做法是考虑到达的state和我们 local state。但想象下面的历史:
1.节点1增加3次。
2.节点2增加2次。
3.节点3增加1次。
在所有这些调用之后,系统的价值是什么?由于该值是我们网络中任何地方被调用的增量总次数,因此这里的答案看起来应该是 6.下面的动画展示了如果我们在整数上使用 max() 作为我们的合并函数会发生什么:
从我们的系统中的三个整数开始:3, 2 和 1.不管我们在任意随机选择的对之间调用 max() 多少次,我们都不会得到高于3的值。但是,我们应该汇集的值是 6。我们需要再试一次。
事实证明,我们需要区分作为 semi-lattice 的系统 value 和与该值对应的人类可读值。通过寻找一个更好的方法来表示我们的计数器状态,而不是简单地使用整数,这是一种借鉴矢量时钟的方法,这种区别将更加清晰。
我们不使用整数作为 local state,而是使用整数向量。向量中的每个元素都对应一个节点。因此,在我们的最后一个例子中,我们将从以下分布的 local state 开始:
X: (3, 0, 0)
Y: (0, 2, 0)
Z: (0, 0, 1)
这一次,为了合并传入的值,我们采用了坐标最大值。我们将 value()作为向量中所有元素的总和。以下动画演示了在这种情况下会发生什么情况:
每个节点逐渐获取其他节点的最新值。在这里,取一个坐标的最大值实际上是取这个坐标的最新值。我们的系统的价值是(3,2,1),并且在一个节点上调用value() 的人类可读结果可以达到 6。
现在我们已经有了一个工作实现,让我们来定义我们的接口操作:
increment():递增该节点对应的向量索引处的整数。
value() :向量中的所有整数求和。
merge(incoming_state):用 local state 的最大坐标值和 incoming_state 代替 local state。
我们来画一下刚才考虑的系统 semi-lattice :
我们看到对应于系统值的向量是 semi-lattice 的最大值。我们的 merge() 函数完全对应于这些元素中的任何两个元素上的 join 操作。这些连接向上收敛。
您可以亲自验证,我们采用哪个顺序并不重要。如果我们多次合并相同的值,这也无关紧要。实质上,我们忘记了图中较低的值,并且要么保持我们的位置,要么移动到更高的位置。
如果你希望看到代码,下面是一个在 Python 中实现为简单可变数据结构的 G-Counter 示例(本地状态表示为一个名为 state_list 的整数列表):
class GCounter:
def __init__(self, nodeId, state_list):
self.nodeId = nodeId
self.state_list = state_list
def value(self):
return sum(self.state_list)
def increment(self):
self.state_list[self.nodeId] += 1
def merge(self, incoming):
for idx in range(0, len(self.state_list)):
self.state_list[idx] = max(self.state_list[idx],
incoming.state_list[idx])
Conclusion
还有许多其他类型的数据结构可以建模为收敛CRDT。您可以拥有计数器,集,映射和图表。在每种情况下,我们都需要先定义一个 value() 方法和 merge() 方法。也许在未来的文章中,我们将看看如何实现其中的一些。
同时,如果您想更深入地了解 CRDT 背后的理论(包括基于操作的 CRDT,我们还没有讨论过),请查看收敛性和交换性复制数据类型的综合研究 Marc Shapiro,Nuno Preguiça,Carlos Baquero和Marek Zawirski。