一致性与共识
本章主要讨论构建容错式分布式系统的相关算法和协议。
分布式系统的最重要的抽象之一就是共识:所有的节点就某一项提议达成一致。一旦解决了共识问题,就可以解决应用层很多的目标需求,比如,一个主从复制的数据库,如果主节点发生失效,就需要切换另一个节点。
一致性保证
- 最终一致性,是一个非常弱的保证,无法告知我们系统何时收敛。
- 分布式一致性模型与我们之前讨论过得多种事务隔离级别有相似之处。虽然存在某些重叠,但总体他们有着显著的区别:事务隔离主要为了处理并发执行过程中的各种临界条件,而分布式一致性则主要针对延迟和故障等问题来协调各部分之间的状态。
本章将探索更强的一致性模型,不过也以为更多的代价,例如性能的降低或者容错性差。但是好处是可以使上层的逻辑更加简单,更不容易出错。
可线性化
什么是可线性化?
可线性化的基本思想是让一个系统看起来好像只有一个数据副本,而且所有的操作都是原子的,有了这个保证,应用程序就不需要关心系统内部的多个副本。
在一个可线性化的系统中,一旦某个客户端成功提交写请求,所有的客户端读请求一定能看到刚刚写入的值。而不是过期的缓存。
可线性化与可串行化?
可串行化是事务的隔离属性,其中每个事物可以读写多个对象。用来确保事务执行的结果和串行执行的结果完全相同。
可线性化是读写寄存器的最新值保证。他并不要求将操作组合到事务中,因此无法避免写倾斜等问题,除非采取其他额外的措施。
数据库可以同时支持可串行化与线性化,这种组合又被称为严格的可串行化。基于两阶段加锁或者实际以串行执行都是典型的可线性化。
但是可串行化的快照隔离则不是线性化的,从快照读取肯定不满足线性化。
线性化的依赖条件
加锁与主节点选举
主从复制的系统通常需要保证只要一个主节点,否则就是脑裂。选举新的主节点常见的方法是使用锁。即每个启动的节点都试图获得锁,但其中只有一个可以成功,继而成为主节点。不管锁具体如何实现,它必须满足可线性化:所有的节点都必须同意哪个节点持有锁,否则就会出现问题。
约束与唯一性保障
与加锁非常类似。例如数据库的唯一主键。
跨通道的时间依赖
当系统中存在多个不同的通信通道时,如果没有线性化的就近保证,这些通道之间存在竞争条件。 线性化并非避免这种竞争的唯一方法,但却是最容易理解的。
实现线性化系统
线性化本质上意味着“表现得好像只有一个数据副本”,所以最简单的方案自然是只用一个数据副本。但显然,该方法无法容错:如果仅有的副本所在的节点发生故障,就会导致数据丢失。
系统容错最常见的方法:复制机制。但是非常遗憾,三种主流复制方案,都无法实现完全的可线性化。
主从复制(部分支持可线性化)
如果是再主节点或者同步更新的从节点读取,则可以满足线性化。但是并非每个主从节点的数据库都是可线性化的,主要是因为他们可能采用了快照隔离的设计或者存在bug。并一个问题是不确定哪个节点就是主节点。而且在异步复制的方式中,故障切换的过程中可能丢失一些已提交的写入,结果是同时违反了持久性和线性化
共识算法(可线性化)
共识协议和主从复制相似,不过共识协议通过内置的措施防止脑裂和过期的版本,这些系统包括zk和etcd等。
多主复制(不可线性化)
同时在多个节点上执行并发写入,并将数据异步复制到其他节点。因此他们可能会产生冲突的写入,需要额外的解决方案。
无主复制(可能不可线性化)
对于无主节点复制的系统,完全取决于具体的quorum的配置,以及如何定义强一致性,它非常有可能并不保证线性化。比如:“最后写入者获胜”冲突解决方法几乎肯定是非线性化的;宽松的quorum也会破坏线性化。
线性化的代价
两个数据中心发生了网络中断,每个数据中心内部的网络工作正常,客户端可以到达就近的数据中心,但数据中心之间无法互联
多主复制的数据库,每个数据中心内都可以继续。
主从复制系统,数据中心之间的网络一旦中断,客户端无法联系上主节点,无法完成任何写入和线性化读取。从节点可以提供服务,但是内容可能是过期的。
CAP理论
上面的问题,不仅会出现在多数据中心的情况,即使在一个数据中心内部,只要有不可靠的网络,都会发生违背线性化的风险。
- 如果应用要求线性化,但是由于网络问题,某些副本断开连接之后无法继续处理请求,就必须等待网络修复,或者直接返回错误。结果导致服务不可用
- 如果应用不要求线性化,那么断开连接之后,每个副本可以单独处理请求例如写操作(多主复制)。此时,服务可用,但是结果不符合线性化。
不要求线性化的应用更能容忍网络故障。这种思路被称为CAP定理。
CAP理论是否有用?
CAP有时也代表一致性,可用性,分区容错性,系统只能支持其中两个特性。不过,这种理解存在误导性,网络分区是一种故障,不管喜欢还是不喜欢,它都可能发生,所以无法选择或逃避分区的问题。
在网络正常的时候,系统可以同时保证一致性(线性化)和可用性。而一旦发生了网络故障,必须要么选择线性(一致性),要么可用性。
因此,更准确的称呼应该是“网络分区情况下,选择一致还是可用”。高可靠的网络会帮助减少发生的概率,但无法做到彻底避免。
正式定义的CAP定理范围很窄,他只考虑了一种一致性模型(线性化)和一种故障,而没有考虑网络延迟、节点失败或其他需要这种的情况、因此,尽管CAP在历史上具有重大的影响力,但是对于一个具体的系统设计来说,他可能没有太大的实际价值。
可线性化与网络延迟
顺序保证
我们之前曾出过,线性化寄存器对外呈现的好像只有一份数据拷贝,而且每一个操作似乎都是原子性生效。这意味着操作是按照某种顺序执行
本书中所涉及的顺序
第五章,主从复制系统主节点的作用主要是确定复制日志中的写入顺序,这样使得从节点遵从相同的顺序执行写入。如果没有这种主节点,可能由于并发操作引发冲突。
第七章中,可串行化是确保事务执行结果与按照某种顺序方式执行一样。实现方式是严格顺序执行,或者允许并发但需要相应的冲突解决方案(如加锁或者冲突-中止)。
第八章讨论分布式系统的时间戳与时钟,试图将顺序引入到无序的操作世界,例如确定两个写操作那一个先发生。
总之,排序、可线性化和共识之间存在着某种深刻的联系。
顺序与因果关系
顺序有助于保持因果关系
举两个例子:
三个主节点之间进行数据复制,由于网络延迟,一些写操作会覆盖其他的写入。从某个副本的角度来看,好像是发生了一个对不存在数据行的更新。这里的因果意味着首先必须先创建数据行,然后才能去更新。
事务之间写倾斜的例子(参阅第7章“写倾斜与幻读”)也说明了因果关系。Alice申请调班成功是因为事务以为Bob仍在值班,反之亦然。在这种情况下,调班动作的因果关系取决于当前是谁在值班。可序列化的快照隔离(参阅第7章“可串行化的快照隔离”)主要通过跟踪事务之间的因果依赖关系从而达到检测写倾斜的目的。
因果关系对所发生的的时间施加了某种排序:发送消息先于收到消息;问题出现在答案之前等等。如果系统服从因果关系所规定的的顺序,我们称之为因果一致性。例如,快照隔离提供了因果一致性:从数据库读取数据时,如果查询到了某些数据,也一定能看到出发该数据的前序事件。
序列号排序
可以使用序列号或时间戳来排序事件。时间戳不一定来自墙上时钟。它可以只是一个逻辑时钟,例如采用算桂来产生一个数字序列用以识别操作,通常是递增的计数器。
在主从复制数据库中,复制日志定义了与因果关系一致的写操作全序关系。主节点可以简单地为每个操作递增某个计数器,从而为
复制日志中的每个操作赋值一个单调递增的序列号。从节点按照复制日志的顺序来写,结果一定满足因果一致性。
如果系统不存在这样的唯一主节点,可以采用一下方法:
每个节点都独立产生自己的一组序列号。例如,如果有两个节点,则一个节点只生成奇数,而另一个节点只生成偶数。
可以把墙上时间戳信息(物理时钊I )附加到每个操作上。
可以预先分配序列号的区间范围。例如,节点A 负责区间1-1000的序列号,节点B负责1001-2000 。然后每个节点独立地从区间中分配序列号,当序列号出现紧张时就分配更多的区间。
以上三种方法都无法保证正确的顺序。
Lamport时间戳 兰伯特时间戳
首先每个节点都有一个唯一的标识符,且每个节点都有一个计数器来记录各自已处理的请求总数。Lamport时间戳是一个值对(计数
器,节点ID)。两个节点可能会有相同的计数器值,但时间戳中还包含节点ID信息,因此可以确保每个时间戳都是唯一的。
Lamport时间戳的核心亮点在于使它们与因果性保持一致,具体如下所示:每个节点以及每个客户端都跟踪迄今为止所见到的最大计数器值,井在每个请求中附带该最大计数器值。当节点收到某个请求(或者回复)时,如果发现请求内嵌的最大计数器值大于节点自身的计数器值,则它立即把自己的计数器修改为该最大值。
全序关系广播
分布式事务与共识
共识问题是分布式计算中最重要也是最基本的问题之一。有很多重要的问题都需要集群节点达成某种一致。
- 主节点选举 主从复制的数据库,所有的节点需要就谁在充当主节点达成一致。如果网络故障,就很容易出现争议。此时,共识对于避免错误的故障切换非常重要,后者会导致两个节点都自认为是主节点即脑裂。最终会导致数据产生分歧、不一致甚至丢失。
- 原子事务提交 跨节点或跨分区的数据库事务,会面临这样的问题:某个事务可能在一些节点上执行成功,但是在其他节点却发生了失败。为了维护事务的原子性,所有节点要么全部成功提交,要么中止。
下面,首先讨论2PC算法,然后会继续讨论Zookeeper(Zab)和etcd(Raft)所使用的算法。
原子提交和两阶段提交
单节点的情况:
单节点上,事务的提交非常依赖于数据持久写入磁盘的顺序关系:先写入数据,然后再提交记录。事务提交的关键点在于磁盘完成日志记录的时刻:在完成日志记录写之前如果发生了崩溃,则事务需要终止;如果日志写入完成之后,即使发生了崩溃,事务也被安全提交。
涉及到多个节点的情况可以由两阶段提交来保证
两阶段提交
两阶段提交( two-phase commit, 2PC )是一种在多节点之间实现事务原子提交的算法,用来确保所有节点要么全部提交,要么全部中止。它是分布式数据库中的经典算法之一
2PC在某些数据库内部使用,或者以XA事务的形式(Java Transaction API)或SOAP Web服务WS-AtomicTransaction的形式提供给应用程序。
注意和第七章中的两阶段加锁区分开来。2PC在分布式数据库中负责原子提交,而2PL则提供可串行化的隔离。
2PC引入了单节点事务没有的一个新组件:协调者(也称事务管理器)。协调者通常实现为共享库,运行在请求事务相同的进程中。但也可以是单独的进程或者服务。
2PC事务从应用程序在多个数据库节点上执行数据读/写开始。我们将这些数据库节点称为事务中的参与者。当应用程序准备提交事务时,协调者开始阶段1:发送一个准备请求到所有节点,询问他们是否可以提交。协调者然后跟踪参与者的回应:
- 如果所有参与者回答“是”,表示他们已准备好提交,那么协调者接下来在阶段2会发出提交请求,提交开始实际执行。
- 如果有任何参与者回复“否”,则协调者在阶段2 中向所有节点发送放弃请求。
为什么两阶段提交能保证分布式事务原子性?
- 应用程序启动一个分布式事务时,他首先向协调者请求事务ID。
- 应用程序在每个节点上执行单节点事务,并将全局唯一事务Id附加到事务上。此时,读写都在单节点完成。如果在这个阶段出现问题,则协调者和其他参与者都可以安全中止。
- 应用程序准备提交时,协调者向所有参与者发送准备请求,并附带全局事务ID。如果准备请求有任何一个失败或者超时,则协调者会通知所有参与者放弃事务。
- 参与者在收到准备请求之后,确保在任何情况下都可以提交事务,包括安全地将食物数据写入磁盘,并检查是否存在插图或约束违规。一旦回答“是”,节点就承诺会提交事务。
- 协调者收到所有准备请求的答复时,就是否提交事务要做出明确的决定。协调者吧最后的决定写入到磁盘事务日志中。这个时刻称为提交点。
- 协调者的决定写入磁盘之后,接下来想所有参与者发送决定。如果此请求出现失败或超时,协调者必须一直重试,直到成功为止。此时所有节点不允许后悔。如果参与者在此期间出现故障,回复之后也必须继续执行。总之参与者和协调者之间都要尽最大努力去保证事务的提交。
该协议有两个关键的“不归路”:首先,当参与者投票“是”时,它做出了肯定提交的承诺(尽管还取决于其他的参与者的投票,协调者才能做出最后觉得)。其次,协调者做出了提交(或者放弃)的决定,这个决定也是不可撤销。正是这两个承诺确保了2PC的原子性(而单节点原子提交其实是将两个事件合二为一,写入事务日志即提交)。
协调者发生故障
如果协调者在发送准备请求之前就已失败,则参与者可以安全地中止交易。但是,一旦参与者收到了准备请求并做了投票“是”,则参与者不能单方面放弃,它必须等待协调者的决定。如果在决定到达之前,出现协调者崩愤或网络故障,参与者只能无奈等待。此时参与者处在一种不确定的状态。
2PC能够顺利完成的唯一方法是等待协调者恢复。这就是为什么协调者必须在向参与者发送提交(或中止)请求之前要将决定写入磁盘的事务日志:等协调者恢复之后,通过读取事务日志来确定所有未决的事务状态。如果在协调者日志中没有完成提交记录就会中止。此时,2PC的提交点现在归结为协调者在常规单节点上的原子提交。
三阶段提交
两阶段提交也被称为阻塞式原子提交协议,因为2PC 可能在等待协调者恢复时卡住。理论上,可以使其改进为非阻塞式从而避免这种情况。但是,实践中要想做到这一点并不容易。
3PC假定一个有界的网络延迟和节点在规定时间内响应。考虑到目前大多数具有无限网络延迟和进程暂停的实际情况(见第8章),它无法保证原子性。
通常,非阻塞原子提交依赖于一个完美的故障检测器,即有一个非常可靠的机制可以判断出节点是否已经崩溃。在无限延迟的网络环境中,超时机制并不是可靠的故障检测器,因为即使节点正常,请求也可能由于网络问题超时。正是由于这样的原因,尽管大家已经意识到协调者潜在的问题,但还是普遍使用2PC。
实践中的分布式事务
分布式事务的某些实现存在严重的性能问题。例如,有报告显示MySQL的分布式事务比单节点事务慢10倍以上。
两阶段提交性能下降的主要原因是为了防崩愤恢复而做的磁盘I/O (fsync)以及额外的网络往返开销。
我们不应该就这么直接地抛弃分布式事务,而应族更加审慎的对待,从中获取一些重要的经验教训
首先,分布式事务的确切含义并没有统一:
数据库内部的分布式事务,某些分布式数据库支持跨数据库节点的内部事务,此时所有节点都运行着相同的数据库软件。
异构分布式事务,存在两种或两种以上不同的参与者实现技术。例如两种数据库。即使是完全不同的系统,跨系统的分布式事务必须确保原子提交。
分布式事务的限制
核心的事务协调者本身就是一种数据库,因此需要和其他重要的数据库一样格外小心:
- 协调者如果不支持复制,而是单节点上运行,所以很多协调者并不是高可用的,或者只支持最基本的复制。
- 许多应用程序都倾向于无状态模式,持久状态都保存在数据库中,这样应用程序可以轻松添加或者删除实例。但是当协调者就是应用服务器的一部分时,部署方式就发生了根本变化。协调者的日志成为可靠系统的重要组成部分,他要求与数据库本身一样重要。这样的服务器已经不是无状态。
- XA需要和各种数据系统保持兼容,他最终是多系统可兼容的最低标准。
- 参与者如果任何部分发生故障,整个事务只能失败。所以分布式事务有扩大事务失败的风险,这与我们构建容错系统的目标有些背道而驰。
支持容错的共识
通俗理解,共识就是让几个节点就某项提议达成一致。 共识问题通常形式化描述如下:一个或多个节点可以提议某些值,由共识算法来决定最终值。在这个描述中,共识算法必须满足以下性质:
(1)协商一致性:所有的节点都接受相同的决议 (2)诚实性:所有的节点不能反悔,即对一项决议不能有两次决定 (3)合法性:如果决定了值x,则x一定是由某个节点所提议的。 (4)可终止性:节点如果不崩溃,则最终一定可以达成决议。
1.协商一致性和诚实性定义了共识的核心思想:决定一致的结果,一旦决定,就不能改变。 2.合法性主要是为了排除一些无意义的方案:例如,无论什么建议,都可以有一个总是为空(NULL)的决定,这虽然可以满足一致性和诚实性,但没有任何实际效果。 3.可终止性则引入了容错的思想。它强调一个共识算法不能原地空转,它必须取得实质性进展。即使某些节点出现了故障,其它节点也必须最终做出决定。 4.可终止性是一种活性,而另外三种属于安全性。 5.可终止性的前提是:发生崩溃或者不可用的节点数必须小于半数节点。因为我们可以证明任何共识算法都需要至少大部分节点正确运行才能确保终止性。
共识算法与全序广播
最著名的容错式共识算法包括VSR,Paxos,Raft,Zab。他们并不是直接使用上述的形式化模型。相反,他们是决定了一系列值,然后采用全序关系广播算法。
全序关系广播的要点是,消息按照相同的顺序发送到所有节点,有且只有一次,其实相当于进行了多轮的共识过程。
所以,全序关系广播相当于持续的多轮共识:
- 由于写上一致性,所有的节点决定以相同的顺序发送相同的消息。
- 由于诚实性,消息不能重复。
- 由于合法性,消息不会被破坏
- 由于可终止性,消息不回丢失。
这比重复性的一轮共识只解决一个提议更加有效。
主从复制和共识
主从复制,所有的写入操作都有主节点负责,并以相同的顺序发送到从节点来保持副本更新。这不就是基本的全序关系广播么?
这里设计的共识问题就是如何选择主节点。如果主节点是运营人员手动更新,那么就是独裁性质的“一致性算法”。一些数据库支持主动选择选举节点和故障切换。这样更接近容错式全序关系广播,从而达成共识。
Epoch和quorum
共识算法在内部都使用了某种形式的主节点,虽然主节点并不是固定的。相反,他们都采用了一种弱化的保证:协议定义了一个世代编号,并保证在每一个世代里,主节点是唯一确定的。
如果当前主节点失效,节点就开始一轮投票选举新的主节点。选举会赋予一个单调递增的epoch号。如果出现两个主节点对于不同的,则更高的获胜。
节点如何知道自己被其他节点代替了呢?它必须从quorum节点中收集投票。主节点如果想做出某个决定,必须将提议发送给其他节点,等待quorum节点的相应。
因此这里面实际存在两轮不同的投票:首先决定谁是主节点,然后对主节点的提议进行投票。关键一点是,参与两轮的quorum必须有重叠:如果某个提议获得通过,其中参与投票的节点中必须至少有一个参与了最近一次的主节点选举。
投票的过程很像两阶段提交。最大的区别是,2PC的协调者不是依靠选举;而且大多数节点投票即可通过。
共识的局限性
- 在异步复制的数据库中,可能会丢失数据
- 严格的多数节点才能运行
- 假定一组固定参与投票的节点集,这意味着不能动态添加删除。
- 超时机制检测节点失效不靠谱
- 网络问题非常敏感。例如Raft已被发现存在不合理的边界条处理。
成员与协调服务
ZooKeeper或etcd项目通常被称为"分布式键值存储"或"协调与配置服务"。它们对外提供的API和数据库非常相像:读取、写入对应主键的值、遍历主键。
应用开发者很少直接使用ZooKeeper,绝大多数情况是其他项目间接地依赖于ZooKeeper,如HBase,Hadoop YARN,OpenStack Nova,Kafka等。(ZooKeeper大写的惨!工具人石锤。)
ZooKeeper/etcd可以实现的功能 ZooKeeper/etcd主要针对保存少量、可完全载入内存的数据而设计,所以不要用他们保存大量数据。它们通常采用容错的全序广播算法在所有节点上复制数据从而实现高可靠。 ZooKeeper的实现其实模仿了Google的Chubby分布式锁服务。但它不仅实现了全序广播(因此实现了共识),还提供了很多非常有用的功能:
- 线性化的原子操作
- 操作全序
- 故障检测
- 更改通知
上述特征中,其实只有第一个——线性化的原子操作才依赖于共识。但是ZooKeeper一次性全部集成了这些功能,在分布式协调服务中发挥了关键作用。