本章目标: 分布式环境如何达成一致性与共识。
本章我们将讨论构建容错式分布式系统的相关算法和协议。为了构建容错系统,最好先构建一套通用的抽象机制和与之对应的技术保证,这样只需实现一次,其上的各种应用程序都可以安全地信赖底层的保证。例如,抽象的事务机制可以屏蔽系统内很多复杂的问题,例如发生崩溃、边界条件、磁盘故障等,使得应用层轻松无忧。
现在继续沿着这个思路,尝试建立可以让分布式应用忽略内部各种问题的抽象机制。例如,分布式系统最重要的抽象之一就是共识:所有的节点就某一项提议达成一致。
大多数多副本的数据库都至少提供了最终的一致性,这意味着如果停止更新数据库,并等待一段时间(长度未知)之后,最终所有读请求会返回相同的内容。换句话说,不一致现象是暂时的,最终会达到一致(假设网络故障最终会被修复)。换言之,最终一致性意味着“收敛”,即预期所有的副本最终会收敛到相同的值。
分布式一致性模型与我们之前讨论过的多种事务隔离级别有相似之处。虽然存在某些重叠,但总体讲他们有着显著的区别:事务隔离主要是为了处理并发执行事务时的各种临界条件,而分布式一致性则主要是针对延迟和故障等问题来协调副本之间的状态。
可线性化基本的想法是让一个系统看起来好像只有一个数据副本,且所有的操作都是原子的。 有了这个保证,应用程序就不需要关心系统内部的多个副本。
可线性化背后的基本思想很简单:使系统看起来好像只有一个数据副本。
就近性保证:一旦新值被写入或读取,所有后续的读都看到的是最新的值,直到被再次覆盖。
通过记录所有请求和响应的时序,然后检查它们是否可以顺序排列,可以用来测试系统是否可线性化(这里存在额外的计算开销)
可线性化(Linearizability)非常容易与可串行化(Serializability)发生混淆,两个词似乎都在表达类似“可以按顺序排列”的意思。但是它们完全不同,需要仔细区分:
数据库可以同时支持可串行化与线性化,这种组合又被称为严格的可串行化或者强的单副本可串行化(strong one-copy serializability,strong-1SR)。基于两阶段加锁或者实际以串行执行都是典型的可线性化。
但是,可串行化的快照隔离则不是线性化的:按照设计,它可以从一致性快照中读取,以避免读、写之间的竞争。一致性快照的要点在于它里面不包括快照点创建时刻之后的写入数据,因此从快照读取肯定不满足线性化。
在有些场景下,线性化对于保证系统正确工作至关重要。
不管锁具体如何实现,它必须满足可线性化:所有节点都必须同一哪个节点持有锁,否则就会出现问题。
提供协调者服务的系统如Apache ZooKeeper和etcd等通常用来实现分布式锁和主节点选举。归根结底,线性化存储服务是所有这些协调服务的基础。
唯一性约束在数据库中很常见。硬性的唯一性约束,常见如关系型数据库中主键的约束,则需要线性化保证。其他如外键或属性约束,则并不要求一定线性化。
线性化违例之所以被注意到,是因为系统中存在其他的通信渠道(例如,Alice对Bob发出的声音来传递信息)
Web服务器和图像调整器通过文件存储和消息队列通信,存在边界条件的可能。之所以出现这个问题是因为Web服务器和调整模块之间存在两个不同的通信通道:文件存储器和消息队列。 如果没有线性化的就近性保证,这两个通道之间存在竞争条件。
尽管使用了严格的quorum,仍然不满足线性化。 可以选择牺牲性能为代价来满足线性化,但这种方式只能实现线性化读、写操作,但无法支持线性化的“比较和设置”操作,后者需要共识算法的支持。
网络中断迫使在可线性化与可用性之间做出选择。
CAP有时也代表一致性,可用性,分区容错性,系统只能支持其中两个特性。不过,这种理解存在误导性,网络分区是一种故障,不管喜欢还是不喜欢,它都可能发生,所以无法选择或逃避分区的问题。
在网络正常的时候,系统可以同时保证一致性(线性化)和可用性。而一旦发生了网络故障,必须要么选择线性(一致性),要么可用性。因此,更准确的称呼应该是“网络分区情况下,选择一致还是可用”。高可靠的网络会帮助减少发生的概率,但无法做到彻底避免。
有必要指出,在CAP的诸多讨论中,术语可用性存在争议,其形式化定理中的可用性与通常意义上的理解有些差别。许多所谓的“高可用性”(容错)系统实际上并不符合CAP对可用性的特殊定义。总之,围绕着CAP有太多的误解与困扰,最后反而无法帮助我们更好地理解系统,所以本人建议最好避免使用CAP。
只要有不可靠的网络,都会发生违背线性化的风险。我们可以做以下的权衡考虑;
不要求线性化的应用更能容忍网络故障。正式定义的CAP定理范围很窄,它只考虑了一种一致性模型(即线性化)和一种故障(网络分区,节点处于活动状态但相互断开),而没有考虑网络延迟、节点失败或其他需要折中的情况。分布式系统中还有很多有趣的研究结果,目前CAP已被更精确的研究成果所取代,所以它现在更多的是代表历史上曾经的一个关注热点而已。
虽然线性化是个很有用的保证,但实际上很少有系统真正满足线性化。例如,现代多核CPU上的内存甚至就是非线性化:如果某个CPU核上运行的线程修改一个内存地址,紧接着另一个CPU核上的线程尝试读取,则系统无法保证可以读到刚刚写入的值,除非使用了内存屏障或fence指令。
出现这种现象的原因是每个CPU核都有自己独立的cache和寄存器。内存访问首先进入cache系统,所有修改默认会异步地刷新到主存。由于访问cache比访问主存要快得多,所以这样的异步刷新特性对于现代CPU的性能至关重要。但是,这就导致出现了多个数据副本(一个在主存,另外几个在不同级别的cache中)。而副本更新是异步方式,无法保证线性化。
Attiya和Welch证明如果想要满足线性化,那么读、写请求的响应时间至少与网络延迟成正比。虽然没有足够快的线性化算法,但弱一致性模型的性能则快得多,这种取舍对于延迟敏感的系统非常重要。
事实证明,排序、可线性化与共识之间存在着某种深刻的联系。
之所以反复出现“顺序”问题,其中的一个原因是它有助于保持因果关系。
因果关系对所发生的事件施加了某种排序:发送消息先于收到消息;问题出现在答案之前等,或者就像在现实生活中一样,一件事情会导致另一件事情:一个节点根据读取的数据做出决定,然后写入结果,另一个节点读取写入的结果再写入新的内容,诸如此类。 这些因果关系的依赖链条定义了系统中的因果顺序,即某件事应该发生另一间事情之前。
如果系统服从因果关系所规定的顺序,我们称之为因果一致性。例如,快照隔离提供了因果一致性:当从数据库中读数据时,如果查询到了某些数据,也一定能看到触发该数据的前序事件(假设期间没有发生删除事件)
全序关系 支持任何两个元素之间进行比较,即对于任意两个元素,总是可以指出哪个更大,哪个更小。全序和偏序的差异也会体现在不同的数据库一致性模型中:
那么因果序和可串行化之间是什么关系呢?答案是可线性化一定意味着因果关系:任何可线性化的系统都将正确地保证因果关系。
线性化并非是保证因果关系的唯一途径,还有其他方法使得系统可以满足因果一致性而免于线性化所带来的性能问题。事实上,因果一致性可以认为是,不会由于网络延迟而显著影响性能,又能对网络故障提供容错的最强的一致性模型。
为保持因果关系,需要知道哪个操作发生在前。为了确定请求的因果依赖关系,我们需要一些手段来描述系统中节点所知道的“知识”。
虽然因果关系很重要,但实际上跟踪所有的因果关系不切实际。更好的办法是 :我们可以使用序列号或时间戳来排序事件。
特别是,我们可以按照与因果关系一致的顺序来创建序列号:保证如果操作A发生在B之前,那么A一定在全序中出现在B之前(即A的序列号更小)。并行操作的序列可能是任意的。这样的全局排序可以捕获所有的因果信息,同时也强加了比因果关系更为严格的顺序性。
如果系统不存在这样唯一的主节点(例如可能是多主或者无主类型的数据库),如何产生序列号就不是那么简单了。
三种方法都存在的问题:所产生的序列号与因果关系并不严格一致。 所有这些序列号发生器都无法保证正确捕获跨界点操作的顺序,因而存在因果关系方面的问题。
兰伯特时间戳(Lamport timestamp) 可以产生与因果关系一致的序列号,可以保证全序与因果关系一致。
首先每个节点都有一个唯一的标识符,且每个节点都有一个计数器来记录各自已处理的请求总数。Lamport时间戳是一个值对(计数器,节点ID)。两个节点可能会有相同的计数器值,但时间戳中还包含节点ID信息,因此可以确保每个时间戳都是唯一的。
Lamport时间戳与物理墙上时钟并不存在直接对应关系,但它可以保证全序:给定两个Lamport时间戳,计数器较大那个时间戳大;如计数器值刚好相同,则节点ID越大,时间戳越大。每个节点以及每个客户端都跟踪迄今为止所见到的最大计数器值,并在每个请求中附带该最大计数器值。当节点收到某个请求(或者回复)时,如果发现请求内嵌的最大计数器值大于节点自身的计数器值,则它立即把自己的计数器修改为最大值。
只要把最大计数器值嵌入到每一个请求中,该方案可以确保Lamport时间戳与因果关系一致,而请求的因果依赖性一定会保证后发生的请求得到更大的时间戳。Lamport时间戳由于版本向量之处在于它更加紧凑和高效。
虽然Lamport时间戳定义了与因果序列一致的全序关系,但还不足以解决实际分布式系统中许多常见的问题。这里问题的关键是:只有在收集了所有的请求信息之后,才能清楚这些请求之间的全序关系。
总而言之,为了实现像用户名唯一性约束这样的目标,仅仅对操作进行全序排列还是不够的,还需要知道这些操作是否发生、何时确定等。假如能够在创建用户名时,已经确定知道了没有其他节点正在执行相同用户名的创建,你大可以直接安全返回创建成功。
如果程序只运行在一个CPU核上,可以非常简单地定义出操作的全序关系,即在单核上执行的顺序。
主从复制的主要挑战在于,如何扩展系统的吞吐量使之突破单一主节点的限制,以及如何处理主节点失效时的故障切换。 在分布式系统研究文献中,这些问题被称为全序关系广播或原子广播。
全序关系广播通常指节点之间交换消息的某种协议。非正式的定义要求满足两个基本安全属性:
全序关系广播与共识之间有着密切关系,可以使用全序关系广播来实现可串行化事务,
全序关系广播另一个要点是顺序在发送消息时已经确定,如果消息发送成功,节点不允许追溯地将某条消息插入到先前的某个位置。这一点使得全序关系广播比基于时间戳排序要求更强。
在一个可线性化的系统中有全序操作集合。可线性化与全序关系广播不是完全相同的,但两者之间有着密切的联系。
全序关系广播是基于异步模型:保证消息以固定的顺序可靠地发送,但是不保证消息何时发送成功(因此某个接收者可能明显落后于其他接收者)。而可线性化则强调就近性:读取时保证能够看到最新的写入值。
可以通过使用全序关系广播以追加日志的方式来实现线性化的原子比较-设置操作。
此过程可确保线性化写入,但它却无法保证线性化读取,即从异步日志更新的存储中读取数据时,可能是旧值。顺序一致性(时间线一致性),它弱于线性化保证。
可以证明,线性化的原子比较-设置(或自增)寄存器与全序关系广播二者都等价于共识问题。
最简单的方式是假设有一个线性化的寄存器来存储一个计数,然后使其支持原子自增-读取操作或者原子比较-设置操作。
有很多重要的场景都需要集群节点达成某种一致:
FLP表明如果节点存在可能崩溃的风险,则不存在总是能够达成共识的稳定算法。(重要的理论意义,基于异步系统模型而做的证明,这是一个非常受限的模型,它假定确定性算法都不能使用任何时钟或超时机制)
原子性可以防止失败的事务破坏系统,避免形成部分成功夹杂着部分失败。
单节点上吗,事务提交非常依赖于数据持久写入磁盘的顺序关系:先写入数据,然后再提交记录。事务提交(或中止)的关键点在于磁盘完成日志记录的时刻:在完成日志记录写之前如果发生了崩溃,则事务需要中止;如果再日志写入完成之后,即使发生崩溃,事务也被安全提交。
向所有节点简单地发送一个提交请求,然后各个节点独立执行事务提交是绝对不够的。这样做很容易发生部分节点提交成功,而其他一些节点发生失败,从而违反了原子性保证:
事务提交不可撤销,不能事后再改变主意(在提交之后再追溯去中止)。当然已提交事务的效果可以被之后一笔新的事务来抵消掉,即补偿性事务。 不过,从数据库的角度来看,前后两个事务完全相互独立。类似这种跨事务的正确性需要由应用层来负责。
两阶段提交(two-phase commit,2PC)是一种在多节点之间实现事务原子提交的算法,用来确保所有节点要么全部提交,要么全部中止。
不要混淆2PC和2PL 两阶段提交(2PC)和两阶段加锁(2PL)是两个完全不同的事情。2PC在分布式数据库中负责原子提交,而2PL则提供可串行化的隔离。
2PC引入了单节点事务所没有的一个新组件:协调者(也称为事务管理器)。 2PC事务从应用程序在多个数据库节点上执行数据读/写开始。
该协议有两个关键的“不归路”:首先,当参与者投票“是”时,它做出了肯定提交的承诺(尽管还取决于其他的参与者的投票,协调者才能做出最后决定)。其次,协调者做出了提交(或者放弃)的决定,这个决定也是不可撤销。正是这两个承诺确保了2PC的原子性(而单节点源自提交其实是将两个事件合二为一,写入事务日志即提交)。
2PC能够顺利完成的唯一方法是等待协调者恢复。
两阶段提交也被称为阻塞式原子提交协议,因为2PC可能在等待协调者恢复时卡住。通常,非阻塞式原子提交 依赖于一个完美的故障检测器,即有一个非常可靠的机制可以判断出节点是否已经崩溃。
3PC假定一个有界的网络延迟和节点在规定时间内响应。考虑到目前大多数具有无线网络延迟和进程暂停的实际情况,它无法保证原子性。
分布式事务,尤其是那些通过两阶段提交所实现的事务,声誉混杂。分布式事务的某些实现存在严重的性能问题,两阶段提交性能下降的主要原因式为了防崩溃恢而做的磁盘I/O(fsync)以及额外的网络往返开销。
两种截然不同的分布式事务概念:
数据库内部事务由于不必考虑与其他系统的兼容,因此可以使用任何形式的内部协议并采取有针对性的优化。因此,数据库内部的分布式事务往往可行且工作不错,但异构环境的事务则充满了挑战。
异构的分布式事务旨在无缝集成多种不同的系统。需要指出,只有在所受影响的系统都使用相同的原子提交协议的前提下,这种分布式事务才是可行的。
X/Open XA(eXtended Architecture,XA)是异构环境下实施两阶段提交的一个工业标准。XA并不是一个网络协议,而是一个与事务协调者进行通信的C API。
数据库事务通常持有待修改行的行级独占锁,用以防止脏写。此外,如果要使用可串行化的隔离,则两阶段锁的数据库还会对事务曾经读取的行持有读-共享锁。在事务提交(或中止)之前,数据库都不会释放这些锁。
协调者崩溃之后最终可能会出现恢复失败,唯一的出路只能是让管理员手动决定究竟是执行提交还是回滚。
许多XA的实现都支持某种紧急避险措施称之为启发式决策:这样参与者节点可以在紧急情况下单方面做出决定,放弃或者继续那些停顿的事务,而不需要等到协调者发出指令。需要说明的是,这里的启发式其实是可能破坏原子性的委婉说法,它的确违背了两阶段提交所做出的承诺。 因此,这种启发式决策只是为了应急,不能作为常规手段来使用。
XA事务解决了多个参与者之间如何达成一致这样一个非常现实而重要的问题。特别是,核心的事务协调者本身就是一种数据库(存储事务的投票结果),因此需要和其他重要的数据库一样格外小心。
共识问题通常形式化描述如下:一个或多个节点可以提议某些值,由共识算法来决定最终值。 在这个描述中,共识算法必须满足以下性质:
协商一致性和诚实性属性定义了共识的核心思想:决定一致的结果,一旦决定,就不能改变。有效性属性主要是为了排除一些无意义的方案:例如,无论什么建议,都可以有一个总是为空(NULL)的决定,虽然可以满足一致性和诚实性,但没有任何实际效果。
最著名的容错式共识算法包括VSR,Paxos,Raft和Zab。这些算法大部分其实并不是直接使用上述的形式化模型(提议并决定某个值,同时满足上面4个属性)。相反,他们是决定了一系列值,然后采用全序关系广播算法。
如果主节点是由运营人员手动选择和配置的,那基本上就是一个独裁性质的“一致性算法”:只允许一个节点接受写入(并决定复制日志中的写入顺序),如果该节点发生故障,系统将无法写入,直到操作人员再手动配置新的节点成为主节点。 这样的方案也能在实践中很好地发挥作用,但它需要人为干预才你甭取得进展,不满足共识的可终止性。
目前所讨论的所有共识协议在其内部都使用了某种形式的主节点,虽然主节点并不是固定的。相反,他们都采用了一种弱化的保证:协议定义了一个世代编号,并且在每个世代里,主节点是唯一确定的。
共识算法对于分布式系统来说绝对是一个巨大的突破,它为一切不确定的系统带来了明确的安全属性(一致性,完整性和有效性),此外它还可以支持容错(只要大多数节点还在工作和服务可达)。公式可以提供全序关系广播,以容错的方式实现线性化的原子操作。
代价:
ZooKeep或etcd这样的项目通常被称为“分布式键值存储”或“协调与配置服务”。ZooKeeper和etcd主要针对保存少量、可完全载入内存的数据(虽然它们最终仍要写入磁盘以支持持久化)而设计,所以不要用它们保存大量的数据。
对于作业调度系统(或类似的有状态服务)非常有用,或者对于一些分区资源(可以是数据库,消息流,文件存储,分布式actor system等),需要决定将哪个分区分配给哪个节点。
通常情况下,ZooKeeper所管理的数据变化非常缓慢,类似“分区7的主节点在10.1.1.23”这样的信息,其变化频率往往在分钟甚至是小时级别。它不适合保存那些应用实时运行的状态数据,后者可能每秒差生数千甚至百万次更改。如果这样,应该考虑使用其他工具(Apache BookKeeper)
ZooKeeper、etcd和Consul还经常用于服务发现。
ZooKeeper等还可以看作是成员服务范畴的一部分。
线性化(一种流行的一致性模型):其目标是使多副本对外看卡来好像单一副本,然后所有操作以原子方式运行,就像一个单线程程序操作变量一样。 主要问题在于性能,特别是在网络延迟较大的环境中。
因果关系对事件进行了某种排序(根据事件发生的原因-结果依赖关系)。线性化是将所有的操作都挡在唯一的、全局有序时间线上,而因果性则不同,它为我们提供了一个弱一致性模型:允许存在某些并发事件,所以版本历史是一个包含多个分支与合并的时间线。因果一致性避免了线性化昂贵的协调开销,且对网络延迟的敏感性要低很多。
共识意味着就某一项提议,所有节点做出一致的决定,而且决定不可撤销。 等价的共识问题包括:
基于主从复制的主节点发生故障,或者出现网络中断而导致主节点不可达,这样的系统就会陷入停顿状态。处理该问题的三种基本思路:
ZooKeeper等工具以一种类似外包方式为应用提供了重要的共识服务、故障检测和成员服务。