本章目标: 数据远程复制。
复制主要指通过互联网络在多台机器上保存相同数据的副本。通过数据复制方案,人们希望达到以下目的:
如果复制的数据一成不变,那么复制就非常容易:只需将数据复制到每个节点,一次即可搞定。然而所有的技术挑战都在于处理那些持续更改的数据,而这正是本章讨论的核心。我们将讨论三种流行的复制数据变化的方法:主从复制、多主节点复制和无主节点复制。 几乎所有的分布式数据库都使用上述方法中的某一种,而三种方法各有优缺点。
复制技术存在许多需要这种考虑的地方,例如采用同步复制还是异步复制,以及如何处理失败的副本等。数据库通常采用可配置选项来调整这些处理策略,虽然在处理细节方面因数据库实现而异,但存在一些通用的一般性原则。
数据库复制其实是个很古老的话题。因为网络的基本约束条件没有发生本质的改变,可以说自1970年所研究的基本复制原则,时至今日也没有发生太大的变化。在复制滞后问题中,会详细讨论最终一致性,包括读自己的写和单调读等。
每个保存数据库完整数据集的节点称之为副本。如何确保所有副本之间的数据是一致的?
主从复制的工作原理如下:
主从复制技术也不仅限于数据库,还广泛用于分布式消息队列如Kafka和RabbitMQ,以及一些网络文件系统和复制快设备(如DRBD)
复制非常重要的一个设计选项是同步复制还是异步复制。对于关系数据库系统,同步或异步通常是一个可配置的选项;而其他系统则可能是硬性指定或者只能二选一。
同步复制和异步复制都存在各自的优缺点,由此衍生处许多其他的复制策略,比如半同步复制,链式复制是同步复制的一种变体,往往要在复制性能和系统可用性两者中进行折中。多副本一致性与共识之间有着密切的联系(即让多个节点对数据状态达成一致)
当如果出现如下情况时,如需要增加副本数以提高容错能力,或者替换失败的副本,就需要考虑增加新的从节点。但如何确保新的从节点和主节点保持数据一致呢?
我们可以做到在不停机、数据服务不中断的前提下完成从节点的设置。逻辑上的主要操作步骤如下,建立新的从副本具体操作步骤可能因数据库系统而异:
系统中的任何节点都可能因故障或者计划内的维护(例如重启节点以安装内核安全补丁)而导致中断甚至停机。我们的目标是,尽管个别节点会出现中断,但要保持系统总体的持续运行,并尽可能减小节点中断带来的影响。
那么如何通过主从复制技术来实现系统高可用呢?
从节点的本地磁盘上都保存了副本收到的数据变更日志。如果从节点发生崩溃,然后顺利重启,或者主从节点之间的网络发生暂时中断(闪断),则恢复比较容易,根据副本的复制日志,从节点可以知道在发生故障之前所处理的最后一笔事务,然后连接到主节点,并请求自那笔事务之后中断期间内所有的数据变更。在收到这些数据变更日志之后,将其应用到本地来追赶主节点。之后就和正常情况一样持续接收来自主节点数据流的变化。
处理主节点故障的情况则比较棘手:选择某个从节点将其提升为主节点;客户端也需要更新,这样之后的写请求会发送给新的主节点,然后其他从节点要接受来自新的主节点上的变更数据,这一过程称之为切换。
自动切换的步骤通常如下:
然而,上述切换过程依然充满了很多变数:
上述这些问题,包括节点失效、网络不可靠、副本一致性、持久性、可用性与延迟之间各种细微的权衡,实际上正是分布式系统核心的基本问题,对于这些问题没有简单的解决方案。
主从复制技术有着多种不同的实现方法。
最简单的情况,主节点记录所执行的每个写请求(操作语句)并将该操作语句作为日志发送给从节点。听起来很合理也不复杂,但这种复制方式有一些不适用的场景:
有可能采取一些特殊措施来解决这些问题,例如,主节点可以在记录操作语句时将非确定性函数替换为执行之后的确定的结果,这样所有节点直接适用相同的结果值。但是,这里面存在太多边界条件需要考虑,因此目前通常首选的是其他复制实现方案。
关于存储引擎的磁盘数据结构,通常每个写操作都是以追加写的方式写入到日志中:
PostgreSQL、Oracle以及其它系统等支持这种复制方式。其主要缺点是日志描述的数据结果非常底层:一个WAL包含了哪些磁盘块的哪些字节发生改变,诸如此类的细节。这使得复制方案和存储引擎紧密耦合。如果数据库的存储格式从一个版本改为另一个版本,那么系统通常无法支持主从节点上运行不同版本的软件。
另一种方法是复制和存储引擎采用不同的日志格式,这样复制与存储逻辑剥离。这种复制日志称为逻辑日志,以区分物理存储引擎的数据表示。
关系数据库的逻辑日志通常是指一系列记录来描述数据表行级别的写请求:
如果一条事务涉及多行的修改,则会产生多个这样的日志记录,并在后面跟着一条记录,指出该事务已经提交。
由于逻辑日志与存储引擎逻辑解耦,因此可以更容易地保持向后兼容,从而使主从节点能够运行不同版本的软件甚至是不同的存储引擎。
对于外部应用程序来说,逻辑日志格式也更容易解析。如果要将数据库的内容发送到外部系统(如用于离线分析的数据仓库),或构建自定义索引和缓存等,基于逻辑日志的复制更有优势。该技术也被称为变更数据捕获。
到目前为止所描述的复制方法都是由数据库系统来实现的,不涉及任何应用程序代码。通常这是大家所渴望的,不过,在某些情况下,我们可能需要更高的灵活性。例如,只想复制数据的一部分,或者想从一种数据库复制到另一种数据库,或者需要订制、管理冲突解决逻辑(“处理写冲突”),则需要将复制控制交给应用程序层。
有一些工具,例如Oracle GoldenGate,可以通过读取数据库日志让应用程序获取数据变更。另一种方法则是借助许多关系数据库都支持的功能:触发器和存储过程。
触发器支持注册自己的应用层代码,使得当数据库系统发生数据更改(写事务)时自动执行上述自定义代码。通过触发器技术,可以将数据更改记录到一个单独的表中,然后外部处理逻辑访问该表,实施必要的自定义应用层逻辑,例如将数据更改复制到另一个系统。Oracle的Databus和Postgres的Bucardo就是这种技术的典型代表。
基于触发器的复制通常比其他复制方式开销更高,也比数据库内置复制更容易出错,或者暴露一些限制。然而,其高度灵活性仍有用武之地。
由于并非所有的写入都反映在从副本上,如果同时对主节点和从节点发起相同的查询,可能会得到不同的结果。这种不一致只是一个暂时的状态,如果停止写数据库,经过一段时间之后,从节点最终会赶上并与主节点保持一致。这种效应也被称为最终一致性。
“最终”一词有些含糊不清,总的来说,副本落后的程度理论上并没有上限。正常情况下,主节点和从节点上完成写操作之间的时间延迟(复制滞后)可能不足1秒,这样的滞后,在实践中通常不会导致太大影响。但是,如果系统已接近设计上限,或者网络存在问题,则滞后可能轻松增加到几秒甚至几分钟不等。
当滞后时间太长时,导致的不一致性不仅仅是一个理论存在的问题,而是个实实在在的现实问题。以下为三个复制滞后可能出现的问题,并给出相应的解决思路。
许多应用让用户提交一些数据,接下来查看他们自己所提交的内容。异步复制存在这样一个问题,用户在写入不久即查看数据,则新数据可能尚未到达从节点。对用户来讲,看起来似乎是刚刚提交的数据丢失了,显然用户不会高兴。
对于这种情况,我们需要“写后读一致性”,也称为读写一致性。该机制保证如果用户重新加载页面,他们总能看到自己最近提交的更新。但对其他用户则没有任何保证,这些用户的更新可能会在稍后才能刷新看到。
基于主从复制的系统该如何实现写后读一致性呢?有多种可行的方案,以下例举一二:
如果同一用户可能会从多个设备访问数据,例如一个桌面Web浏览器和一个移动端的应用,情况会变得更加复杂。此时,要提供跨设备的写后读一致性,即如果用户在某个设备上设备上输入一些信息然后在另一台设备上查看,也应该看到刚刚所输入的内容。
这种情况下需要考虑的问题:
用户数据向后回滚的奇怪情况,用户看到了最新内容之后又读到了过期的内容,好像时间被回拨,此时需要单调读一致性。
单调读一致性可以确保不会发生这种异常。这是一个比强一致性弱,但比最终一致性强的保证。当读取数据时,单调读保证,如果某个用户依次进行多次读取,则他绝不会看到回滚现象,即在读取较新值之后又发生读旧值的情况。
实现单调读的一种方式是,确保每个用户总是从固定的同一副本执行读取(而不同的用户可以从不同的副本读取)。例如,基于用户ID的哈希方法而不是随机选择副本。但如果该副本发生失效,则用户的查询必须重新路由到另一个副本。
因果反常,事实上实际的顺序由于延迟不同而对第三方影响,导致第三方看到了逻辑混乱的顺序。(没问就答)
分区数据经多副本复制后出现了不同程度的滞后,导致观察者先看到果后看到因
防止这种异常需要引入另一种保证:前缀一致读。该保证是说,对于一系列按照某个顺序发生的写请求,那么读取这些内容时也会按照当时写入的顺序。这是分区(分片)数据库中出现的一个特殊问题,细节见第六章。如果数据库总是以相同的顺序写入,则读取总是看到一致的序列,不会发生这种反常。然而,在许多分布式数据库中,不同的分区独立运行,因此不存在全局写入顺序。这就导致当用户从数据库中读数据时,可能会看到数据库的某部分旧值和另一部分新值。
一个解决方案是确保任何具有因果顺序关系的写入都交给一个分区来完成,但该方案真实实现效率会大打折扣。现在有一些新的算法来显示地追踪事件因果关系,稍后的“Happened-before关系与并发”会继续探讨。
设计应用系统之前就应该充分的考虑到异步复制可能带来的麻烦,在应用层可以提供比底层数据库更强有力的保证。例如只在主节点上进行特定类型的读取,而代价则是,应用层代码中处理这些问题通常会非常复杂,且容易出错。
如果应用程序开发人员不必担心这么多底层的复制问题,而是假定数据库在“做正确的事情”,情况就变得很简单。而这也是事务存在的原因,事务是数据库提供更强保证的一种方式。
单节点上支持事务已经非常成熟。然而,在转向分布式数据库(即支持复制和分区)的过程中,有许多系统却选择放弃支持事务,并声称事务在性能与可用性方面代价过高,然而断言在可扩展的分布式系统中最终的一致性是无法避免的终极选择。关于这样的表述,首先它有一定道理,但情况远不是所说的那么简单,后面会展开讨论,尝试形成一个更为深入的观点。
多主节点复制,每个主节点还同时扮演其他主节点的从节点。主从复制存在一个明显的缺点:系统只有一个主节点,而所有写入都必须经由主节点。如果由于某种原因,例如与主节点之间的网络中断而导致主节点无法连接,主从复制方案就会影响所有的写入操作。
在一个数据中心内部适用多主节点基本没有太大意义,其复杂性已经超过所能带来的好处。但是,在以下场景这种配置则是合理的。
为了容忍整个数据中心级别故障或者更接近用户,可以把数据库的副本横跨多个数据中心。而如果使用常规的基于主从的复制模型,主节点势必只能放在其中的某一个数据中心,而所有写请求都必须经过该数据中心。
有了多主节点复制模型,则可以在每个数据中心都配置主节点。在每个数据中心内,采用常规的主从复制方案;而在数据中心之间,由各个数据中心的主节点来负责同其他数据中心的主节点进行数据的交换、更新。
对比一下在多数据中心环境下,部署单主节点的主从复制方案与多主复制方案之间的差异:
尽管多主复制具有上述优势,但也存在一个很大的缺点:不同的数据中心可能会同时修改相同的数据,因而必须解决潜在的写冲突。
应用在与网络断开后还需要继续工作。在离线状态下进行的任何更改,会在下次设备上线时,与服务器以及其他设备同步。
这种情况下,每个设备都有一个充当主节点的本地数据库(用来接受写请求),然后在所有设备之间采用异步方式同步这些多主节点上的副本,同步滞后可能是几个小时或者数天,具体时间取决于设备何时可以再次联网。
从架构层面来看,上述设置基本上等同于数据中心之间的多主复制,只不过是个极端情况,即一个设备就是数据中心,而且它们之间的网络连接非常不可靠。多个设备同步日历的例子表明,多主节点可以得到想要的结果,但中间过程依然有很多的未知数。
我们通常不会将协作编辑完全等价于数据库复制问题,但二者确实有很多相似之处。当一个用户编辑文档时,所做的更改会立即应用到本地副本(Web浏览器或客户端应用程序),然后异步复制到服务器以及编辑同一文档的其他用户。
如果要确保不会发生编辑冲突,则应用程序必须先将文档锁定,然后才能对其进行编辑。如果另一个用户想要编辑同一个文档,首先必须等到第一个用户提交修改并释放锁。这种协作模式相当于主从复制模型下在主节点上执行事务操作。
为了加快写作编辑的效率,可编辑的粒度需要非常小。例如,单个按键甚至是全程无锁。然而另一方面,也会面临所有多主复制都存在的挑战,即如何解决冲突。
多主复制的最大问题是可能发生写冲突,这意味着必须有方案来解决冲突。
如果是主从复制数据库,第二个写请求要么会被阻塞直到第一个写完成,要么被中止(用户必须重试)。然而在多节点的复制模型下,这两个写请求都是成功的,并且只能在稍后的时间点上才能异步检测到冲突,那时再要求用户层来解决冲突为时已晚。
理论上,也可以做到同步冲突检测,即等待写请求完成对所有副本的同步,然后再通知用户写入成功。但是,这样做将会失去多主节点的主要优势:允许每个主节点独立接受写请求。 如果确实想要同步方式冲突检测,或许应该考虑采用单主节点的主从复制模型。
处理冲突最理想的策略是避免发生冲突,即如果应用层可以保证对特定记录的写请求总是通过一个主节点,这样就不会发生冲突。更新自己的数据时,确保每次路由到特定的数据中心。从用户的角度来看,这基本等价于主从复制模型。
但是,当数据中心发生故障或者用户漫游至另一个位置。此时,冲突避免方式不再有效,必须有措施来处理同时写入冲突的可能性。
对于主从复制模型,数据更新符合顺序性原则,即如果同一个字段有多个更新,则最后一个写操作将决定该字段的最终值。对于多主节点复制模型,由于不存在这样的写入顺序,所以最终值也会变得不确定。
如果每个副本都只是按照它所看到写入的顺序执行,那么数据库最终将处于不一致状态。因此,数据库必须以一种收敛趋同的方式来解决冲突,这也意味着当所有更改最终被复制、同步之后,所有副本的最终值是相同的。
实现收敛的冲突解决有以下可能的方式:
解决冲突最合适的方式可能还是依靠应用层,所以大多数多主节点复制模型都有工具来让用户编写应用代码来解决冲突。可以在写入时或在读取时执行这些代码逻辑:
注意,冲突解决通常用于单个行或文档,而不是整个事务。因此,如果有一个原子事务包含多个不同请求,每个请求仍然是分开考虑来解决冲突。
有些冲突是显而易见的,而其他类型的冲突可能会非常微妙,更难以实现。
冲突解决的规则可能会变得越来越复杂,且自定义代码很容易出错。一些有意思的研究尝试自动解决并发修改所引起的冲突,下面这些方法值得一看:
复制的拓扑结构描述了写请求从一个节点传播到其他节点的通信路径。
三种模型:环形拓扑、星型拓扑、全部-至-全部型拓扑。最常见的是全部-至-全部型拓扑。
对于链接紧密的拓扑(如全部到全部),消息可以沿着不同的路径传播,避免了单点故障,因而有更好的容错性。但另一方面,全链接拓扑也存在一些自身的问题。主要是存在某些网络链路比其他链路更快的情况(例如由于不同网络拥塞),从而导致复制日志之间的覆盖。对于多主节点复制,某些副本上可能会出现错误的写请求到达顺序。为了使得日志消息正确有序,可以使用一种称为版本向量的技术。
放弃主节点,允许任何副本直接接受来自客户端的写请求。其实最早的数据复制系统就是无主节点的(或称为去中心复制,无中心复制),但后来到了关系数据库主导的时代,这个想法被大家选择性遗忘了。
对于某些无主节点系统实现,客户端直接将其写请求发送到多副本,而在其他一些实现中,有一个协调者节点代表客户端进行写入,但于主节点的数据库不同,协调者并不负责写入顺序的维护。这种设计上的差异对数据库的使用方式有着深刻的影响。
为了解决写入期间存在某节点下线的情况(客户端写入成功),当一个客户端从数据库中读取数据时,它不是向一个副本发送请求,而是并行地发送到多个副本。客户端可能会得到不同节点的不同相应,包括某些节点的新值和某些节点的旧值。可以采用版本号技术确定哪个值更新。
复制模型应确保所有数据最终复制到所有的副本。当一个失效的节点重新上线之后,它如何赶上中间错过的那些写请求呢?
并不是所有的系统都实现了上述两种方案。请注意,当缺少反熵过程的支持时,由于读时修复只在发生读取时才可能执行修复,那些很少访问的数据就可能在某些副本中已经丢失而无法检测到,从而降低了写的持久性。
如果有n个副本,写入需要w个节点确认,读取必须至少查询r个节点,则只要w+r>n,读取的节点中一定会包含最新值。满足上述这些r、w值的读/写操作称之为法定票数读(或仲裁读)或法定票数写(或仲裁写)。也可以认为r和w是用于判定读、写是否有效的最低票数。
在Dynamo风格的数据库中,参数n、w和r通常是可配置的。一个常见的选择是设置n为某奇数(通常为3或5),w=r=(n+1)/2(向上舍入)。也可以根据自己的需求灵活调整这些配置。例如,对于读多写少的负载,设置w=n和r=1比较合适,这样读取速度更快,但是一个失效的节点就会使得数据库所有写入因无法完成quorum而失败。
仲裁条件w+r>n定义了系统可容忍的失效节点数:
如果有n个副本,并且配置w和r,使得w+r>n,可以预期可以读取到一个最新值。之所以这样,是因为成功写入的节点集合和读取的节点集合必然有重合,这样读取的节点中至少有一个具有最新值。
当w和r配置的节点数较小,读取请求当中可能恰好没有包含新值的节点,因此最终可能会返回一个过期的旧值。好的一方面是,这种配置可以获得更低的延迟和更高的可用性,例如网络中断,许多副本变得无法访问,相比而言有更高的概率继续处理读取和写入。只有当可用的副本数已经低于w或r时,数据库才会变得无法读/写,即处于不可用状态。
即使在w+r>n的情况下,也可能存在返回旧值的边界条件。这主要取决于具体实现,可能的情况包括:
因此,虽然quorum设计上似乎可以保证读取最新值,但现实情况却往往更加复杂。Dynamo风格的数据库通常是针对最终一致性场景而优化的。我们建议最好不要把参数w和r视为绝对的保证,而是一种灵活可调的读取新值的概率。
从运维角度来看,监视数据库是否返回最新结果非常重要。对于主从复制的系统监控起来和衡量该从节点落后于主节点的程度更为容易。
然而,对于无主节点复制的系统,并没有固定的写入顺序,因而控制就变得更加困难。而且,如果数据库只支持读时修复(不支持反熵),那么旧值的落后就没有一个上限。例如如果一个值很少被访问,那么所返回的旧值可能非常之古老。
目前针对无主节点复制系统已经有一些研究,根据参数n,w和r来预测读到旧值的期望百分比。不过,总体讲还不是很普及。即便如此,将旧值监控纳入到数据库标准指标集中还是很有必要。要知道,最终一致性其实是个非常模糊的保证,从可操作性上讲,量化究竟何为“最终”很有实际价值。
配置适当quorum的数据库系统可以容忍某些节点故障,也不需要执行故障切换。它们还可以容忍某些节点变慢,这是因为请求并不需要等待所有n个节点的响应,只需w或r节点响应即可。对于需要高可用和低延迟的场景来说,还可以容忍偶尔读取旧值,所有这些特性使之具有很高的吸引力。但是,一个网络中断可以很容易切断一个客户端到多数数据库节点的链接。尽管此时集群节点是活着的,且对与其他用户也的确可以正常链接,但是对于断掉链接的客户端来讲,情况无疑等价于集群整体失效。
在一个大规模集群中(节点数远大于n个),客户可能在网络中断期间还能连接到某些数据库节点,但这些节点又不是能够满足数据仲裁的那些节点。此时,数据库设计者就面临着一个选择:
后一种方案称之为放松的仲裁:写入和读取仍然需要w和r个成功的响应,但包含了那些并不在先前指定的n个节点。 一旦网络问题得到解决,临时节点需要把接收到的写入全部发送到原始主节点。这就是所谓的数据回传(或暗示移交)。
可以看出,sloppy quorum对于提高写入可用性特别有用:只要有任何w个节点可用,数据库就可以接受新的写入。然而这意味着,即使满足w+r>n,也不能保证在读取某个键时,一定能读到最新值,因为新值可能被临时写入n之外的某些节点且尚未回传过来。
因此,sloppy quorum并非传统意义上quorum。而更像是为了数据持久性而设计的一个保证措施,除非回传结束,否则它无法保证客户端一定能从r个节点读到新值。
无主节点复制由于旨在更好地容忍并发写入冲突,网络中断和延迟尖峰等,因此也可适用于多数据中心操作。
Cassandra和Voldemort在其默认设置的无主节点模型中都支持跨数据中心操作:副本的数量n是包含所有数据中心的节点总数。配置时,可以指定每个数据中心各有多少副本。每个客户端的写入都会发送到所有副本,但客户端通常只会等待来自本地数据中心内的quorum节点数的确认,这样避免了高延迟和跨数据中心可能的网络异常。尽管可以灵活配置,但对远程数据中心的写入由于延迟很高,通常都被配置为异步方式。
Riak则将客户端与数据库节点之间的通信限制在一个数据中心内,因此n描述的是一个数据中心内的副本数量。集群之间跨数据中心的复制则在后台异步运行,类似于多主节点复制风格。
一个核心问题是,由于网络延迟不稳定或者局部失效,请求在不同的节点上可能会呈现不同的顺序。 我们知道副本应该收敛于相同的内容,这样才能达成最终一致。大多数的系统都不能自动处理数据副本之间的一致性,接下来进一步介绍“处理写冲突”的技巧。
一种实现最终收敛的方法是,每个副本总是保存最新值,允许覆盖并丢弃旧值。如何去定义“最新”是很重要的。
即使无法确定写请求的“自然顺序”,我们可以强制对其排序。例如,为每个写请求附加一个时间戳,然后选择最新即最大的时间戳,丢弃较早时间戳的写入。这个冲突解决算法被称为最后写入者获胜(last write wins,LWW),它是Cassandra仅有的冲突解决方法,而在Riak中,它是可选方案之一。
LWW可以实现最终收敛的目标,但是以牺牲数据持久性为代价。如果同一个主键有多个并发写,即使这些并发写都向客户端报告成功(因为完成了写入w个副本),但最后只有一个写入值会存活下来,其他的将被系统默默丢弃。此外,LWW甚至可能会删除那些非并发写,第八章将会讨论“时间戳与事件顺序”。
在一些场景如缓存系统,覆盖写是可以接受的。如果覆盖、丢失数据不可接受,则LWW并不是解决冲突很好的选择。
要确保LWW安全无副作用的唯一方法是,只写入一次然后写入值视为不可变,这样就避免了对同一个主键的并发(覆盖)写。例如,Cassandra的一个推荐使用方法就是采用UUID作为主键,这样每个写操作都针对不同的、系统唯一的主键。
如果B知道A,或者依赖于A,或者以某种方式在A基础上构建,则称操作A在操作B之前发生。这是定义何为并发的关键。事实上,我们也可以简单地说,如果两个操作都不在另一个之前发生,那么操作是并发的(或者两者都不知道对方)
因此,对于两个操作A和B,一共存在三种可能性:A在B之前发生,或者B在A之前发生,或者A和B并发。我们需要的是一个算法来判定两个操作是否并发。如果一个操作发生在另一个操作之前,则后面的操作可以覆盖较早的操作。如果属于并发,就需要解决潜在的冲突问题。
通常如果两个操作“同时”发生,则称之为并发,然而事实上,操作是否在时间上重叠并不重要。由于分布式系统中复杂的时钟同步问题,现实当中,我们很难严格确定它们是否同时发生。
为更好地定义并发性,我们并不依赖确切的发生时间,即不管物理的实际如何,如果两个操作并不需要意识到对方,我们即可声称它们是并发操作。一些人尝试把这个思路与物理学中狭义相对论联系起来,后者引入了“信息传递不能超越光速”的假定,如果两个事件发生的间隔短于光在它们之间的折返,那么这两个事件不可能有相互影响,因此就是并发。
在计算机系统中,即使光速快到允许一个操作影响到另一个操作,但两个操作仍可能被定义为并发。例如,发生了网络拥塞或中断,可能就会出现两个操作由于网络问题导致一个操作无法感知另一个,因此两者称为并发。
服务器判断操作是否并发的依据主要依靠对比版本号,而并不需要解释新旧值本身(值可以是任何数据结构)。算法的工作流形如下:
当写请求包含了前一次读取的版本号时,意味着修改的是基于以前的状态。如果一个写请求没有包含版本号,它将与所有其他写入同时进行,不会覆盖任何已有值,其传入的值将包含在后续读请求的返回值列表当中。
上述算法可以保证不会发生数据丢失,但如果多个操作并发发生,则客户端必须通过合并并发写入的值来继承旧值。Riak称这些并发值为兄弟关系。
合并本质上与先前讨论的多节点复制冲突解决类似。一个简单的方法是基于版本号或时间戳(即最后写入获胜)来选择其中的一个值,但这意味着会丢失部分数据。合并并发值的合理方式是包含新值和旧值(union操作),合并时去掉了重复值。但是在删除场景下可能会导致错误的结果,如果合并了两个客户端的值,且其中有一个商品被客户端删除掉,则被删除的项目会再次出现在合并的终值中。 为了防止该问题,项目在删除时不能简单地从数据库中删除,系统必须保留一个对应的版本号以恰当的标记该项目需要在合并时被剔除。这种删除标记被称为墓碑。
考虑到在应用代码中合并非常复杂且容易出错,因此可以设计一些专门的数据结构来自动执行合并,例如,Riak支持称为CRDT一系列数据结构,以合理的方式高效自动合并,包括支持删除标记。
当多个副本同时接受写入时,我们需要为每个副本和每个主键均定义一个版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本看到的版本号。通过这些信息来指示要覆盖哪些值、该保留哪些并发值。
所有副本的版本号集合称为版本矢量。与单个副本的版本号类似,当读取数据时,数据库副本会返回版本矢量给客户端,而在随后写入时需要将版本信息包含在请求当中一起发送到数据库。Riak将版本矢量编码为一个称之为因果上下文的字符串。版本矢量技术使数据库可以区分究竟应该覆盖写还是保留并发值。
另外,就像单副本的例子一样,应用程序仍然需要执行合并操作。版本矢量可以保证从某一个副本读取值然后写入到另一个副本,而这些值可能会导致在其他副本上衍生出来新的“兄弟”值,但至少不会发生数据丢失且可以正确合并所有并发值。
复制或者多副本技术主要服务以下目的:
在多台机器上保存多份相同的数据副本,看似只是个很简单的目标,但事实上复制技术是一个非常烧脑的问题。需要仔细考虑并发以及所有可能出错的环节,并小心处理故障之后的各种情形。最最基本的,要处理好节点不可用与网络中断问题,这里甚至还没考虑一些更隐蔽的失效场景,例如由于软件bug而导致的无提示的数据损坏。
我们主要讨论了三种多副本方案:
每种方法都有其优点和缺点。主从复制非常流行,主要是因为它很容易理解,也不需要担心冲突问题。而万一出现节点失效、网络中断或者延迟抖动等情况,多主节点和无主节点复制方案会更加可靠,不过背后的代价则是系统的复杂性和弱一致性保证。
复制可以是同步的,也可以是异步的,而一旦发生故障,二者的表现差异会对系统行为产生深远的影响。在系统稳定状态下异步复制i性能优秀,但仍然认真考虑一旦出现复制滞后和节点失效两种场景会导致何种影响。万一某个主节点发生故障,而一个异步复制滞后和节点失效两种场景会导致何种影响。万一某个主节点发生故障,而一个异步更新的从节点被提升为新的主节点,要意识到最新确认的数据可能有丢失的风险。
我们还分析了由于复制滞后引起的一些奇怪效应,并讨论了以下一致性模型,来帮助应用程序处理复制滞后:
最后,我们讨论了多主节点和无主节点复制方案所引入的并发问题。即由于多个写可能同时发生,继而可能产生冲突。为此,我们研究了一个算法使得数据库系统可以指定某操作是否发生在另一个操作之前,或者是同时发生。接下来,探讨采用合并并发更新值得方法来解决冲突。