共识(consensus) :就是让所有的节点对某件事达成一致。
构建容错系统的最好方法,就是找到一些带有使用保证的通用抽象,实现一次,然后让应用依赖这些保证。共识是分布式系统最重要的抽象之一。
一致性保证
由于复制延迟问题,无论数据库采用何种复制方法(单主、多主或无主复制),都会出现不一致的情况。
大多数复制的数据库至少提供了最终一致性,即最终所有的读请求都会返回相同的值。最终一致性保证所有的副本最终会收敛到相同的值。
但最终一致性是一个很弱的保证——它并没有说什么时候副本会收敛。
在与只提供弱保证的数据库打交道时,需要始终意识到它的局限性,而不是意外地做出太多假设。
分布式一致性模型与事务隔离级别的层次结构有一些相似之处。尽管两者有一部分内容重叠,但它们大多是无关的问题:事务隔离主要是为了 避免由于同时执行事务而导致的竞争状态,而分布式一致性主要关于 在面对延迟和故障时如何协调副本间的状态。
本章主要内容:
- 常用的 最强一致性模型 之一,线性一致性(linearizability) ,并考察其优缺点。
- 分布式系统中 事件顺序 的问题,特别是因果关系和全局顺序的问题。
- 如何原子地提交分布式事务,这将最终引领我们走向共识问题的解决方案。
线性一致性
线性一致性(linearizability)的基本想法是:让一个系统看起来好像只有一个数据副本,而且所有的操作都是原子性的。有了这个保证,即使实际中可能有多个副本,应用也不需要担心它们。
线性一致性 也称为 原子一致性(atomic consistency) 【7】,强一致性(strong consistency) ,立即一致性(immediate consistency) 或 外部一致性(external consistency )
在一个线性一致的系统中,只要一个客户端成功完成写操作,所有客户端从数据库中读取数据必须能够看到刚刚写入的值。要维护数据的单个副本的假象,系统应保障读到的值是最近的、最新的,而不是来自陈旧的缓存或副本。换句话说,线性一致性是一个 新鲜度保证(recency guarantee) 。
例子:Alice和Bob在同一个房间里,都各自盯着各自的手机,关注者球赛的结果。在最后得分公布后,Alice 刷新页面,看到宣布了获胜者,并兴奋地告诉 Bob。Bob 难以置信地刷新了自己的手机,但他的请求路由到了一个落后的数据库副本上,手机显示比赛仍在进行。
图 9-1 这个系统是非线性一致的,导致了球迷的困惑
如果 Alice 和 Bob 在同一时间刷新并获得了两个不同的查询结果,也许就没有那么令人惊讶了。因为他们不知道服务器处理他们请求的精确时刻。然而 Bob 是在听到 Alice 惊呼最后得分 之后,点击了刷新按钮(启动了他的查询),因此他希望查询结果至少与爱丽丝一样新鲜。但他的查询返回了陈旧结果,这一事实违背了线性一致性的要求(让系统看起来好像只有一个数据副本)。
什么使得系统线性一致?
分布式系统中的寄存器(x),它可以键值存储中的一个键,关系型数据库中的一行,或文档数据库中的一个文档。
图 9-2 如果读取请求与写入请求并发,则可能会返回旧值或新值
寄存器(x)有3中类型的操作:
- read(x) ⇒ v 表示客户端请求读取寄存器
x的值,数据库返回值v - write(x,v) ⇒ r 表示客户端请求将寄存器
x设置为值v,数据库返回响应r(可能正确,可能错误) - cas(x,v_old,v_new) ⇒ r 表示客户端请求进行原子性的**比较与设置**操作。如果寄存器 x 的当前值等于v_old ,则应该原子地设置为v_new。如果x≠v_old ,则操作应该保持寄存器不变并返回一个错误。r 是数据库的响应(正确或错误)。
在 图 9-2 中,x 的值最初为 0,客户端 C 执行写请求将其设置为 1。发生这种情况时,客户端 A 和 B 反复轮询数据库以读取最新值。 A 和 B 的请求可能会收到怎样的响应?
- 客户端 A 的第一个读操作,完成于写操作开始之前,因此必须返回旧值
0。 - 客户端 A 的最后一个读操作,开始于写操作完成之后。如果数据库是线性一致性的,它必然返回新值
1:因为读操作和写操作一定是在其各自的起止区间内的某个时刻被处理。如果在写入结束后开始读取,则读取处理一定发生在写入完成之后,因此它必须看到写入的新值。 - 与写操作在时间上重叠的任何读操作,可能会返回
0或1,因为我们不知道读取时,写操作是否已经生效。这些操作是 并发(concurrent) 的。
但是,这还不足以完全描述线性一致性:如果与写入同时发生的读取可以返回旧值或新值,那么读者可能会在写入期间看到数值在旧值和新值之间来回翻转。这不是我们所期望的仿真 “单一数据副本” 的系统。
💡 如果读取(与写入同时发生时)可能返回旧值或新值,则称该寄存器为 常规寄存器(regular register)为了使系统线性一致,我们需要添加另一个约束,如 图 9-3 所示
图 9-3 任何一个读取返回新值后,所有后续读取(在相同或其他客户端上)也必须返回新值。
如果一个客户端读取返回新的值,即使写操作尚未完成,所有后续读取也必须返回新值。
图 9-4 可视化读取和写入看起来已经生效的时间点。 B 的最后读取不是线性一致性的
这就是线性一致性背后的直觉。 通过记录所有请求和响应的时序,并检查它们是否可以排列成有效的顺序,以测试一个系统的行为是否线性一致性是可能的(尽管在计算上是昂贵的)。
线性一致与可串行化的区别
线性一致性 容易和 可串行化 相混淆,因为两个词似乎都是类似 “可以按顺序排列” 的东西。但它们是两种完全不同的保证,区分两者非常重要:
- 可串行化(Serializability) 是事务的隔离属性,每个事务可以读写多个对象(行,文档,记录)。它确保事务的行为,与它们按照 某种 顺序依次执行的结果相同(每个事务在下一个事务开始之前运行完成)。这种执行顺序可以与事务实际执行的顺序不同。
- 线性一致性(Linearizability) 是读取和写入寄存器(单个对象)的 新鲜度保证。它不会将操作组合为事务,因此它也不会阻止写入偏差等问题,除非采取其他措施。
依赖线性一致
在某些领域,线性一致是系统正确工作的一个重要条件。
锁定和领导选举
在单主复制的系统中,需要确保领导者真的只有一个,而不会几个(脑裂)。一种选择领导者的方法是使用锁:每个节点在启动后尝试获取锁,成功者成为领导者。不管这个锁是如何实现的,它必须是线性一致的:所有节点必须就哪个节点拥有锁达成一致,否则就没用了。
诸如 Apache ZooKeeper 和 etcd 之类的协调服务通常用于实现分布式锁和领导者选举。它们使用一致性算法,以容错的方式实现线性一致的操作。线性一致性存储服务是这些协调任务的基础。
💡 严格地说,ZooKeeper 和 etcd 提供线性一致性的写操作,但读取可能是陈旧的,因为默认情况下,它们可以由任何一个副本提供服务。你可以选择请求线性一致性读取:etcd 称之为 **法定人数读取(quorum read)** ,而在 ZooKeeper 中,你需要在读取之前调用 `sync()`约束和一致性保证
唯一性约束在数据库中很常见,如果要在写入数据库时强制执行此约束,则需要线性一致性。
一个硬性的唯一性约束(关系型数据库中常见的那种)需要线性一致性。其他类型的约束,如外界或属性约束,可以不需要线性一致性。
跨信道的时序依赖
图 9-5 Web 服务器和图像缩放器通过文件存储和消息队列进行通信,打开竞争条件的可能性。
如果文件存储服务是线性一致的,那么这个系统应该可以正常工作。如果它不是线性一致的,则存在竞争条件的风险:消息队列(图 9-5 中的步骤 3 和 4)可能比存储服务内部的复制(replication)更快。在这种情况下,当缩放器读取图像(步骤 5)时,可能会看到图像的旧版本,或者什么都没有。如果它处理的是旧版本的图像,则文件存储中的全尺寸图和缩略图就产生了永久性的不一致。
出现这个问题是因为 Web 服务器和缩放器之间存在两个不同的信道:文件存储与消息队列。没有线性一致性的新鲜性保证,这两个信道之间的竞争条件是可能的。
线性一致性并不是避免这种竞争条件的唯一方法,但它是最容易理解的。如果你可以控制额外信道,,则可以使用在 “读己之写” 讨论过的类似方法,不过会有额外的复杂度代价。
实现线性一致的系统
由于线性一致性本质上意味着 “表现得好像只有一个数据副本,而且所有的操作都是原子的”,所以最简单的答案就是,真的只用一个数据副本。但是这种方法无法容错:如果持有该副本的节点失效,数据将会丢失,或者至少无法访问,直到节点重新启动。
使系统容错最常用的方法是使用复制。
-
单主复制(可能线性一致)
如果从主库或同步更新的从库读取数据,它们 可能(potential) 是线性一致性的。然而,实际上并不是每个单主数据库都是线性一致性的,无论是因为设计的原因(例如,因为使用了快照隔离)还是因为在并发处理上存在错误。
💡 对单主数据库进行分区(分片),使得每个分区有一个单独的领导者,不会影响线性一致性,因为线性一致性只是对单一对象的保证。 交叉分区事务是一个不同的问题从主库读取依赖一个假设,你确定地至少领导者是谁,而事实并非如此——如果具有错觉的领导者继续为请求提供服务,可能违反线性一致性。
使用异步复制,故障切换时甚至可能会丢失已提交的写入,这同时违反了持久性和线性一致性。
-
共识算法(线性一致)
共识协议包含防止脑裂和陈旧副本的措施。正是由于这些细节,共识算法可以安全地实现线性一致性存储。例如,Zookeeper 和 etcd 就是这样工作的。
-
多主复制(非线性一致)
具有多主程序复制的系统通常不是线性一致的,因为它们同时在多个节点上处理写入,并将其异步复制到其他节点。因此,它们可能会产生需要被解决的写入冲突(请参阅 “处理写入冲突”)。这种冲突是因为缺少单一数据副本所导致的。
-
无主复制(也许不是线性一致的)
对于无主复制的系统,有时候人们会声称通过要求法定人数读写( w + r > n)可以获得 “强一致性”。这取决于法定人数的具体配置,以及强一致性如何定义(通常不完全正确)。
基于日历时钟(例如,在 Cassandra 中;请参阅 “依赖同步时钟”)的 “最后写入胜利” 冲突解决方法几乎可以确定是非线性一致的,由于时钟偏差,不能保证时钟的时间戳与实际事件顺序一致。宽松的法定人数(请参阅 “宽松的法定人数与提示移交”)也破坏了线性一致的可能性。即使使用严格的法定人数,非线性一致的行为也是可能的,如下节所示。
线性一致和法定人数
直觉上在 Dynamo 风格的模型中,严格的法定人数读写应该是线性一致性的。但是当我们有可变的网络延迟时,就可能存在竞争条件,如图所示:
图 9-6 非线性一致的执行,尽管使用了严格的法定人数
法定人数条件满足( w + r> n),但是这个执行是非线性一致的:B 的请求在 A 的请求完成后开始,但是 B 返回旧值,而 A 返回新值。
通过牺牲性能:读取者必须在将结果返回给应用之前,同步执行读修复,并且写入者必须在发送写入之前,读取法定数量节点的最新状态;可以实现线性一致的读写;但不能实现线性一致的CAS操作,因为它需要一个共识算法。
💡 由于性能损失,Riak 不执行同步读修复【26】。 Cassandra 在进行法定人数读取时,**确实** 在等待读修复完成【27】;但是由于使用了最后写入胜利的冲突解决方案,当同一个键有多个并发写入时,将不能保证线性一致性。线性一致的代价
在单主配置的条件下,如果数据中心之间的网络被中断,则连接到从库数据中心的客户端无法联系到主库,因此它们无法对数据库执行任何写入,也不能执行任何线性一致的读取。它们仍能从从库读取,但结果可能是陈旧的(非线性一致)。如果应用需要线性一致的读写,却又位于与主库网络中断的数据中心,则网络中断将导致这些应用不可用。
如果客户端可以直接连接到主库所在的数据中心,这就不是问题了,那些应用可以继续正常工作。但只能访问从库数据中心的客户端会中断运行,直到网络连接得到修复。
CAP定理
以上这个问题面临的权衡如下:
- 如果应用需要线性一致性,且某些副本因为网络问题与其他副本断开连接,那么这些副本掉线时不能处理请求。请求必须等到网络问题解决,或直接返回错误。(无论哪种方式,服务都 不可用)。
- 如果应用不需要线性一致性,那么某个副本即使与其他副本断开连接,也可以独立处理请求(例如多主复制)。在这种情况下,应用可以在网络问题前保持可用,但其行为不是线性一致的。
因此,不需要线性一致性的应用对网络问题有更强的容错能力。这种见解通常被称为 CAP 定理。
CAP 最初是作为一个经验法则提出的,没有准确的定义,目的是开始讨论数据库的权衡。那时候许多分布式数据库侧重于在共享存储的集群上提供线性一致性的语义,CAP 定理鼓励数据库工程师向分布式无共享系统的设计领域深入探索,这类架构更适合实现大规模的网络服务。 对于这种文化上的转变,CAP 值得赞扬 —— 它见证了自 00 年代中期以来新数据库的技术爆炸(即 NoSQL)。
CAP 定理的正式定义仅限于很狭隘的范围,它只考虑了一个一致性模型(即线性一致性)和一种故障(网络分区 [^vi],或活跃但彼此断开的节点)。它没有讨论任何关于网络延迟,死亡节点或其他权衡的事。 因此,尽管 CAP 在历史上有一些影响力,但对于设计系统而言并没有实际价值。
在分布式系统中有更多有趣的 “不可能” 的结果,且 CAP 定理现在已经被更精确的结果取代,所以它现在基本上成了历史古迹了。
线性一致和网络延迟
牺牲线性一致性的原因是 性能(performance) ,而不是容错。
许多分布式数据库都是如此:它们是 为了提高性能 而选择了牺牲线性一致性,而不是为了容错。线性一致的速度很慢 —— 这始终是事实,而不仅仅是网络故障期间。
能找到一个更高效的线性一致存储实现吗?看起来答案是否定的:Attiya 和 Welch证明,如果你想要线性一致性,读写请求的响应时间至少与网络延迟的不确定性成正比。在像大多数计算机网络一样具有高度可变延迟的网络中,线性读写的响应时间不可避免地会很高。
顺序保证
顺序(ordering)一个重要的基础性概念。之前讨论过顺序的上下文有:
- 领导者在单主复制中的主要目的就是,在复制日志中确定 写入顺序(order of write) —— 也就是从库应用这些写入的顺序。如果不存在一个领导者,则并发操作可能导致冲突
- 可串行化,是关于事务表现的像按 某种先后顺序(some sequential order) 执行的保证。它可以字面意义上地以 串行顺序(serial order) 执行事务来实现,或者允许并行执行,但同时防止序列化冲突来实现(通过锁或中止事务)。
- 在分布式系统中使用时间戳和时钟是另一种将顺序引入无序世界的尝试,例如,确定两个写入操作哪一个更晚发生。
事实证明,顺序、线性一致性和共识之间有着深刻的联系。
顺序与因果关系
顺序 反复出现有几个原因,其中一个原因是,它有助于保持 因果关系(causality) 。
因果关系对事件施加了一种 顺序:因在果之前;消息发送在消息收取之前。
如果一个系统服从因果关系所规定的顺序,我们说它是 因果一致(causally consistent) 的。例如,快照隔离提供了因果一致性:当你从数据库中读取到一些数据时,你一定还能够看到其因果前驱(假设在此期间这些数据还没有被删除)。
因果顺序不是全序的
全序(total order) 允许任意两个元素进行比较,所以如果有两个元素,你总是可以说出哪个更大,哪个更小。例如,自然数集是全序的:给定两个自然数,比如说 5 和 13,那么你可以告诉我,13 大于 5。
偏序(partially order) 的:在某些情况下,可以说一个集合大于另一个(如果一个集合包含另一个集合的所有元素),但在其他情况下它们是无法比较的。(集合就是偏序的)
全序和偏序之间的差异反映在不同的数据库一致性模型中:
-
线性一致性
在线性一致的系统中,操作是全序的:如果系统表现的就好像只有一个数据副本,并且所有操作都是原子性的,这意味着对任何两个操作,我们总是能判定哪个操作先发生。
-
因果性
我们说过,如果两个操作都没有在彼此 之前发生,那么这两个操作是并发的。换句话说,如果两个事件是因果相关的(一个发生在另一个事件之前),则它们之间是有序的,但如果它们是并发的,则它们之间的顺序是无法比较的。这意味着因果关系定义了一个偏序,而不是一个全序:一些操作相互之间是有顺序的,但有些则是无法比较的。
因此,根据这个定义,在线性一致的数据存储中是不存在并发操作的:必须有且仅有一条时间线,所有的操作都在这条时间线上,构成一个全序关系。可能有几个请求在等待处理,但是数据存储确保了每个请求都是在唯一时间线上的某个时间点自动处理的,不存在任何并发。
并发意味着时间线会分岔然后合并 —— 在这种情况下,不同分支上的操作是无法比较的(即并发操作)。
线性一致性强于因果一致性
因果顺序和线性一致性之间的关系是什么?答案是线性一致性 隐含着(implies) 因果关系:任何线性一致的系统都能正确保持因果性。特别是,如果系统中有多个通信通道(如 图 9-5 中的消息队列和文件存储服务),线性一致性可以自动保证因果性,系统无需任何特殊操作(如在不同组件间传递时间戳)。
捕获因果关系
为了维持因果性,你需要知道哪个操作发生在哪个其他操作之前(happened before)。这是一个偏序:并发操作可以以任意顺序进行,但如果一个操作发生在另一个操作之前,那它们必须在所有副本上以那个顺序被处理。因此,当一个副本处理一个操作时,它必须确保所有因果前驱的操作(之前发生的所有操作)已经被处理;如果前面的某个操作丢失了,后面的操作必须等待,直到前面的操作被处理完毕。
序列号顺序
虽然因果是一个重要的理论概念,但实际上跟踪所有的因果关系是不切实际的。显式跟踪所有已读数据意味着巨大的额外开销。
其他方法:使用 序列号(sequence nunber) 或 时间戳(timestamp) 来排序事件。时间戳不一定来自日历时钟(或物理时钟,它们存在许多问题)。它可以来自一个 逻辑时钟(logical clock) ,这是一个用来生成标识操作的数字序列的算法,典型实现是使用一个每次操作自增的计数器。
非因果序列号生成器
如果主库不存在(可能因为使用了多主数据库或无主数据库,或者因为使用了分区的数据库),如何为操作生成序列号就没有那么明显了。在实践中有各种各样的方法:
- 每个节点都可以生成自己独立的一组序列号。
- 可以将日志时钟(物理时钟)的时间戳附加到每个操作上。
- 可以预先分配序列号区块。各个节点分配不同区间的序列号,然后每个节点独立分配所需区块中的序列号,并在序列号告急时请求分配一个新的区块。
这三个选项都比单一主库的自增计数器表现要好,并且更具可伸缩性。它们为每个操作生成一个唯一的,近似自增的序列号。然而它们都有同一个问题:生成的序列号与因果不一致。
因为这些序列号生成器不能正确地捕获跨节点的操作顺序,所以会出现因果关系的问题:
- 每个节点每秒可以处理不同数量的操作。
- 来自物理时钟的时间戳会受到时钟偏移的影响
- 在分配分配区块的时候,可能就会出现序列号与因果关系不一致
兰伯特时间戳
兰伯特时间戳:可以产生与因果关系一致的序列号。
应用:每个节点都有一个唯一标识符,和一个保存自己执行操作数量的计数器。兰伯特时间戳就是两者的简单组合。(计数器,节点ID)(counter,nodeID)。两个节点的计数器值有时可能相同,但节点ID不同,因此时间戳也不同。
图 9-8 Lamport 时间戳提供了与因果关系一致的全序。
兰伯特时间戳与物理的日历时钟没有任何关系,但是它提供了一个全序:如果你有两个时间戳,则 计数器 值大者是更大的时间戳。如果计数器值相同,则节点 ID 越大的,时间戳越大。
兰伯特时间戳因果一致的关键思想是:每个节点和客户端跟踪迄今为止所见到的最大计数器值,并在每个请求中包含这个最大计数器值。当一个节点收到最大计数器值大于自身计数器值的请求或响应时,它立即将自己的计数器设置为这个最大值。
只要每一个操作都携带着最大计数器值,这个方案确保兰伯特时间戳的排序与因果一致,因为每个因果依赖都会导致时间戳增长。
兰伯特时间戳与版本向量的区别:
- 版本向量可以区分两个操作是并发的,还是一个因果依赖另一个
- 兰伯特时间戳总是施行一个全序。从兰伯特时间戳的全序中,无法分辨两个操作是并发的还是因果依赖的。兰伯特时间戳由于版本向量的地方是,它更加紧凑。
光有时间戳排序还不够
虽然兰伯特时间戳定义了一个与因果一致的全序,但它还不足以解决分布式系统中的许多常见问题。
例如,考虑一个需要确保用户名能唯一标识用户帐户的系统。如果两个用户同时尝试使用相同的用户名创建帐户,则其中一个应该成功,另一个应该失败。
这种方法适用于事后确定胜利者:一旦你收集了系统中的所有用户名创建操作,就可以比较它们的时间戳。然而当某个节点需要实时处理用户创建用户名的请求时,这样的方法就无法满足了。节点需要 马上(right now) 决定这个请求是成功还是失败。在那个时刻,节点并不知道是否存在其他节点正在并发执行创建同样用户名的操作,罔论其它节点可能分配给那个操作的时间戳。
这里的问题是,只有在所有的操作都被收集之后,操作的全序才会出现。如果另一个节点已经产生了一些操作,但你还不知道那些操作是什么,那就无法构造所有操作最终的全序关系:来自另一个节点的未知操作可能需要被插入到全序中的不同位置。
总之:为了实现诸如用户名上的唯一约束这种东西,仅有操作的全序是不够的,你还需要知道这个全序何时会尘埃落定。如果你有一个创建用户名的操作,并且确定在全序中没有任何其他节点可以在你的操作之前插入对同一用户名的声称,那么你就可以安全地宣告操作执行成功。
全序广播
在单个CPU核上,操作全序可以简单认为就是CPU执行这些操作的顺序。
在分布式系统中,让所有节点对同一个全局操作顺序达成一致相当困难。按时间戳或序列号进行排序还不如单主复制给力。
单主复制通过选择一个节点作为主库来确定操作的全序,并在主库的单个CPU核上对所有操作进行排序。那么剩下的挑战就是:如果扩展单主?如果主库失效,如何处理故障切换?在分布式系统中,这个问题被称为 全序广播(total order broadcast) 或 原子广播(atomic broadcast) 。
全序广播通常被描述为在节点间交换消息的协议。 非正式地讲,它要满足两个安全属性:
-
可靠交付(reliable delivery)
没有消息丢失:如果消息被传递到一个节点,它将被传递到所有节点。
-
全序交付(totally ordered delivery)
消息以相同的顺序传递给每个节点。
正确的全序广播算法必须始终保证可靠性和有序性,即使节点或网络出现故障。当然在网络中断的时候,消息是传不出去的,但是算法可以不断重试,以便在网络最终修复时,消息能及时通过并送达(当然它们必须仍然按照正确的顺序传递)。
使用全序广播
像 ZooKeeper 和 etcd 这样的共识服务实际上实现了全序广播。
看待全序广播的角度:
- 顺序在消息送达时被固化:如果后续的消息已经送达,节点就不允许追溯地将(先前)消息插入顺序中较早的位置。这个事实使得全序广播比时间戳排序更强。
- 一种创建日志的方式(如在复制日志、事务日志或预写日志中):传递消息就像追加写入日志。由于所有节点必须以相同的顺序传递相同的消息,因此所有节点都可以读取日志,并看到相同的消息序列。
全序广播的用例:
- 数据库复制。全序广播正是数据库复制所需的:如果每个消息都代表一次数据库的写入,且每个副本都按相同的顺序处理相同的写入,那么副本间将相互保持一致(除了临时的复制延迟)。这个原理被称为 状态机复制(state machine replication) 。
- 实现可串行化事务。如果每个消息都表示一个确定性事务,以存储过程的形式来执行,且每个节点都以相同的顺序处理这些消息,那么数据库的分区和副本就可以相互保持一致。
- 实现提供防护令牌的锁服务。每个获取锁的请求都作为一条消息追加到日志末尾,并且所有的消息都按它们在日志中出现的顺序依次编号。序列号可以当成防护令牌用,因为它是单调递增的。在 ZooKeeper 中,这个序列号被称为
zxid
使用全序广播实现线性一致的存储
全序广播与消息一致性的对比:
- 全序广播是异步的:消息被保证以固定的顺序可靠地传送,但是不能保证消息何时被送达
- 消息一致性是新鲜性的保证:读取一定能看见最新写入的值。
在全序广播的基础上构建线性一致的存储:可以通过将全序广播当成仅追加日志的方式来实现线性一致的 CAS 操作:
- 在日志中追加一条消息,试探性地指明你要声明的用户名。
- 读日志,并等待你刚才追加的消息被读回。
- 检查是否有任何消息声称目标用户名的所有权。如果这些消息中的第一条就是你自己的消息,那么你就成功了:你可以提交声称的用户名(也许是通过向日志追加另一条消息)并向客户端确认。如果所需用户名的第一条消息来自其他用户,则中止操作。
由于日志项是以相同顺序送达至所有节点,因此如果有多个并发写入,则所有节点会对最先到达者达成一致。选择冲突写入中的第一个作为胜利者,并中止后来者,以此确定所有节点对某个写入是提交还是中止达成一致。类似的方法可以在一个日志的基础上实现可串行化的多对象事务。
尽管这一过程保证写入是线性一致的,但它并不保证读取也是线性一致的 —— 如果你从与日志异步更新的存储中读取数据,结果可能是陈旧的。 (精确地说,这里描述的过程提供了 顺序一致性(sequential consistency) ,有时也称为 时间线一致性(timeline consistency) ,比线性一致性稍微弱一些的保证)。为了使读取也线性一致,有几个选项:
- 你可以通过在日志中追加一条消息,然后读取日志,直到该消息被读回才执行实际的读取操作。消息在日志中的位置因此定义了读取发生的时间点(etcd 的法定人数读取有些类似这种情况)。
- 如果日志允许以线性一致的方式获取最新日志消息的位置,则可以查询该位置,等待该位置前的所有消息都传达到你,然后执行读取。 (这是 Zookeeper
sync()操作背后的思想)。 - 你可以从同步更新的副本中进行读取,因此可以确保结果是最新的(这种技术用于链式复制(chain replication))
使用线性一致性存储实现全序广播
假设我们有线性一致的存储,如何在此基础上构建全序广播?
最简单的方法是假设你有一个线性一致的寄存器来存储一个整数,并且有一个原子 自增并返回 操作。或者原子 CAS 操作也可以完成这项工作。每个要通过全序广播发送的消息首先对线性一致寄存器执行 自增并返回 操作。然后将从寄存器获得的值作为序列号附加到消息中。然后你可以将消息发送到所有节点(重新发送任何丢失的消息),而收件人将按序列号依序传递(deliver)消息。
分布式事务与共识
共识 是分布式计算中最重要也是最基本的问题之一。从表面上看似乎很简单:非正式地讲,目标只是 让几个节点达成一致(get serveral nodes to agree on something) 。
共识的应用场景举例:
-
领导选举
在单主复制的数据库中,所有节点需要就哪个节点是领导者达成一致。共识需要避免错误的故障切换。
-
原子提交
在支持多节点或跨分区事物的数据库中,一个事物可能在某些节点上失败,但在其他节点上成功。如果想要维护事物的原子性,必须让所有节点对事物的结果达成一致:要么全部终止/回滚,要么全部提交。这个共识的例子称为**原子提交(atomic commit)**问题。
共识的不可能性
FLP结果证明,如果存在节点可能崩溃的风险,则不存在总是能够的达成共识的算法。在分布式系统中,我们必须假设节点可能会
FLP结果是在异步系统模型中被证明的,而这是一种限制性很强的模型,它假定确定性算法不能使用任何时钟或超时。如果算法允许使用超时或其他方法来识别可疑的崩溃节点(即使怀疑有时是错误的),则共识变为一个可解的问题。即使仅仅允许算法使用随机数,也足以绕过这个不可能的结果。
因此,虽然 FLP 是关于共识不可能性的重要理论结果,但现实中的分布式系统通常是可以达成共识的。
原子提交与两阶段提交
2PC算法是解决原子提交问题最常见的方法。2PC 是一个共识算法,但不是一个非常好的共识算法。
从单节点到分布式原子提交
对于在单个数据库节点执行的事物,原子性通常由存储引擎实现。在单个节点上,事物的提交主要取决于数据持久化落盘的顺序:首先是数据,然后是提交记录。
如果一个事物中涉及多个节点/分区,不能仅向所有节点发送提交请求并独立提交每个节点的事物,这样很容易违反原子性。事物提交必须是不可撤销的——事物提交之后,不能改变主意,并追溯性地中止事物。这是因为一旦数据被提交,其结果就对其他事物可见,因此其他客户端可能会开始依赖这些数据。
提交事物的结果有可能通过时候执行另一个补偿事物(compensating transaction)来取消,但数据库角度来看,这是一个单独的事物,因此任何关于跨事物正确性的保证都是应用自己的问题。
两阶段提交简介
两阶段提交(two-phase commit) 是一种用于实现跨多个节点的原子事务提交的算法,即确保所有节点提交或所有节点中止。 它是分布式数据库中的经典算法。 2PC 在某些数据库内部使用,也以 XA 事务 的形式对应用可用(例如 Java Transaction API 支持)或以 SOAP Web 服务的 WS-AtomicTransaction 形式提供给应用。
2PC 中的提交 / 中止过程分为两个阶段(因此而得名),而不是单节点事务中的单个提交请求。
图 9-9 两阶段提交(2PC)的成功执行
2PC中涉及的组件:
- 协调者(coordinator,也称为事物管理器,即 transcation manager)
- 参与者(participants):数据库节点
2PC流程:
- 准备(prepare)阶段:协调者向发送**准备(prepare)**请求到所有节点,询问它们是否能够提交。然后协调者会跟踪参与者的响应。
- 提交阶段(commit): 如果所有参与者都回答“是”,则协调者发出**提交(commit)请求,然后提交真正发生;如果任意一个参与者恢复了“否”,则协调者向所有节点发送终止(abort)**请求。
系统承诺
2PC的详细过程:
- 当应用想要启动一个分布式事务时,它向协调者请求一个事务ID。此事务ID是全局唯一的。
- 应用在每个参与者上启动单节点事务,并在单节点事务上捎带上这个全局事务ID。所有的读写都是在这些单节点事务中各自完成的。如果在这个阶段出现任何问题(例如,节点崩溃或请求超时),则协调者或任何参与者都可以中止。
- 当应用准备提交时,协调者向所有参与者发送一个准备请求,并打上全局事务ID的标记。如果任意一个请求失败或超时,则协调者向所有参与者发送针对该事务ID的中止请求。
- 参与者收到准备请求时,需要确保在任意情况下都的确可以提交事务。这包括将所有事务数据写入磁盘(出现故障,电源故障,或硬盘空间不足都不能是稍后拒绝提交的理由)以及检查是否存在任何冲突或违反约束。通过向协调者回答“是” ,节点承诺,只要请求,这个事务一定可以不出差错地提交。换句话说,参与者放弃了中止事务的权利,但没有实际提交。
- 当协调者收到所有准备请求的答复时,会就提交或中止事务作出明确的决定(只有在所有参与者投赞成票的情况下才会提交)。协调者必须把这个决定写到磁盘上的事务日志中,如果它随后就崩溃,恢复后也能知道自己所做的决定。这被称为提交点(commit point) 。
- 一旦协调者的决定落盘,提交或放弃请求会发送给所有参与者。如果这个请求失败或超时,协调者必须永远保持重试,直到成功为止。没有回头路:如果已经做出决定,不管需要多少次重试它都必须被执行。如果参与者在此期间崩溃,事务将在其恢复后提交——由于参与者投了赞成,因此恢复后它不能拒绝提交。
因此,该协议包含两个关键的“不归路”点:当参与者投票“是”时,它承诺它稍后肯定能够提交(尽管协调者可能仍然选择放弃)。一旦协调者做出决定,这一决定是不可撤销的。这些承诺保证了2PC的原子性。 (单节点原子提交将这两个事件混为一谈:将提交记录写入事务日志。)
协调者失效
在2PC期间,如果参与者之一或者网络发生故障时:如果任何一个准备请求失败或者超时,协调者就会中止事务。如果任何提交或中止请求失败,协调者将无条件重试。
如何协调者在发送准备请求之前失败,参与者可以安全地中止事务。但是,一旦参与者收到了准备请求并投了“是”,就不能再单方面放弃 —— 必须等待协调者回答事务是否已经提交或中止。如果此时协调者崩溃或网络出现故障,参与者什么也做不了只能等待。参与者的这种事务状态称为**存疑(in doubt)的或不确定(uncertain)**的。
没有协调者的消息,参与者无法知道是提交还是放弃。原则上参与者可以相互沟通,找出每个参与者是如何投票的,并达成一致,但这不是2PC协议的一部分。
可以完成2PC的唯一方法是等待协调者恢复。这就是为什么协调者必须在向参与者发送提交或中止请求之前,将其提交或中止决定写入磁盘上的事务日志:协调者恢复后,通过读取其事务日志来确定所有存疑事务的状态。任何在协调者日志中没有提交记录的事务都会中止。因此,2PC的提交点 归结为协调者上的常规单节点原子提交。
三阶段提交
两阶段提交被称为**阻塞(blocking)原子提交协议,因为存在2PC可能卡住并等待协调者恢复的情况。理论上,可以使一个原子提交协议变为非阻塞(nonblocking)**的,以便在节点失败时不会卡住。但是让这个协议能在实践中工作并没有那么简单。
作为2PC的替代方案,已经提出了一种称为**三阶段提交(3PC)**的算法。然而,3PC假定网络延迟有界,节点响应时间有限;在大多数具有无限网络延迟和进程暂停的实际系统中(见第8章 ),它并不能保证原子性。
通常,非阻塞原子提交需要一个完美的故障检测器(perfect failure detector) —— 即一个可靠的机制来判断一个节点是否已经崩溃。在具有无限延迟的网络中,超时并不是一种可靠的故障检测机制,因为即使没有节点崩溃,请求也可能由于网络问题而超时。出于这个原因,2PC仍然被使用,尽管大家都清楚可能存在协调者故障的问题。
实践中的分布式事务
分布式事务的问题:
- 实现复杂
- 性能问题。大部分是由于崩溃恢复所需要的额外强制刷盘(fsync)记忆额外的网络往返。
不同类型的分布式事务:
-
数据库内部的分布式事务
一些分布式数据库支持数据库节点之间的内部事务。
- 不必与其他任何系统兼容,因此可以使用任何协议,并能针对特定技术进行特定优化。因此数据库内部的事务通常能够工作地很好
-
异构分布式事务
参与者是两种或以上不同技术:例如来自不同供应商的数据库,甚至是非数据库系统(如消息代理)。跨系统的分布式事务必须确保原子提交,尽管系统可能完全不同。
- 更具挑战性
恰好一次的消息处理
在一个事务中原子提交消息确认和数据库写入两个操作,可以实现消息队列和数据库事务的异构分布式事务。
然而,只有当所有受事务影响的系统都使用同样的**原子提交协议(atomic commit protocol)**时,这样的分布式事务才是可能的。例如,假设处理消息的副作用是发送一封邮件,而邮件服务器并不支持两阶段提交:如果消息处理失败并重试,则可能会发送两次或更多次的邮件。但如果处理消息的所有副作用都可以在事务中止时回滚,那么这样的处理流程就可以安全地重试,就好像什么都没有发生过一样。
XA事务
X/Open XA(**扩展架构(eXtended Architecture)**的缩写)是跨异构技术实现两阶段提交的标准。
XA不是一个网络协议——它只是一个用来与事务协调者连接的C API。
XA假定你的应用使用网络驱动或客户端库来与参与者进行通信(数据库或消息服务)。如果驱动支持XA,则意味着它会调用XA API 以查明操作是否为分布式事务的一部分 —— 如果是,则将必要的信息发往数据库服务器。驱动还会向协调者暴露回调接口,协调者可以通过回调来要求参与者准备,提交或中止。
事务协调者需要实现XA API。标准没有指明应该如何实现,但实际上协调者通常只是一个库,被加载到发起事务的应用的同一个进程中(而不是单独的服务)。它在事务中个跟踪所有的参与者,并在要求它们准备之后收集参与者的响应(通过驱动回调),并使用本地磁盘上的日志记录每次事务的决定(提交/中止)。
怀疑时持有锁
为什么我们这么关心存疑事务?系统的其他部分就不能继续正常工作,无视那些终将被清理的存疑事务吗?
问题在于锁(locking) 。在事务提交或中止之前,数据库不能释放这些锁。因此,其他事务没法儿简单地继续它们的业务了 —— 如果它们要访问同样的数据,就会被阻塞。这可能会导致应用大面积进入不可用状态,直到存疑事务被解决。
从协调者故障中恢复
**孤立(orphaned)**的存疑事务:即无论出于何种理由,协调者无法确定事务的结果(例如事务日志已经由于软件错误丢失或损坏)。
这些事务无法自动解决,所以它们永远待在数据库中,持有锁并阻塞其他事务。 即使重启数据库服务器也无法解决这个问题,因为在2PC的正确实现中,即使重启也必须保留存疑事务的锁(否则就会冒有违反原子性保证的风险)。
- 唯一的出路是让管理员手动决定提交还是回滚事务。
- 启发式决策(heuristic decistions):允许参与者单方面决定放弃或提交一个存疑事务,而无需协调者做出最终决定。启发式是**可能破坏原子性(probably breaking atomicity)**的委婉说法,因为它违背了两阶段提交的系统承诺。因此,启发式决策只是为了逃出灾难性的情况而准备的,而不是为了日常使用的。
分布式事务的限制
事务协调者本身就是一种数据库(存储了事务的结果),因此需要像其他重要数据库一样小心地打交道:
- 如果协调者没有复制,而是只在单台机器上运行,那么它是整个系统的失效单点(因为它的失效会导致其他应用服务器阻塞在存疑事务持有的锁上)。令人惊讶的是,许多协调者实现默认情况下并不是高可用的,或者只有基本的复制支持。
- 许多服务器端应用都是使用无状态模式开发的(受HTTP的青睐),所有持久状态都存储在数据库中,因此具有应用服务器可随意按需添加删除的优点。但是,当协调者成为应用服务器的一部分时,它会改变部署的性质。突然间,协调者的日志成为持久系统状态的关键部分—— 与数据库本身一样重要,因为协调者日志是为了在崩溃后恢复存疑事务所必需的。这样的应用服务器不再是无状态的了。
- 由于XA需要兼容各种数据系统,因此它必须是所有系统的最小公分母。例如,它不能检测不同系统间的死锁(因为这将需要一个标准协议来让系统交换每个事务正在等待的锁的信息),而且它无法与SSI 协同工作,因为这需要一个跨系统定位冲突的协议。
- 对于数据库内部的分布式事务(不是XA),限制没有这么大,例如,分布式版本的SSI 是可能的。然而仍然存在问题:2PC成功提交一个事务需要所有参与者的响应。因此,如果系统的任何部分损坏,事务也会失败。因此,分布式事务又有**扩大失效(amplifying failures)**的趋势,这又与我们构建容错系统的目标背道而驰。
容错共识
非正式地,共识意味着让几个节点就某事达成一致。
共识问题通常形式化如下:一个或多个节点可以**提议(propose)某些值,而共识算法决定(decides)**采用其中的某个值。
在这种形式下,共识算法必须满足以下性质:
- 一致同意(Uniform agreement): 没有两个节点的决定不同*。***
- 完整性(Integrity):没有节点决定两次。
- *有效性(Validity): *如果一个节点决定了值
v,则v由某个节点所提议。 - *终止(Termination) * 由所有未崩溃的节点来最终决定值。
一致同意和完整性属性定义了共识的核心思想:所有人都决定了相同的结果,一旦决定了,你就不能改变主意。有效性属性主要是为了排除平凡的解决方案:例如,无论提议了什么值,你都可以有一个始终决定值为null的算法。;该算法满足一致同意和完整性属性,但不满足有效性属性。
终止属性正式形成了容错的思想。它实质上说的是,一个共识算法不能简单地永远闲坐着等死 —— 换句话说,它必须取得进展。即使部分节点出现故障,其他节点也必须达成一项决定。 (终止是一种活性属性,而另外三种是安全属性 —— 参见“安全性和活性”。)
算法可以容忍的失效数量是有限的:事实上可以证明,任何共识算法都需要至少占总体**多数(majority)**的节点正确工作,以确保终止属性。多数可以安全地组成法定人数。
因此终止属性取决于一个假设,不超过一半的节点崩溃或不可达。然而即使多数节点出现故障或存在严重的网络问题,绝大多数共识的实现都能始终确保安全属性得到满足—— 一致同意,完整性和有效性【92】。因此,大规模的中断可能会阻止系统处理请求,但是它不能通过使系统做出无效的决定来破坏共识系统。
共识算法和全序广播
最著名的容错共识算法是视图戳复制(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) ),并确保在每个时代中,领导者都是唯一的。
每次当现任领导被认为挂掉的时候,节点间就会开始一场投票,以选出一个新领导。这次选举被赋予一个递增的时代编号,因此时代编号是全序且单调递增的。如果两个不同的时代的领导者之间出现冲突(也许是因为前任领导者实际上并未死亡),那么带有更高时代编号的领导说了算。
共识的局限性
-
投票的过程是一种同步复制
-
共识系统需要严格多少来运转
-
大多数共识算法假定参与投票的节点是固定的集合,因此不能简单的在集群中添加或删除节点。
- **动态成员扩展(dynamic membership extension)**难以理解
-
共识系统通常依靠超时来检测失效节点
-
有时共识算法对网络特别敏感
成员与协调服务
像ZooKeeper或etcd这样的项目通常被描述为“分布式键值存储”或“协调与配置服务”:你可以读写给定键的值,并遍历键。所以如果它们基本上算是数据库的话,为什么它们要把工夫全花在实现一个共识算法上呢?是什么使它们区别于其他任意类型的数据库?
ZooKeeper和etcd被设计为容纳少量完全可以放在内存中的数据(虽然它们仍然会写入磁盘以保证持久性),所以你不会想着把所有应用数据放到这里。这些少量数据会通过容错的全序广播算法复制到所有节点上。正如前面所讨论的那样,数据库复制需要的就是全序广播:如果每条消息代表对数据库的写入,则以相同的顺序应用相同的写入操作可以使副本之间保持一致。
ZooKeeper模仿了Google的Chubby锁服务【14,98】,不仅实现了全序广播(因此也实现了共识),而且还构建了一组有趣的其他特性,这些特性在构建分布式系统时变得特别有用:
-
线性一致性的原子操作
使用原子CAS操作可以实现锁:如果多个节点同时尝试执行相同的操作,只有一个节点会成功。共识协议保证了操作的原子性和线性一致性,即使节点发生故障或网络在任意时刻中断。分布式锁通常以**租约(lease)**的形式实现,租约有一个到期时间,以便在客户端失效的情况下最终能被释放
-
操作的全序排序
你需要一个防护令牌来防止客户端在进程暂停的情况下彼此冲突。防护令牌是每次锁被获取时单调增加的数字。 ZooKeeper通过全局排序操作来提供这个功能,它为每个操作提供一个单调递增的事务ID(
zxid)和版本号(cversion) -
失效检测
客户端在ZooKeeper服务器上维护一个长期会话,客户端和服务器周期性地交换心跳包来检查节点是否还活着。即使连接暂时中断,或者ZooKeeper节点失效,会话仍保持在活跃状态。但如果心跳停止的持续时间超出会话超时,ZooKeeper会宣告该会话已死亡。当会话超时(ZooKeeper调用这些临时节点)时,会话持有的任何锁都可以配置为自动释放(ZooKeeper称之为临时节点(ephemeral nodes) )。
-
变更通知
客户端不仅可以读取其他客户端创建的锁和值,还可以监听它们的变更。因此,客户端可以知道另一个客户端何时加入集群(基于新客户端写入ZooKeeper的值),或发生故障(因其会话超时,而其临时节点消失)。通过订阅通知,客户端不用再通过频繁轮询的方式来找出变更。
在这些功能中,只有线性一致的原子操作才真的需要共识。但正是这些功能的组合,使得像ZooKeeper这样的系统在分布式协调中非常有用。
将工作分配给节点
例子:
- 如果你有几个进程实例或服务,需要选择其中一个实例作为主库或首选服务。如果领导者失败,其他节点之一应该接管。这对单主数据库当然非常实用,但对作业调度程序和类似的有状态系统也很好用。
- 你有一些分区资源(数据库,消息流,文件存储,分布式Actor系统等),并需要决定将哪个分区分配给哪个节点时。当新节点加入集群时,需要将某些分区从现有节点移动到新节点,以便重新平衡负载(参阅“重新平衡分区”)。当节点被移除或失效时,其他节点需要接管失效节点的工作。
这类任务可以通过在ZooKeeper中明智地使用原子操作,临时节点与通知来实现。如果设计得当,这种方法允许应用自动从故障中恢复而无需人工干预。
服务发现
ZooKeeper,etcd和Consul也经常用于服务发现——也就是找出你需要连接到哪个IP地址才能到达特定的服务。在云数据中心环境中,虚拟机连续来去常见,你通常不会事先知道服务的IP地址。相反,你可以配置你的服务,使其在启动时注册服务注册表中的网络端点,然后可以由其他服务找到它们。
成员服务
成员资格服务确定哪些节点当前处于活动状态并且是群集的活动成员。
由于无限的网络延迟,无法可靠地检测到另一个节点是否发生故障。但是,如果你通过一致的方式进行故障检测,那么节点可以就哪些节点应该被认为是存在或不存在达成一致。
即使它确实存在,仍然可能发生一个节点被共识错误地宣告死亡。但是对于一个系统来说,在哪些节点构成当前的成员关系方面是非常有用的。例如,选择领导者可能意味着简单地选择当前成员中编号最小的成员,但如果不同的节点对现有成员的成员有不同意见,则这种方法将不起作用。