无主复制

​ 我们在本章到目前为止所讨论的复制方法 ——单主复制、多主复制——都是这样的想法:客户端向一个主库发送写请求,而数据库系统负责将写入复制到其他副本。主库决定写入的顺序,而从库按相同顺序应用主库的写入。

​ 一些数据存储系统采用不同的方法,放弃主库的概念,并允许任何副本直接接受来自客户端的写入。最早的一些的复制数据系统是无领导的(leaderless)【1,44】,但是在关系数据库主导的时代,这个想法几乎已被忘却。在亚马逊将其用于其内部的Dynamo系统[^vi]之后,它再一次成为数据库的一种时尚架构【37】。 Riak,Cassandra和Voldemort是由Dynamo启发的无领导复制模型的开源数据存储,所以这类数据库也被称为Dynamo风格

[^vi]: Dynamo不适用于Amazon以外的用户。 令人困惑的是,AWS提供了一个名为DynamoDB的托管数据库产品,它使用了完全不同的体系结构:它基于单引导程序复制。

​ 在一些无领导者的实现中,客户端直接将写入发送到到几个副本中,而另一些情况下,一个协调者(coordinator)节点代表客户端进行写入。但与主库数据库不同,协调者不执行特定的写入顺序。我们将会看到,这种设计上的差异对数据库的使用方式有着深远的影响。

当节点故障时写入数据库

​ 假设你有一个带有三个副本的数据库,而其中一个副本目前不可用,或许正在重新启动以安装系统更新。在基于主机的配置中,如果要继续处理写入,则可能需要执行故障切换(参阅「处理节点宕机」)。

​ 另一方面,在无领导配置中,故障切换不存在。图5-10显示了发生了什么事情:客户端(用户1234)并行发送写入到所有三个副本,并且两个可用副本接受写入,但是不可用副本错过了它。假设三个副本中的两个承认写入是足够的:在用户1234已经收到两个确定的响应之后,我们认为写入成功。客户简单地忽略了其中一个副本错过了写入的事实。

无主复制 - 图1

图5-10 仲裁写入,法定读取,并在节点中断后读修复。

​ 现在想象一下,不可用的节点重新联机,客户端开始读取它。节点关闭时发生的任何写入都从该节点丢失。因此,如果您从该节点读取数据,则可能会将陈旧(过时)值视为响应。

​ 为了解决这个问题,当一个客户端从数据库中读取数据时,它不仅仅发送它的请求到一个副本:读请求也被并行地发送到多个节点。客户可能会从不同的节点获得不同的响应。即来自一个节点的最新值和来自另一个节点的陈旧值。版本号用于确定哪个值更新(参阅“检测并发写入”)。

读修复和反熵

​ 复制方案应确保最终将所有数据复制到每个副本。在一个不可用的节点重新联机之后,它如何赶上它错过的写入?

​ 在Dynamo风格的数据存储中经常使用两种机制:

读修复(Read repair)

​ 当客户端并行读取多个节点时,它可以检测到任何陈旧的响应。例如,在图5-10中,用户2345获得了来自Replica 3的版本6值和来自副本1和2的版本7值。客户端发现副本3具有陈旧值,并将新值写回复制品。这种方法适用于频繁阅读的值。

反熵过程(Anti-entropy process)

​ 此外,一些数据存储具有后台进程,该进程不断查找副本之间的数据差异,并将任何缺少的数据从一个副本复制到另一个副本。与基于领导者的复制中的复制日志不同,此反熵过程不会以任何特定的顺序复制写入,并且在复制数据之前可能会有显著的延迟。

​ 并不是所有的系统都实现了这两个;例如,Voldemort目前没有反熵过程。请注意,如果没有反熵过程,某些副本中很少读取的值可能会丢失,从而降低了持久性,因为只有在应用程序读取值时才执行读修复。

读写的法定人数

​ 在图5-10的示例中,我们认为即使仅在三个副本中的两个上进行处理,写入仍然是成功的。如果三个副本中只有一个接受了写入,会怎样?我们能推多远呢?

​ 如果我们知道,每个成功的写操作意味着在三个副本中至少有两个出现,这意味着至多有一个副本可能是陈旧的。因此,如果我们从至少两个副本读取,我们可以确定至少有一个是最新的。如果第三个副本停机或响应速度缓慢,则读取仍可以继续返回最新值。

​ 更一般地说,如果有n个副本,每个写入必须由w节点确认才能被认为是成功的,并且我们必须至少为每个读取查询r个节点。 (在我们的例子中,$n = 3,w = 2,r = 2$)。只要$w + r> n$,我们期望在读取时获得最新的值,因为r个读取中至少有一个节点是最新的。遵循这些r值,w值的读写称为法定人数(quorum)^vii的读和写。【44】 ,你可以认为,r和w是有效读写所需的最低票数。

​ 在Dynamo风格的数据库中,参数n,w和r通常是可配置的。一个常见的选择是使n为奇数(通常为3或5)并设置 $w = r =(n + 1)/ 2$(向上取整)。但是可以根据需要更改数字。例如,设置$w = n$和$r = 1$的写入很少且读取次数较多的工作负载可能会受益。这使得读取速度更快,但具有只有一个失败节点导致所有数据库写入失败的缺点。

集群中可能有多于n的节点。(集群的机器数可能多于副本数目),但是任何给定的值只能存储在n个节点上。 这允许对数据集进行分区,从而支持可以放在一个节点上的数据集更大的数据集。 将在第6章回到分区。

仲裁条件$w + r> n$允许系统容忍不可用的节点,如下所示:

  • 如果$w <n$,如果节点不可用,我们仍然可以处理写入。
  • 如果$r <n$,如果节点不可用,我们仍然可以处理读取。
  • 对于$n = 3,w = 2,r = 2$,我们可以容忍一个不可用的节点。
  • 对于$n = 5,w = 3,r = 3$,我们可以容忍两个不可用的节点。 这个案例如图5-11所示。
  • 通常,读取和写入操作始终并行发送到所有n个副本。 参数w和r决定我们等待多少个节点,即在我们认为读或写成功之前,有多少个节点需要报告成功。

无主复制 - 图2

图5-11 如果$w + r > n$,读取r个副本,至少有一个r副本必然包含了最近的成功写入

​ 如果少于所需的w或r节点可用,则写入或读取将返回错误。 由于许多原因,节点可能不可用:因为由于执行操作的错误(由于磁盘已满而无法写入)导致节点关闭(崩溃,关闭电源),由于客户端和服务器之间的网络中断 节点,或任何其他原因。 我们只关心节点是否返回了成功的响应,而不需要区分不同类型的错误。

仲裁一致性的局限性

​ 如果你有n个副本,并且你选择w和r,使得$w + r> n$,你通常可以期望每个读取返回为一个键写的最近的值。情况就是这样,因为你写的节点集合和你读过的节点集合必须重叠。也就是说,您读取的节点中必须至少有一个具有最新值的节点(如图5-11所示)。

​ 通常,r和w被选为多数(超过 $n/2$ )节点,因为这确保了$w + r> n$,同时仍然容忍多达$n/2$个节点故障。但是,法定人数不一定必须是大多数,只是读写使用的节点交集至少需要包括一个节点。其他法定人数的配置是可能的,这使得分布式算法的设计有一定的灵活性【45】。

​ 您也可以将w和r设置为较小的数字,以使$w + r≤n$(即法定条件不满足)。在这种情况下,读取和写入操作仍将被发送到n个节点,但操作成功只需要少量的成功响应。

​ 较小的w和r更有可能会读取过时的数据,因为您的读取更有可能不包含具有最新值的节点。另一方面,这种配置允许更低的延迟和更高的可用性:如果存在网络中断,并且许多副本变得无法访问,则可以继续处理读取和写入的机会更大。只有当可达副本的数量低于w或r时,数据库才分别变得不可用于写入或读取。

但是,即使在$w + r> n$的情况下,也可能存在返回陈旧值的边缘情况。这取决于实现,但可能的情况包括:

  • 如果使用松散的法定人数(见“松散法定人数与带提示的接力”),w个写入和r个读取落在完全不同的节点上,因此r节点和w之间不再保证有重叠节点【46】。
  • 如果两个写入同时发生,不清楚哪一个先发生。在这种情况下,唯一安全的解决方案是合并并发写入(请参阅第171页的“处理写入冲突”)。如果根据时间戳(最后写入胜利)挑选出胜者,则由于时钟偏差[35],写入可能会丢失。我们将返回“检测并发写入”中的此主题。
  • 如果写操作与读操作同时发生,写操作可能仅反映在某些副本上。在这种情况下,不确定读取是返回旧值还是新值。
  • 如果写操作在某些副本上成功,而在其他节点上失败(例如,因为某些节点上的磁盘已满),在小于w个副本上写入成功。所以整体判定写入失败,但整体写入失败并没有在写入成功的副本上回滚。这意味着如果一个写入虽然报告失败,后续的读取仍然可能会读取这次失败写入的值【47】。
  • 如果携带新值的节点失败,需要读取其他带有旧值的副本。并且其数据从带有旧值的副本中恢复,则存储新值的副本数可能会低于w,从而打破法定人数条件。
  • 即使一切工作正常,有时也会不幸地出现关于时序(timing)的边缘情况,在这种情况下,您可能会感到不安,因为我们将在第334页上的“线性化和法定人数”中看到。

因此,尽管法定人数似乎保证读取返回最新的写入值,但在实践中并不那么简单。 Dynamo风格的数据库通常针对可以忍受最终一致性的用例进行优化。允许通过参数w和r来调整读取陈旧值的概率,但把它们当成绝对的保证是不明智的。

​ 尤其是,通常没有得到“与延迟有关的问题”(读取您的写入,单调读取或一致的前缀读取)中讨论的保证,因此前面提到的异常可能会发生在应用程序中。更强有力的保证通常需要事务共识。我们将在第七章第九章回到这些话题。

监控陈旧度

​ 从运维的角度来看,监视你的数据库是否返回最新的结果是很重要的。即使应用可以容忍陈旧的读取,您也需要了解复制的健康状况。如果显著落后,应该提醒您,以便您可以调查原因(例如,网络中的问题或超载节点)。

​ 对于基于领导者的复制,数据库通常会公开复制滞后的度量标准,您可以将其提供给监视系统。这是可能的,因为写入按照相同的顺序应用于领导者和追随者,并且每个节点在复制日志中具有一个位置(在本地应用的写入次数)。通过从领导者的当前位置中减去随从者的当前位置,您可以测量复制滞后量。

​ 然而,在无领导者复制的系统中,没有固定的写入顺序,这使得监控变得更加困难。而且,如果数据库只使用读修复(没有反熵过程),那么对于一个值可能会有多大的限制是没有限制的 - 如果一个值很少被读取,那么由一个陈旧副本返回的值可能是古老的。

​ 已经有一些关于衡量无主复制数据库中的复制陈旧度的研究,并根据参数n,w和r来预测陈旧读取的预期百分比【48】。不幸的是,这还不是很常见的做法,但是将过时测量值包含在数据库的标准度量标准中是一件好事。最终的一致性是故意模糊的保证,但是对于可操作性来说,能够量化“最终”是很重要的。

松散法定人数与带提示的接力

​ 合理配置的法定人数可以使数据库无需故障切换即可容忍个别节点的故障。也可以容忍个别节点变慢,因为请求不必等待所有n个节点响应——当w或r节点响应时它们可以返回。对于需要高可用、低延时、且能够容忍偶尔读到陈旧值的应用场景来说,这些特性使无主复制的数据库很有吸引力。

​ 然而,法定人数(如迄今为止所描述的)并不像它们可能的那样具有容错性。网络中断可以很容易地将客户端从大量的数据库节点上切断。虽然这些节点是活着的,而其他客户端可能能够连接到它们,但是从数据库节点切断的客户端,它们也可能已经死亡。在这种情况下,剩余的可用节点可能会少于可用节点,因此客户端可能无法达到法定人数。

​ 在一个大型的群集中(节点数量明显多于n个),网络中断期间客户端可能连接到某些数据库节点,而不是为了为特定值组成法定人数的节点们。在这种情况下,数据库设计人员需要权衡一下:

  • 将错误返回给我们无法达到w或r节点的法定数量的所有请求是否更好?
  • 或者我们是否应该接受写入,然后将它们写入一些可达的节点,但不在n值通常存在的n个节点之间?

后者被认为是一个松散的法定人数(sloppy quorum)【37】:写和读仍然需要w和r成功的响应,但是那些可能包括不在指定的n个“主”节点中的值。比方说,如果你把自己锁在房子外面,你可能会敲开邻居的门,问你是否可以暂时停留在沙发上。

​ 一旦网络中断得到解决,代表另一个节点临时接受的一个节点的任何写入都被发送到适当的“本地”节点。这就是所谓的带提示的接力(hinted handoff)。 (一旦你再次找到你的房子的钥匙,你的邻居礼貌地要求你离开沙发回家。)

​ 松散法定人数对写入可用性的提高特别有用:只要有任何w节点可用,数据库就可以接受写入。然而,这意味着即使当$w + r> n$时,也不能确定读取某个键的最新值,因为最新的值可能已经临时写入了n之外的某些节点【47】。

​ 因此,在传统意义上,一个松散的法定人数实际上不是一个法定人数。这只是一个保证,即数据存储在w节点的地方。不能保证r节点的读取直到提示已经完成。

​ 在所有常见的Dynamo实现中,松散法定人数是可选的。在Riak中,它们默认是启用的,而在Cassandra和Voldemort中它们默认是禁用的【46,49,50】。

运维多个数据中心

​ 我们先前讨论了跨数据中心复制作为多主复制的用例(参阅“多主复制”)。无主复制还适用于多数据中心操作,因为它旨在容忍冲突的并发写入,网络中断和延迟尖峰。

​ Cassandra和Voldemort在正常的无主模型中实现了他们的多数据中心支持:副本的数量n包括所有数据中心的节点,在配置中,您可以指定每个数据中心中您想拥有的副本的数量。无论数据中心如何,每个来自客户端的写入都会发送到所有副本,但客户端通常只等待来自其本地数据中心内的法定节点的确认,从而不会受到跨数据中心链路延迟和中断的影响。对其他数据中心的高延迟写入通常被配置为异步发生,尽管配置有一定的灵活性【50,51】。

​ Riak将客户端和数据库节点之间的所有通信保持在一个数据中心本地,因此n描述了一个数据中心内的副本数量。数据库集群之间的跨数据中心复制在后台异步发生,其风格类似于多领导者复制【52】。

检测并发写入

​ Dynamo风格的数据库允许多个客户端同时写入相同的Key,这意味着即使使用严格的法定人数也会发生冲突。这种情况与多领导者复制相似(参阅“处理写入冲突”),但在Dynamo样式的数据库中,在读修复带提示的接力期间也可能会产生冲突。

​ 问题在于,由于可变的网络延迟和部分故障,事件可能在不同的节点以不同的顺序到达。例如,图5-12显示了两个客户机A和B同时写入三节点数据存储区中的键X:

  • 节点 1 接收来自 A 的写入,但由于暂时中断,从不接收来自 B 的写入。
  • 节点 2 首先接收来自 A 的写入,然后接收来自 B 的写入。
  • 节点 3 首先接收来自 B 的写入,然后从 A 写入。

无主复制 - 图3

图5-12 并发写入Dynamo风格的数据存储:没有明确定义的顺序。

​ 如果每个节点只要接收到来自客户端的写入请求就简单地覆盖了某个键的值,那么节点就会永久地不一致,如图5-12中的最终获取请求所示:节点2认为 X 的最终值是 B,而其他节点认为值是 A 。

​ 为了最终达成一致,副本应该趋于相同的值。如何做到这一点?有人可能希望复制的数据库能够自动处理,但不幸的是,大多数的实现都很糟糕:如果你想避免丢失数据,你(应用程序开发人员)需要知道很多有关数据库冲突处理的内部信息。

​ 在“处理写冲突”一节中已经简要介绍了一些解决冲突的技术。在总结本章之前,让我们来更详细地探讨这个问题。

最后写入胜利(丢弃并发写入)

​ 实现最终融合的一种方法是声明每个副本只需要存储最“最近”的值,并允许“更旧”的值被覆盖和抛弃。然后,只要我们有一种明确的方式来确定哪个写是“最近的”,并且每个写入最终都被复制到每个副本,那么复制最终会收敛到相同的值。

​ 正如“最近”的引号所表明的,这个想法其实颇具误导性。在图5-12的例子中,当客户端向数据库节点发送写入请求时,客户端都不知道另一个客户端,因此不清楚哪一个先发生了。事实上,说“发生”是没有意义的:我们说写入是并发(concurrent)的,所以它们的顺序是不确定的。

​ 即使写入没有自然的排序,我们也可以强制任意排序。例如,可以为每个写入附加一个时间戳,挑选最“最近”的最大时间戳,并丢弃具有较早时间戳的任何写入。这种冲突解决算法被称为最后写入胜利(LWW, last write wins),是Cassandra 【53】唯一支持的冲突解决方法,也是Riak 【35】中的一个可选特征。

​ LWW实现了最终收敛的目标,但以持久性为代价:如果同一个Key有多个并发写入,即使它们都被报告为客户端成功(因为它们被写入 w 个副本),但只有一个写入将存活,而其他写入将被静默丢弃。此外,LWW甚至可能会删除不是并发的写入,我们将在的“有序事件的时间戳”中讨论。

​ 有一些情况,如缓存,其中丢失的写入可能是可以接受的。如果丢失数据不可接受,LWW是解决冲突的一个很烂的选择。

​ 与LWW一起使用数据库的唯一安全方法是确保一个键只写入一次,然后视为不可变,从而避免对同一个密钥进行并发更新。例如,Cassandra推荐使用的方法是使用UUID作为键,从而为每个写操作提供一个唯一的键【53】。

“此前发生”的关系和并发

我们如何判断两个操作是否是并发的?为了建立一个直觉,让我们看看一些例子:

  • 图5-9中,两个写入不是并发的:A的插入发生在B的增量之前,因为B递增的值是A插入的值。换句话说,B的操作建立在A的操作上,所以B的操作必须有后来发生。我们也可以说B是因果依赖(causally dependent)于A
  • 另一方面,图5-12中的两个写入是并发的:当每个客户端启动操作时,它不知道另一个客户端也正在执行操作同样的Key。因此,操作之间不存在因果关系。

如果操作B了解操作A,或者依赖于A,或者以某种方式构建于操作A之上,则操作A在另一个操作B之前发生。在另一个操作之前是否发生一个操作是定义什么并发的关键。事实上,我们可以简单地说,如果两个操作都不在另一个之前发生,那么两个操作是并发的(即,两个操作都不知道另一个)【54】。

​ 因此,只要有两个操作A和B,就有三种可能性:A在B之前发生,或者B在A之前发生,或者A和B并发。我们需要的是一个算法来告诉我们两个操作是否是并发的。如果一个操作发生在另一个操作之前,则后面的操作应该覆盖较早的操作,但是如果这些操作是并发的,则存在需要解决的冲突。

并发性,时间和相对性

​ 如果两个操作“同时”发生,似乎应该称为并发——但事实上,它们在字面时间上重叠与否并不重要。由于分布式系统中的时钟问题,现实中是很难判断两个事件是否同时发生的,这个问题我们将在第8章中详细讨论。

​ 为了定义并发性,确切的时间并不重要:如果两个操作都意识不到对方的存在,就称这两个操作并发,而不管它们发生的物理时间。人们有时把这个原理和狭义相对论的物理学联系起来【54】,它引入了信息不能比光速更快的思想。因此,如果事件之间的时间短于光通过它们之间的距离,那么发生一定距离的两个事件不可能相互影响。

​ 在计算机系统中,即使光速原则上允许一个操作影响另一个操作,但两个操作也可能是并行的。例如,如果网络缓慢或中断,两个操作间可能会出现一段时间间隔,且仍然是并发的,因为网络问题阻止一个操作意识到另一个操作的存在。

捕获”此前发生”关系

​ 来看一个算法,它确定两个操作是否为并发的,还是一个在另一个之前。为了简单起见,我们从一个只有一个副本的数据库开始。一旦我们已经制定了如何在单个副本上完成这项工作,我们可以将该方法概括为具有多个副本的无领导者数据库。

图5-13显示了两个客户端同时向同一购物车添加项目。 (如果这样的例子让你觉得太麻烦了,那么可以想象,两个空中交通管制员同时把飞机添加到他们正在跟踪的区域)最初,购物车是空的。在它们之间,客户端向数据库发出五次写入:

  1. 客户端 1 将牛奶加入购物车。这是该键的第一次写入,服务器成功存储了它并为其分配版本号1,最后将值与版本号一起回送给客户端。
  2. 客户端 2 将鸡蛋加入购物车,不知道客户端 1 同时添加了牛奶(客户端 2 认为它的鸡蛋是购物车中的唯一物品)。服务器为此写入分配版本号 2,并将鸡蛋和牛奶存储为两个单独的值。然后它将这两个值反回给客户端 2 ,并附上版本号 2 。
  3. 客户端 1 不知道客户端 2 的写入,想要将面粉加入购物车,因此认为当前的购物车内容应该是 [牛奶,面粉]。它将此值与服务器先前向客户端 1 提供的版本号 1 一起发送到服务器。服务器可以从版本号中知道[牛奶,面粉]的写入取代了[牛奶]的先前值,但与[鸡蛋]的值是并发的。因此,服务器将版本 3 分配给[牛奶,面粉],覆盖版本1值[牛奶],但保留版本 2 的值[蛋],并将所有的值返回给客户端 1 。
  4. 同时,客户端 2 想要加入火腿,不知道客端户 1 刚刚加了面粉。客户端 2 在最后一个响应中从服务器收到了两个值[牛奶]和[蛋],所以客户端 2 现在合并这些值,并添加火腿形成一个新的值,[鸡蛋,牛奶,火腿]。它将这个值发送到服务器,带着之前的版本号 2 。服务器检测到新值会覆盖版本 2 [鸡蛋],但新值也会与版本 3 [牛奶,面粉]并发,所以剩下的两个是v3 [牛奶,面粉],和v4:[鸡蛋,牛奶,火腿]
  5. 最后,客户端 1 想要加培根。它以前在v3中从服务器接收[牛奶,面粉]和[鸡蛋],所以它合并这些,添加培根,并将最终值[牛奶,面粉,鸡蛋,培根]连同版本号v3发往服务器。这会覆盖v3[牛奶,面粉](请注意[鸡蛋]已经在最后一步被覆盖),但与v4[鸡蛋,牛奶,火腿]并发,所以服务器保留这两个并发值。

无主复制 - 图4

图5-13 捕获两个客户端之间的因果关系,同时编辑购物车。

图5-13中的操作之间的数据流如图5-14所示。 箭头表示哪个操作发生在其他操作之前,意味着后面的操作知道或依赖于较早的操作。 在这个例子中,客户端永远不会完全掌握服务器上的数据,因为总是有另一个操作同时进行。 但是,旧版本的值最终会被覆盖,并且不会丢失任何写入。

无主复制 - 图5

图5-14 图5-13中的因果依赖关系图。

​ 请注意,服务器可以通过查看版本号来确定两个操作是否是并发的——它不需要解释该值本身(因此该值可以是任何数据结构)。该算法的工作原理如下:

  • 服务器为每个键保留一个版本号,每次写入键时都增加版本号,并将新版本号与写入的值一起存储。
  • 当客户端读取键时,服务器将返回所有未覆盖的值以及最新的版本号。客户端在写入前必须读取。
  • 客户端写入键时,必须包含之前读取的版本号,并且必须将之前读取的所有值合并在一起。 (来自写入请求的响应可以像读取一样,返回所有当前值,这使得我们可以像购物车示例那样连接多个写入。)
  • 当服务器接收到具有特定版本号的写入时,它可以覆盖该版本号或更低版本的所有值(因为它知道它们已经被合并到新的值中),但是它必须保持所有值更高版本号(因为这些值与传入的写入同时发生)。

当一个写入包含前一次读取的版本号时,它会告诉我们写入的是哪一种状态。如果在不包含版本号的情况下进行写操作,则与所有其他写操作并发,因此它不会覆盖任何内容 —— 只会在随后的读取中作为其中一个值返回。

合并同时写入的值

​ 这种算法可以确保没有数据被无声地丢弃,但不幸的是,客户端需要做一些额外的工作:如果多个操作并发发生,则客户端必须通过合并并发写入的值来擦屁股。 Riak称这些并发值兄弟(siblings)

​ 合并兄弟值,本质上是与多领导者复制中的冲突解决相同的问题,我们先前讨论过(参阅“处理写入冲突”)。一个简单的方法是根据版本号或时间戳(最后写入胜利)选择一个值,但这意味着丢失数据。所以,你可能需要在应用程序代码中做更聪明的事情。

​ 以购物车为例,一种合理的合并兄弟方法就是集合求并。在图5-14中,最后的两个兄弟是[牛奶,面粉,鸡蛋,熏肉]和[鸡蛋,牛奶,火腿]。注意牛奶和鸡蛋出现在两个,即使他们每个只写一次。合并的价值可能是像[牛奶,面粉,鸡蛋,培根,火腿],没有重复。

​ 然而,如果你想让人们也可以从他们的手推车中删除东西,而不是仅仅添加东西,那么把兄弟求并可能不会产生正确的结果:如果你合并了两个兄弟手推车,并且只在其中一个兄弟值里删掉了它,那么被删除的项目会重新出现在兄弟的并集中【37】。为了防止这个问题,一个项目在删除时不能简单地从数据库中删除;相反,系统必须留下一个具有合适版本号的标记,以指示合并兄弟时该项目已被删除。这种删除标记被称为墓碑(tombstone)。 (我们之前在“哈希索引”中的日志压缩的上下文中看到了墓碑。)

​ 因为在应用程序代码中合并兄弟是复杂且容易出错的,所以有一些数据结构被设计出来用于自动执行这种合并,如“自动冲突解决”中讨论的。例如,Riak的数据类型支持使用称为CRDT的数据结构家族【38,39,55】可以以合理的方式自动合并兄弟,包括保留删除。

版本向量

图5-13中的示例只使用一个副本。当有多个副本但没有领导者时,算法如何修改?

图5-13使用单个版本号来捕获操作之间的依赖关系,但是当多个副本并发接受写入时,这是不够的。相反,除了对每个键使用版本号之外,还需要在每个副本中使用版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些值,以及保留哪些值作为兄弟。

​ 所有副本的版本号集合称为版本向量(version vector)【56】。这个想法的一些变体正在使用,但最有趣的可能是在Riak 2.0 【58,59】中使用的分散版本矢量(dotted version vector)【57】。我们不会深入细节,但是它的工作方式与我们在购物车示例中看到的非常相似。

​ 与图5-13中的版本号一样,当读取值时,版本向量会从数据库副本发送到客户端,并且随后写入值时需要将其发送回数据库。 (Riak将版本向量编码为一个字符串,它称为因果上下文(causal context))。版本向量允许数据库区分覆盖写入和并发写入。

​ 另外,就像在单个副本的例子中,应用程序可能需要合并兄弟。版本向量结构确保从一个副本读取并随后写回到另一个副本是安全的。这样做可能会创建兄弟,但只要兄弟姐妹合并正确,就不会丢失数据。

版本向量和向量时钟

​ 版本向量有时也被称为矢量时钟,即使它们不完全相同。 差别很微妙——请参阅参考资料的细节【57,60,61】。 简而言之,在比较副本的状态时,版本向量是正确的数据结构。