上一文提过,分布式系统可能出现各种各样的问题:分布式系统的麻烦
处理故障最简单的方法是让整个服务失效并向用户显示错误消息。更高级的方式是找到容错的方法,让某些内部组件即使出现故障,服务也能正常运行。
构建容错系统的最好方法,是找到一些带有实用保证的通用抽象,实现一次,然后让应用依赖这些保证。分布式系统最重要的抽象之一就是共识(consensus) :就是让所有的节点对某件事达成一致。 一旦达成共识,应用可以将其用于各种目的。然而,由于网络故障和流程故障,可靠地达成共识是一个棘手问题。在本章后面的"分布式事务和共识"中,我们将研究解决共识和相关问题的算法。但首先,我们需要探索可以在分布式系统中提供的保证。
1 一致性保证
在"复制延迟问题"中提到过时序问题:修改数据库可能会由于网络延迟等问题,在不同时间到达两个数据库,导致同一时刻两个数据库副本的数据不一致。大多数复制的数据库至少提供了最终一致性 ,也称收敛(convergence) ,所有的副本最终会收敛到相同的值。
然而这个保证非常弱,由于收敛的时间不确定,收敛前的读操作可能产生预期外的后果。在与只提供弱保证的数据库打交道时,你需要始终意识到它的局限性,错误往往是微妙的,很难找到,也很难测试。本章将探索数据系统的更强一致性模型 ------ 分布式一致性模型。
它们在结构层次上的不同:
- 事务隔离:避免由于同时执行事务而导致的竞争状态
- 分布式一致性:面对延迟和故障时,如何协调副本间的状态。
2 线性一致性
在最终一致 的数据库,如果你在同一时刻问两个不同副本相同的问题,可能会得到两个不同的答案。但如果数据库能假装只有一个副本且所有操作都是原子性的,那问题就不存在了 ------ 这就是线性一致性(linearizability)(也称原子一致性,强一致性,立即一致性、外部一致性)。
在一个线性一致的系统中,只要一个客户端成功完成写操作,所有客户端从数据库中读取数据必须能够看到刚刚写入的值。换句话说,线性一致性是一个新鲜度保证(recency guarantee) 。
2.1 什么使得系统线性一致
图中,每个柱都是由客户端发出的请求,其中柱头是请求发送的时刻,柱尾是客户端收到响应的时刻。图中C仍然在写入中,A读取到了x的值是1,那么B读取到的值必须也是1,A和B之间的箭头说明了这个时序依赖关系。
进一步细化这个时序图,展示每个操作是如何在特定时刻原子性生效的:
图中,cas(x,v∗old,v∗new)⇒rcas(x, v {old}, v {new})⇒rcas(x,v∗old,v∗new)⇒r 表示客户端请求进行原子性的比较与设置 操作(若寄存器x=v{old},x=v{new}然后返回ok,否则返回error)。图中的每个操作都在执行操作时在条柱内用竖线标出。这些标记按顺序连在一起,其结果必须是一个有效的寄存器读写序列(每次读取都必须返回最近一次写入设置的值)。
线性一致性的要求是,操作标记的连线总是按时间(从左到右)向前移动,这也确保了新鲜性保证:一旦新的值被写入或读取,所有后续的读都会看到写入的值,直到它被再次覆盖。
2.2 线性一致性和可序列化
两者是有区别的:
- 可序列化:是事务的隔离属性,确保每个事务在下一个事务开始前完成。
- 现象一致性:对单个对象的新鲜度保证。不会将操作组合为事务,因此也无法阻止写偏差等问题。
当数据库同时提供现象一致性和可串行性时,被称为单副本可串行性。基于"两阶段锁定"的可串行化或"实际串行执行"通常是线性一致的。但"可序列化的快照隔离"不是线性一致的,因为快照内没有比快照更新的写入。
2.3 依赖线性一致性
线性一致性在什么情况下有用呢?
锁定和领导选举
使用单主复制的系统,需要确保领导真的只有一个,否则会发生脑裂。一种选择领导者的方法是使用锁,而这个锁就必须线性一致。如ZooKeeper和etcd之类的协调服务通常用于实现分布式锁和领导者选举,它们使用一致性算法,以容错的方式实现线性一致的操作。
约束和唯一性保证
唯一性约束在数据库中很常见。如果想要确保银行账户余额永远不会为负数,或者不会出售比仓库里的库存更多的物品,或者两个人不会都预定了航班或剧院里同一时间的同一个位置。这些约束条件都要求所有节点都同意一个最新的值(账户余额,库存水平,座位占用率)。
跨信道的时序依赖
A和B同时请求数据并获得了不一样的数据,A对B说:"咱们数据不一样!",这就是系统中存在的额外信道。计算机中也有可能出现这种情况,例如图像缩放器中,Web服务器通过消息队列发送,照片先写入存储服务然后再将缩放器的指令放入消息队列,而消息队列可能比存储服务内部的复制要快,所以缩放器读取图像可能读到旧版本。这个问题是由于Web服务器和缩放器之间存在两个不同信道:文件存储和消息队列,没有线性一致性的保证,两个信道就可能发生竞争。
2.4 实现线性一致的系统
我们当然不可能真的只用一个副本,这样将会没有容错。而使系统容错的最常用方法是复制,我们来回顾一下第五章的方法,看看他们是否满足线性一致性:
-
单主复制(可能线性一致)
- 主库具有用于写入的数据的主副本,而追随者在其他节点上保留数据的备份副本。如果从主库或同步更新的从库读取数据,它们可能(protential) 是线性一致性的。
- 然而从主库读取依赖一个假设,你确定领导是谁。正如在"真理在多数人手中"中所讨论的那样,一个节点很可能会认为它是领导者,如果具有错觉的领导者继续为请求提供服务,可能违反线性一致性。
- 使用异步复制,故障切换时甚至可能会丢失已提交的写入,这同时违反了持久性和线性一致性。
-
共识算法(线性一致)
- 将在本章后面讨论,与单领导者复制类似。共识协议包含防止脑裂和陈旧副本的措施。
-
多主复制(非线性一致)
- 它同时在多个节点上处理写入,并将其异步复制到其他节点。
-
无主复制(也许不是线性一致的)
- 法定人数的读写可以获得强一致性,但是非线性一致性的行为有可能发生。
- 基于时钟的"最后写入胜利"冲突解决方法是非线性的,由于时钟偏差,不能保证时钟的时间戳与实际事件顺序一致。松散的法定人数也破坏了线性一致的可能性。
线性一致性和法定人数
由于网络延迟可变,竞争条件可能出现。
2.5 线性一致性的代价
第五章说过,对多数据中心的复制而言,多主复制通常是理想的选择,但是它却无法保证线性一致性。而单主复制可能线性一致,但是整体可用性却低:当数据中心之间断联时,连接从库数据中心的客户端没法写入,同时没法线性一致读取。
面临的权衡:
- 如果应用需要线性一致性,那么副本与其他副本断联时不能处理请求,请求必须等到网络问题解决,或直接返回错误。无论哪种方式,服务都不可用(unavailable) 。
- 如果应用不需要线性一致性,那么断联的副本也可以独立处理请求(例如多主复制)。在这种情况下,应用可以在网络问题前保持可用,但其行为不是线性一致的。
因此,不需要线性一致性的应用对网络问题有更强的容错能力,这被称为CAP定理。但是CAP只考虑了一个一致性模型和一种故障,没有讨论任何关于网络延迟,死亡节点或其他权衡的事。因此它对于设计系统而言并没有实际价值,所以最好避免使用CAP。
虽然线性一致是一个很有用的保证,但由于线性一致的速度很慢,因此线性一致系统很少。
3 顺序保证
线性一致寄存器的行为就好像只有单个数据副本一样,且每个操作似乎都是在某个时间点以原子性的方式生效的,这个定义意味着操作是按照某种良好定义的顺序执行的。
顺序(ordering) 这一主题在本书中反复出现,这是一个重要的基础概念
- 第五章:领导者在单主复制中的主要目的就是,在复制日志中确定写入顺序(order of write) 。
- 第七章:可序列化 ,是关于事务表现的像按某种序列顺序(some sequential order) 执行的保证。
- 第八章:使用时间戳和时钟是一种将顺序引入无序世界的尝试。
顺序,线性一致性和共识之间有着深刻的联系,这个概念可以明确系统的能力范围。
3.1 顺序与因果
顺序有助于保持因果关系(causality) 。因果关系对事件施加了一种顺序:因在果之前,例如消息发送在消息收取之前。一件事会导致另一件事,这些因果依赖的操作链定义了系统中的因果顺序,即什么在什么之前发生。
如果一个系统服从因果关系所规定的顺序,我们说它是因果一致(causally) 的。
因果顺序不是全序的
全序(total order) 允许任意两个元素进行比较,而偏序(partially order) 的元素无法比较。
例如,自然数集是全序的:给定两个自然数,可以告诉我哪个大哪个小。而数学集合是偏序的:{a,b}和{b,c}无法比较(除非存在包含关系)。而在线性一致的系统中,操作是全序的,我们总是能判定哪个操作先发生。
因果性:如果两个事件是因果相关的,则它们之间是有序的,但如果它们是并发的,则它们之间的顺序是无法比较的。换句话说,如果两个操作都没有在彼此之前发生,那么这两个操作是并发的。换而言之,有因果相关的事件是全序的,并发事件是偏序的。因此,线性一致的数据存储中不存在并发操作,必须有且仅有一条时间线,所有的操作都在这条时间线上,构成一个全序关系。并发意味着时间线会分岔然后合并 ------ 在这种情况下,不同分支上的操作是无法比较的。
线性一致性强于因果一致性
答案是线性一致性隐含着(implies) 因果关系:任何线性一致的系统都能正确保持因果性。线性一致性并不是保持因果性的唯一途径,一个系统可以是因果一致的,而无需承担线性一致带来的性能折损。
在许多情况下,看上去需要线性一致性的系统,实际上需要的只是因果一致性,因果一致性可以更高效地实现。在所有不会被网络延迟拖慢的一致性模型中,因果一致性是可行的最强的一致性模型,而且在网络故障时仍能保持可用。
在许多情况下,看上去需要线性一致性的系统,实际上需要的只是因果一致性,因果一致性可以更高效地实现。
捕获因果关系
为了维持因果性,我们需要知道操作的顺序。并发操作可以以任意顺序进行,但如果一个操作发生在另一个操作之前,那它们必须在所有副本上以那个顺序被处理。因此,当一个副本处理一个操作时,它必须确保所有因果前驱的操作已经被处理。
我们需要确定因果依赖。可以推广版本向量以解决此类问题:为了确定因果顺序,数据库需要知道应用读取了哪个版本的数据。如"可序列化的快照隔离(SSI)"中所述:当事务要提交时,数据库将检查它所读取的数据版本是否仍然是最新的。为此,数据库跟踪哪些数据被哪些事务所读取。
3.2 序列号顺序
在实际场景中,跟踪所有的因果关系是不切实际的。我们可以用序列号(sequence nunber) 或时间戳(timestamp) 来排序事件。时间戳可以来自逻辑时钟。这样的序列号或时间戳是紧凑的(只有几个字节大小),且提供了一个全序关系。
非因果序列号生成器
如果主库不存在(可能因为使用了多主数据库或无主数据库,或者因为使用了分区的数据库),如何为操作生成序列号就没有那么明显了。在实践中有各种各样的方法:
- 每个节点都可以生成自己独立的一组序列号。同时确保两个节点不会生成相同的序列号(例如在二进制中预留一些位,或一个节点奇数一个节点偶数序列)。
- 将时钟(物理时钟)时间戳附加到每个操作上。
- 预先分配序列号区块,然后每个节点独立分配所属区块中的序列号
然而,他们都有一个问题:生成的序列号和因果不一致,他们不能正确的捕获跨节点的操作顺序:
- 奇数节点可能落后于偶数计数器,或反之。
- 来自物理时钟的时间戳会受到时钟偏移的影响。
- 1到1000之间的序列号肯定是比1001到2000之间的序列号好的。
兰伯特时间戳
这是一种用来产生因果关系一致的序列号的方法。每个节点都有一个唯一标识符,和一个保存自己执行操作数量的计数器。兰伯特时间戳就是两者的简单组合:(计数器,节点ID)(counter,nodeID)(counter, node ID)(counter,nodeID)。两个节点有时可能具有相同的计数器值,但通过在时间戳中包含节点ID,每个时间戳都是唯一的。
如果你有两个时间戳,则计数器值大者是更大的时间戳。如果计数器值相同,则节点ID越大的,时间戳越大。
光有时间戳排序还不够
虽然兰伯特时间戳定义了一个与因果一致的全序,但它还不足以解决分布式系统中的许多常见问题。
例如,两个用户同时注册相同的用户名,那么一个应该失败,另一个应该成功。此时节点需要马上决定这个请求成功或失败,为了确保没有其他节点正在使用相同的用户名和较小的时间戳并发创建同名账户,就必须检查其他的每个节点,看看他们在做什么。
这里的矛盾是:只有收集所有操作才能出现全序,但是我们并不能将其他节点正在做的操作收集,所以无法构造构造所有操作的最终全序关系,因为来自另一个节点的未知操作可能需要被插入到全序中的不同位置。
所以,在类似这种场景中,仅有操作的全序是不够的。
3.3 全序广播
全序广播(total order broadcast) 又称原子广播,用于同一个全局操作的顺序达成一致,常被描述为在节点间交换消息的协议
它两个安全属性:
- 可靠交付:没有消息丢失。如果消息被传递到一个节点,他将被传递到所有节点。
- 全序交付:消息以相同顺序传递给每个节点。
正确的全序广播算法必须始终保证可靠性和有序性,即使节点或网络出现故障。当然在网络中断的时候,消息是传不出去的,但是算法可以不断重试,以便在网络最终修复时,消息能及时通过并按顺序送达。
使用全序广播
像ZooKeeper和etcd这样的共识服务实际上实现了全序广播,全序广播和共识之间有着紧密联系。
全序广播正是数据库复制所需的:若每个副本都按相同顺序处理相同写入,那么每个副本终将保持一致,这被称为状态机复制(state machine replication) 。这代表我们可以使用全序广播实现可序列化的事务。
全序广播的一个重要表现是,顺序在消息送达时被固化。全序广播就像一种创建日志的方式,传递消息就像附加写入日志,所有节点必须以相同顺序传递相同消息,所有节点可以读取日志并看到相同的消息序列。
全序广播对于实现提供防护令牌的锁服务也很有用。每个获取锁的请求都作为一条消息追加到日志末尾,并且所有的消息都按它们在日志中出现的顺序依次编号。序列号可以当成防护令牌用,因为它是单调递增的。在ZooKeeper中,这个序列号被称为zxid
。
使用全序广播实现线性一致的存储
全序广播是异步的:消息保证以固定顺序可靠送达,但不保证何时送达。而现象一致性保证新鲜性:读取一定能看见最新的写入值。有了全序广播,我们可以在此基础上构建线性一致的存储。
例如,你可以确保用户名能唯一标识用户帐户。设想对于每一个可能的用户名,你都可以有一个带有CAS原子操作的线性一致寄存器。每个寄存器最初的值为空值(表示不使用用户名)。当用户想要创建一个用户名时,对该用户名的寄存器执行CAS操作,在先前寄存器值为空的条件,将其值设置为用户的账号ID。如果多个用户试图同时获取相同的用户名,则只有一个CAS操作会成功,因为其他用户会看到非空的值(由于线性一致性)。
可以将全序广播当成仅追加日志来实现这种线性一致的CAS操作:
- 日志中追加一条消息,试探性地指明你要声明的用户名。
- 读日志,并等待你所附加的信息被回送。
- 检查是否有任何消息声称目标用户名的所有权。如果这些消息中的第一条就你自己的消息,那么你就成功了。如果所需用户名的第一条消息来自其他用户,则中止操作。
由于日志项是以相同顺序送达至所有节点,因此如果有多个并发写入,则所有节点会对最先到达者达成一致,以此确定所有节点对某个事务的处理结果。类似的方法可以在一个日志的基础上实现可序列化的多对象事务。
然而,尽管这一过程保证写入是线性一致的,但它并不保证读取也是线性一致的,如果你从与日志异步更新的存储中读取数据,结果可能是陈旧的(这是顺序一致性,比线性一致性稍弱)。
为了使读取也线性一致,我们可以:
- 通过追加一条消息,当消息回送时读取日志,执行实际的读取。
- 若日志允许以线性一致的方式获取最新日志消息的位置,则可以查询该位置,等待直到该位置前的所有消息都传达到你,然后执行读取。
- 从同步更新的副本中进行读取,因此可以确保结果是最新的。
使用线性一致性存储实现全序广播
最简单的方法是假设你有一个线性一致的寄存器来存储一个整数,并且有一个原子自增并返回操作。
该算法很简单:每个要通过全序广播发送的消息首先对线性一致寄存器执行自增返回操作,然后将从寄存器获得的值作为序列号附加到消息中。然后你可以将消息发送到所有节点(重新发送任何丢失的消息),而收件人将按序列号连续发送消息。
与兰伯特时间戳不同,通过自增线性一致性寄存器获得的是没有间隙的序列。例如,一个节点收到了消息4和消息6,那么在传递消息6之前必须先等待消息5。事实上,这是全序广播和时间戳排序间的关键区别。
通过深入思考我们能发现,线性一致的CAS(或自增并返回)寄存器与全序广播都都等价于共识问题。也就是说,如果你能解决其中的一个问题,你可以把它转化成为其他问题的解决方案。因此,我们将在本章其余部分讨论共识问题。
4 分布式事务与共识
共识是分布式计算中最重要也是最基本的问题之一。即让几个节点达成一致,这在很多场景下很重要:
- 领导选举:单主复制的数据库中,所有节点要对哪个节点是领导者达成一致共识,以应对故障切换。
- 原子提交:让所有节点对事务的结果达成一致,要么全部回滚要么全部提交。
4.1 原子提交与两阶段提交(2PC)
从单节点到分布式原子提交
单个节点中,原子性通常由存储引擎实现。请求提交事务时,数据库将写入持久化后将提交记录追加到日志,若期间数据库崩溃,那么重启后事务会从日志中恢复。
但是,当事务涉及多个节点时,仅向所有节点发送提交请求并独立提交每个节点的事务是不够的,这很容易导致提交在某些节点上失败:
- 某些节点中检测到约束冲突或冲突。
- 某些请求在网络中丢失,最终由于超时而中止,而其他请求则通过。
- 在提交记录完全写入之前,某些节点可能会崩溃,并在恢复时回滚,而 其他节点却成功提交。
若某些节点提交了事务,其他节点却放弃了这些事务,那么这些节点就会彼此不一致。而且若事务在一些节点上提交成功却在其他节点上中止,提交也是无法撤回的。所以一旦确定事务中所有其他节点也将提交,那么节点就必须进行提交。
事务提交必须是不可撤销的,因为数据一旦被提交,其他客户端就可能会开始依赖这些数据。这个原则构成了读已提交隔离等级的基础。
两阶段提交(2PC)简介
一种用于实现跨多个节点的原子事务提交的算法,即确保所有节点提交或所有节点中止。它是分布式数据库中的经典算法。下图说明了2PC的基本流程。2PC中的提交/中止过程分为两个阶段(因此而得名),而不是单节点事务中的单个提交请求。
不要把2PC和2PL搞混了。2PC是两阶段提交提供原子提交。2PL是两阶段锁定,提供可序列化的隔离等级。
2PC使用一个新组件:协调者(coordinator) (也称事务管理器)。协调者通常在请求事务的相同应用进程中以库的形式实现(例如,嵌入在J2EE容器中),也可以是单独的进程或服务。
正常情况下,2PC事务以应用在多个数据库节点上读写数据开始,我们称这些数据库节点为参与者。协调者的工作氛围两个阶段,当应用准备提交时:
- 发送一个准备(prepare) 请求到每个节点,询问它们是否能够提交。然后协调者会跟踪参与者的响应。
- 若所有参与者都回答"是",则协调者发出提交(commit) 请求。否则协调者向所有节点发送中止(abort) 请求。
系统承诺
详细的分解整个过程:
- 当应用想要启动一个分布式事务时,它向协调者请求一个事务ID。此事务ID是全局唯一的。
- 应用在每个参与者上启动单节点事务,并在单节点事务上捎带上这个全局事务ID。
- 当应用准备提交时,协调者向所有参与者发送一个准备请求,并打上全局事务ID的标记。如果任意一个请求失败或超时,则协调者向所有参与者发送针对该事务ID的中止请求。
- 参与者收到准备请求时,向协调者回答是否能确保在任意情况下都可以提交事务。
- 协调者收到所有准备请求的答复后,根据是否所有参与者都回答"是"做出提交或中止决定,然后将决定写入磁盘上的事务日志中(提交点)。
- 一旦协调者的决定落盘,提交或放弃请求会发送给所有参与者。如果这个请求失败或超时,协调者将一直重试直到成功为止。
该协议包含两个关键的"不归路":参与者回答"是",那么它就肯定得能提交;协调者做出决定后,这个决定不能撤销。这两条保证了2PC的原子性。
协调者失效
若协调者在发送准备 请求之前失效,参与者可以安全地中止事务。但是,当参与者回答"是"后,就必须等待协调者的回答,此时若协调者崩溃故障了,参与者就什么都做不了只能等待。这种事务状态称为存疑(in doubt) 或不确定。
如下图的例子,协调者决定提交,DB2收到提交请求,但是DB1没收到,此时DB1就只能干等着:如果超时而单方面中止,DB2可能执行的是提交,那么两个数据库就不一致,当然,单方面提交更寄。
原则上,参与者可以互相沟通以达成一致,但这不是2PC协议的一部分。2PC协议只允许参与者等待协调者恢复。
协调者恢复后,日志中没有提交记录的事务都会中止,因此,2PC的提交点归结为协调者上的常规单节点原子提交。
三阶段提交
两阶段提交被称为阻塞原子提交协议。理论上,可以使一个原子提交协议变为非阻塞的,以便在节点失败时不会卡住。3PC算法可以达成非阻塞原子提交,但是它假定网络延迟有界,这在大多数无限网络延迟和进程暂停的实际系统中不能保证原子性。同时,它需要一个完美的故障检测器,在无限网络延迟的网络中,超时不是一种可靠的故障检测机制。因此,2PC算法虽然有问题但是已经是最优解。
4.2 实践中的分布式事务
分布式事务难以实现安全保证,由于运维问题容易造成性能下降,且某些实现会带来严重的性能损失(例如MySQL分布式事务比单点事务慢10倍以上)
但我们不应该直接忽视分布式事务,而应当从中汲取经验教训。
-
数据库内的分布式事务
- 一些分布式数据库支持数据库节点之间的内部事务,例如VoltDB和MySQL Cluster的NDB存储引擎。
-
异构分布式事务
- 异构事务中,参与者是两种或以上不同的技术:例如来自不同供应商的两个数据库,甚至是非数据库系统(如消息代理)。
- 数据库内部事务不必与任何其他系统兼容,因此它们可以使用任何协议,并针对特定技术进行优化,因此数据库内部的分布式事务通常做的很好。
恰好一次的消息处理
异构的分布式事务处理能集成不同的系统:当且仅当用于处理消息的数据库事务成功提交时,消息队列中的一条消息可以被确认为已处理。这通过在同一个事务中原子提交消息确认 和数据库写入两个操作来实现。
若消息传递或数据库事务失败,两者都会中止,因此消息代理可以在稍后安全的重传消息。因此,通过原子提交消息处理及其副作用 ,即使在成功之前需要几次重试,也可以确保消息被有效地(effectively) 恰好处理一次。中止会抛弃部分完成事务所导致的任何副作用。
注意,只有当所有系统都使用同样的原子提交协议时,这样的分布式事务才是可用的。
XA事务
X/Open XA (扩展架构(eXtended Architecture) 的缩写)是跨异构技术实现两阶段提交的标准。于1991年推出并得到广泛实现,许多传统关系数据库和消息代理都支持XA。
XA不是网络协议,而是用于与事务协调者连接的API。XA假定应用使用网络驱动或客户端来与参与者进行通信。若驱动支持XA,那么XA API会查明操作是否为分布式事务的一部分,若是,则将必要的信息发给数据库服务器,驱动还会向协调者暴露回调接口,协调者可以通过回调来要求参与者准备、提交或中止。
事务协调者需要实现XA API。协调者通常只是一个库而不是单独的服务,它倍加载到发起事务的应用的同一个进程中。它在事务中跟踪所有的参与者,并在要求它们准备之后通过驱动回调收集参与者的响应,最后使用本地磁盘上的日志记录每次事务的决定(提交或中止)。
若应用或机器崩溃,协调者也会失效,导致准备了但未提交事务的参与者在疑虑中卡死。由于协调程序的日志在服务器的本地磁盘上,所以必须重启该服务器,且协调程序库必须读取日志以恢复每个事务的决定,以使用数据库驱动的XA回调来要求参与者提交或中止。数据库服务器不能直接联系协调者,因为所有通信都必须通过客户端库。
怀疑时持有锁
为什么我们这么关心存疑事务?系统的其他部分就不能继续正常工作,无视那些终将被清理的存疑事务吗?
问题在于锁(locking) 。数据库事务通常获取待修改的行上的行级排他锁,以防止脏写(读已提交)。如果要使用可序列化的隔离等级,则使用两阶段锁定的数据库也必须为事务所读取的行加上共享锁(2PL)。
在事务提交或中止之前,数据库不能释放这些锁。因此,使用两阶段提交时,事务必须在整个存疑期间持有这些锁,直到协调者恢复。若协调者日志丢失,那么锁将被永久持有,直到运维人员过来解决。这可能会导致大面积不可用,直到存疑事务被解决。
从协调者故障中恢复
实践中,孤立(orphaned) 的存疑事务可能出现,这会导致协调者无法确定事务的结果,从而阻塞持有锁并其他事务。
而在2PC的正确实现中,即使重启也会保留存疑事务的锁从而保持原子性。这种情况只能让运维手动决定提交还是回滚,再将结果应用于其他参与者。
许多XA有一个叫做启发式决策(heuristic decistions) 的紧急逃生舱口:允许参与者单方面决定放弃或提交一个存疑事务而无需协调者做出决定。这样会破坏原子性但总比引发灾难好。
分布式事务的限制
XA事务的优点:解决了保持多个参与者相互一致的问题;缺点:带来了严重的运维问题
事务协调者本身就是一种存储了事务结果的数据库,因此要像对其他重要数据库一样小心地打交道:
- 如果协调者没有复制,而是只在单台机器上运行,那么它是整个系统的失效单点:它的失效会导致其他应用服务器阻塞再存疑事务持有的锁上。
- 应用服务器本来可随意按需添加删除,但是如果有协调者就会改变部署的性质:应用服务器不再是无状态的了。
- 由于XA需要兼容各种数据系统,因此必须是所有系统的"最小公分母"。
- 对于数据库内部的分布式事务(不是XA)限制比较小。例如,分布式版本的SSI 是可能的。然而仍然存在问题:2PC成功提交一个事务需要所有参与者的响应,因此若系统有部分损坏,事务也会失效,从而导致扩大失效。
11和12章会教我们在没有异构分布式事务的痛苦的情况下实现几个系统的一致性。接下来我们先概况一下共识问题。
4.3 容错共识
共识即让几个节点就某事达成一致,共识算法可以用来确定这些互不相容(mutually incompatible) 的操作中,哪一个才是赢家。
共识问题中,一个或多个节点可以提议(propose) 某些值,而共识算法决定(decides) 采用其中的某个值。
在这种形式下,共识算法必须满足以下性质:这种共识的特殊形式被称为统一共识(uniform consensus) ,相当于在具有不可靠故障检测器的异步系统中的常规共识(regular consensus) 。
- 一致同意:没有两个节点的决定不同。
- 完整性:没有节点决定两次。
- 有效性:如果一个节点决定了值
v
,则v
由某个节点所提议。 - 终止:由所有未崩溃的节点来最终决定值。
一致同意 和完整性 属性定义了共识的核心思想:所有人都决定了相同的结果,一旦决定了,你就不能改变主意。有效性 属性主要是为了排除平凡的解决方案:例如,无论提议了什么值,你都可以有一个始终决定值为null
的算法。该算法满足一致同意和完整性属性,但不满足有效性属性。终止属性正式形成了容错的思想。实质是:一个共识算法不能等死,部分节点出现故障,其他节点也得达成项决定。
终止是一种活属性,另外三种是安全属性(参见"安全性和活性")
共识的系统模型假设,当一个节点"崩溃"时,它会突然消失而且永远不会回来。在这个系统模型中,任何需要等待节点恢复的算法都不能满足终止属性(比如2PC)。
任何共识算法都需要至少占总体多数(majority) 的节点正确工作,以确保终止属性。多数可以安全地组成法定人数。然而即使多数节点出现故障或存在严重的网络问题,绝大多数共识的实现都能始终确保安全属性得到满足(一致同意,完整性和有效性)。
大多数共识算法假设不存在拜占庭式错误,若一个节点没有正确地遵循协议,它就可能会破坏协议的安全属性。因此只要少于一定比例的节点存在拜占庭故障,就不会破坏共识。
共识算法和全序广播
最著名的容错共识算法是视图戳复制(VSR, viewstamped replication) 、Paxos、Raft和Zab。大多数算法不直接使用形式化的模型,而是决定了顺序(sequence) ,这使它们成为全序广播算法。
所以,全序广播相当于重复进行多轮共识:
- 一致同意:所有节点决定以相同的顺序传递相同的消息。
- 完整性:消息不会重复。
- 有效性:消息不会被损坏,也不能凭空编造。
- 终止:消息不会丢失。
视图戳复制,Raft和Zab直接实现了全序广播,因为这样做比重复一次一值(one value a time) 的共识更高效。在Paxos的情况下,这种优化被称为Multi-Paxos。
单领导者复制和共识
第五章中讨论的"领导者和追随者",实际上也是一个全序广播,为什么我们在第五章里一点都没担心过共识问题呢?这是因为主库是由运维人员手动选择和配置的,实际上是一种独裁类型的"共识算法":只有一个节点被允许写入,若节点故障则系统无法写入,这无法满足共识的终止属性,它的恢复需要运维手动配置其他节点作为主库。
有些数据库会自动提拔一个新领导者,但是由于脑裂问题,领导者需要达成共识,这里的共识是一种全序广播算法,并且全序广播算法就像单主复制,这样就会陷入鸡生蛋的问题:要选出一个领导者,我们首先需要一个领导者。要解决共识问题,我们首先需要解决共识问题。
时代编号和法定人数
迄今为止所讨论的所有共识协议, 在内部都以某种形式使用一个领导者,但他们不能保证领导者独一无二。
相反,它们可以做出更弱的保证:协议定义了一个时代编号(epoch number)(在Paxos中称为投票编号(ballot number),视图戳复制中的视图编号(view number),以及Raft中的任期号码(term number)),并确保在每个时代中,领导者都是唯一的。
当现任领导被认为挂掉的时候,节点间投票选出一个新领导。时代编号全序递增,若领导者出现冲突,由编号更高的领导者说了算。
领导者被允许决定前,要先检查是否有更新的领导者。领导者从法定人数的节点中获取选票,对领导者想要做的每一个决定,都要将提议值发给其他节点,等法定人数的的节点响应赞成提案。在没有发现有更高的领导者的情况下,一个节点才会投同意票。
因此,有两次投票:选举(选出一位领导者)、表决(对领导者的提议进行表决)。两次投票的法定人群必须重叠,若提案的表决通过,至少得有一个投票的节点参与过选举,因为参与过选举的节点才知道有没有新的领导。
这一投票过程表面上像两阶段提交,但是2PC中协调者不是由选举产生,且2PC要求所有参与者投赞成票。容错共识还定义了一个恢复过程,节点可以在选举出新的领导者后进入一个一致状态以确保满足安全属性。
共识的局限性
共识未分布式系统带来了基础的安全属性(一致同意,完整性和有效性),同时保持容错。
但是共识算法也有局限性:
- 节点做出决定前对提议进行投票是一种同步复制,在同步复制中发生故障切换时可能造成已提交的数据丢失(参见"同步与异步复制")。
- 节点至少要有三个(两个构成多数),如果网络故障切断了节点间的连接,则只有多数节点所在的网络可以继续工作,其余部分将被阻塞。
- 大多数共识算法假定节点数是固定的,那么我们不能简单地添加或删除节点(共识算法的动态成员扩展可以解决这个问题)。
- 共识系统依赖超时来检测失效节点,可能导致错误的认为领导者已经失效,导致性能不佳。
- 共识算法对网络问题特别敏感,例如Raft可能因为网络问题进入领导者不断辞职然后二人转的问题。
4.4 成员与协调服务
像ZooKeeper或etcd这样的项目通常被描述为"分布式键值存储"或"协调与配置服务"。这种服务的API看起来非常像数据库:能读写遍历键值。那么是上面使他们区别于数据库呢?
ZooKeeper和etcd被设计为容纳少量可以放在内存中的数据(虽然它们仍然会写入磁盘以保证持久性),这些少量数据会通过容错的全序广播算法复制到所有节点上。
ZooKeeper模仿了Google的Chubby锁服务,实现了全序广播(因此也实现了共识)又构建了一组有趣的其他特性:
- 线性一致性的原子操作:使用原子CAS操作实现锁,多个节点尝试相同操作时只有一个节点会成功,共识协议保证了操作的原子性和线性一致性。分布式锁通常以租约(lease) 的形式实现。
- 操作的全序排序:某个资源受到锁或租约保护时,需要防护令牌来防止客户端在进程暂停的情况下彼此冲突(参见"领导者与锁定")。防护令牌是每次被锁获取时单调增加的数字。ZooKeeper通过全局排序操作提供这个功能,为每个操作提供事务id(zxid)和版本号(cversion)。
- 失效检测:客户端中在ZooKeeper服务器上维护一个长期会话,客户端和服务器周期性交换心跳包检查节点活性。若心跳停止的持续时间超出会话超时,ZooKeeper会宣告该会话已死亡。
- 变更通知:客户端不仅可以读取其他客户端创建的锁和值,还可以监听其变更。客户端可以知道另一个客户端加入集群的时间(基于新客户端写入ZooKeeper的值),以及是否发生故障(根据其会话超时)。通过订阅通知,客户端就不用频繁轮询了。
将工作分配给节点
在ZooKeeper/Chubby模型中:
- 若我们有几个进程实例或服务,那么需要选择其中一个实例作为主库或首选服务。如果领导者失败,其他节点之一应该接管。这个功能对单主数据库非常实用。
- 当我们有一些分区资源并需要决定怎么分配时,当新节点加入集群时,需要将某些分区从现有节点移动到新节点以便重新平衡负载(参阅"重新平衡分区")。
应用最初只能在单个节点上运行,但最终可能增长到数千个节点,这会导致投票变得低效。ZooKeeper在固定数量的节点上运行并执行其多数票,同时支持潜在的大量客户端。因此,ZooKeeper提供了一种将协调节点的一些工作"外包"到外部服务的方式。
服务发现
服务发现:找出你需要连接到哪个IP地址才能到达特定的服务。
ZooKeeper,etcd和Consul也经常用于服务发现。在云数据中心环境中,虚拟机连续来去常见,你通常不会事先知道服务的IP地址。通常先配置服务,使其在启动时注册服务注册表中的网络端点,然后可以由其他服务找到它们。
但是,服务发现是否需要达成共识还不太清楚。DNS是查找IP地址的传统方式,但不是线性一致的。
若共识系统已经知道领导是谁,也可以使用这些信息来帮助其他服务发现领导是谁。为此,一些共识系统支持用只读的缓存副本异步接收共识算法所有决策的日志,但不主动参与投票。因此,它们能够提供不需要线性一致性的读取请求。
成员服务
成员资格服务确定哪些节点当前处于活动状态并且是群集的活动成员。
由于无限的网络延迟,无法可靠地检测到另一个节点是否发生故障。但是,如果你通过一致的方式进行故障检测,那么节点可以就哪些节点应该被认为是存在或不存在达成一致。(参见第八章)
即使有成员资格服务,仍然可能让一个节点被共识误判为死亡。