第九章:一致性与共识


作者:负雪明烛 时间:2021 年 11 月 9 日 image.png 构建容错系统的最好方法,是找到一些带有实用保证的通用抽象,实现一次,然后让应用依赖这些保证。比如事务。 我们需要了解系统能力的边界:哪些可行,哪些不可行。

一致性保证

分布式系统的时序问题:

  • 在同一时刻查看两个数据库节点,则可能在两个节点上看到不同的数据,因为写请求在不同的时间到达不同的节点。
  • 无论数据库使用何种复制方法(单主复制,多主复制或无主复制),都会出现这些不一致情况。
  • 大多数复制的数据库至少提供了最终一致性,这意味着如果你停止向数据库写入数据并等待一段不确定的时间,那么最终所有的读取请求都会返回相同的值。
  • 换句话说,不一致性是暂时的,最终会自行解决(假设网络中的任何故障最终都会被修复)。最终一致性的一个更好的名字可能是收敛(convergence),因为我们预计所有的副本最终会收敛到相同的值。
  • 最终一致性是非常弱的保证——没有说什么时候会收敛。
  • 在与只提供弱保证的数据库打交道时,你需要始终意识到它的局限性,而不是意外地作出太多假设。(始终不信任它)

除了最终一致性,有没有更强的一致性模型?

  • 分布式一致性模型
  • 分布式一致性模型和我们之前讨论的事务隔离级别的层次结构有一些相似之处。
  • 不同:事务隔离主要是为了避免由于同时执行事务而导致的竞争状态,而分布式一致性主要关于在面对延迟和故障时如何协调副本间的状态

线性一致性

线性一致性的想法?

  • 数据库可以提供只有一个副本的假象(即,只有一个数据副本),那么每个客户端都会有相同的数据视图,且不必担心复制滞后了。
  • 线性一致性(linearizability),也称为原子一致性(atomic consistency)强一致性(strong consistency)立即一致性(immediate consistency)外部一致性(external consistency )。

线性一致性提供了什么保证?

  • 只要一个客户端成功完成写操作,所有客户端从数据库中读取数据必须能够看到刚刚写入的值。
  • 系统应保障读到的值是最近的、最新的,而不是来自陈旧的缓存或副本。
  • 线性一致性是一个新鲜度保证(recency guarantee)

图9-1 这个系统是非线性一致的,导致了球迷的困惑image.png

如何使得系统线性一致?

举例说明线性一致性。 下图中,客户端 C 修改了 x 的值,客户端 A 和 B 在与写操作时间上有重叠的任何读操作,看到的 x 的值可能不一致。 图9-2 如果读取请求与写入请求并发,则可能会返回旧值或新值image.png

为了使系统线性一致,需要添加约束: 图9-3 任何一个读取返回新值后,所有后续读取(在相同或其他客户端上)也必须返回新值。image.png 在变量 x 从 0 变成 1 的时候,一定有一个确切的时间点。如果一个客户端读取返回的新值是 1,即使写操作尚未完成,所有后续读取也必须返回新值。

除了读写之外,需要增加了第三种类型的操作:

  • 表示客户端请求进行原子性的比较与设置操作。如果寄存器 的当前值等于 ,则应该原子地设置为 。如果 ,则操作应该保持寄存器不变并返回一个错误。 是数据库的响应(正确或错误)。

图9-4 可视化读取和写入看起来已经生效的时间点。 B的最后读取不是线性一致性的image.png 说明:

  • 每个条形图中间的黑线,表示读取和修改变量 x 的确切时刻。
  • 客户端 B 的最后一次读取不是线性一致的。该操作与C的cas写操作并发(它将 x 从 2 更新为 4 )。在没有其他请求的情况下,B的读取返回 2 是可以的。然而,在B的读取开始之前,客户端A已经读取了新的值 4 ,因此不允许B读取比A更旧的值。

线性一致性与可串行化

这两个概念容易混淆 可串行化可串行化(Serializability) 是事务的隔离属性,每个事务可以读写多个对象(行,文档,记录)它确保事务的行为,与它们按照某种顺序依次执行的结果相同(每个事务在下一个事务开始之前运行完成)。这种执行顺序可以与事务实际执行的顺序不同。 线性一致性线性一致性(Linearizability) 是读取和写入寄存器(单个对象)的新鲜度保证

线性一致性的依赖条件

锁定和领导选举

单主复制的系统中,怎么确保领导真的只有一个,而不是几个(脑裂)?

  • 一种选择领导者的方法是使用锁:每个节点在启动时尝试获取锁,成功者成为领导者
  • 不管这个锁是如何实现的,它必须是线性一致的:所有节点必须就哪个节点拥有锁达成一致,否则就没用了。
  • 诸如Apache ZooKeeper 和etcd 之类的协调服务通常用于实现分布式锁和领导者选举。它们使用一致性算法,以容错的方式实现线性一致的操作。
  • 分布式锁也在一些分布式数据库(如Oracle Real Application Clusters(RAC))中更多的粒度级别上使用。RAC对每个磁盘页面使用一个锁,多个节点共享对同一个磁盘存储系统的访问权限。

约束和唯一性保证

  • 用户名和邮箱必须唯一,文件路径和名称不许重复等。
  • 类似于一个锁。
  • 关系型数据库的主键需要线性一致性,其他类型的约束,如外键或属性约束,可以不需要线性一致性

跨信道的时序依赖

  • 上传图片后要进行图片压缩,分成了两个服务:文件存储图片、通过消息队列传递压缩指令。
  • 如果消息队列运行的更快,那么就找不到图片或者压缩了老的图片。

图9-5 Web服务器和图像缩放器通过文件存储和消息队列进行通信,打开竞争条件的可能性。image.png 线性一致性并不是避免这种竞争条件的唯一方法,但它是最容易理解的。

实现线性一致的系统

怎么实现线性一致的系统?

  • 最简单的答案就是,真的只用一个数据副本。
  • 但是这种方法无法容错:如果持有该副本的节点失效,数据将会丢失,或者至少无法访问,直到节点重新启动。

不同复制方法下是否可以满足线性一致性?

  • 单主复制(可能线性一致)
    • 如果从主库或同步更新的从库读取数据,它们可能(potential) 是线性一致性
    • 然而,实际上并不是每个单主数据库都是线性一致性的,无论是因为设计的原因(例如,因为使用了快照隔离)还是因为在并发处理上存在错误
    • 从主库读取依赖一个假设,你确定地知道领导者是谁。但是有可能节点误以为自己是领导者。
    • 使用异步复制,故障切换时甚至可能会丢失已提交的写入,这同时违反了持久性和线性一致性。
  • 共识算法(线性一致)
    • 本章后面讨论的共识算法,与单领导者复制类似。然而,共识协议包含防止脑裂和陈旧副本的措施。正是由于这些细节,共识算法可以安全地实现线性一致性存储。比如 Zookeeper 和 etcd。
  • 多主复制(非线性一致)
    • 具有多主程序复制的系统通常不是线性一致的,因为它们同时在多个节点上处理写入,并将其异步复制到其他节点。因此,它们可能会产生需要被解决的写入冲突。这种冲突是因为缺少单一数据副本所导致的。
  • 无主复制(也许不是线性一致的)
    • 有时候人们会声称通过要求法定人数读写( )可以获得“强一致性”。这取决于法定人数的具体配置,以及强一致性如何定义(通常不完全正确)
    • 基于日历时钟的“最后写入胜利”冲突解决方法几乎可以确定是非线性一致的,由于时钟偏差,不能保证时钟的时间戳与实际事件顺序一致。
    • 宽松的法定人数也破坏了线性一致的可能性。
    • 即使使用严格的法定人数,非线性一致的行为也只是可能的,如下节所示。

线性一致性和法定人数

直觉上,严格的法定人数读写应是线性一致性的。但是由于可变的网络延迟,就可能存在竞争条件。 图9-6 非线性一致的执行,尽管使用了严格的法定人数image.png

  • 上图的法定人数是 2,满足
  • A 看到的值是 1
  • B 看到的值是 0
  • 非线性一致。

可以让上述法定人数的读写线性化吗?

  • 通过牺牲性能,可以:
    • 读取者必须在将结果返回给应用之前,同步执行读修复
    • 并且写入者必须在发送写入之前,读取法定数量节点的最新状态
  • 实际上数据库系统都没有做
  • 而且,这种方式只能实现线性一致的读写;不能实现线性一致的「比较和设置」(CAS)操作,因为它需要一个共识算法。

总而言之,最安全的假定是:假设采用Dynamo风格无主复制的系统不能提供线性一致性。

线性一致性的代价

本节讨论线性一致性的优缺点。

第五章中讨论了不同复制方法的一些用例:

  • 例如:对多数据中心的复制而言,多主复制通常是理想的选择

image.png图9-7 网络中断迫使在线性一致性和可用性之间做出选择。

但是如果两个数据中心之间发生网络中断会发生什么?

  1. 使用多主数据库
    • 每个数据中心都可以继续正常运行:由于在一个数据中心写入的数据是异步复制到另一个数据中心的,所以只有恢复网络连接后,操作才会继续同步。
  2. 使用单主复制
    • 则主库必须位于其中一个数据中心。
    • 任何写入和任何线性一致的读取请求都必须发送给该主库,因此对于连接到从库所在数据中心的客户端,这些读取和写入请求必须通过网络同步发送到主库所在的数据中心。
    • 只能连接到从库所在的数据中心的客户端,当数据中心之间的网络被中断时:
      • 客户端无法对数据库执行任何写入,也不能执行任何线性一致的读取。它们仍能从从库读取,但结果可能是陈旧的(非线性一致)。
      • 如果应用需要线性一致的读写,却又位于与主库网络中断的数据中心,则网络中断将导致这些应用不可用。
    • 可以直接连接到主库所在的数据中心的客户端:
      • 可以继续正常工作。

CAP定理

任何可线性的数据库都有上述问题。也不局限于但数据中心部署的情况,即使在一个数据中心内部,只要有不可靠的网络,都会发生违背线性化的风险。

权衡考虑:

  • 如果应用需要线性一致性,且某些副本因为网络问题与其他副本断开连接,那么这些副本掉线时不能处理请求。即服务都不可用(unavailable)
  • 如果应用不需要线性一致性,那么某个副本即使与其他副本断开连接,也可以独立处理请求(例如多主复制)。但其行为不是线性一致的。

不要求线性化的应用更能容忍网络故障。这种思路被称为 CAP 定理。

CAP更好的表述成:在分区时要么选择一致,要么选择可用。

CAP理论的缺点:

  • 它只考虑了一个一致性模型(即线性一致性)和一种故障(网络分区,或活跃但彼此断开的节点)
  • 它没有讨论任何关于网络延迟,死亡节点或其他权衡的事。
  • 因此,尽管CAP在历史上有一些影响力,但对于设计系统而言并没有实际价值。
  • CAP 已经成了历史古迹了。

线性一致性和网络延迟

线性一致性很普及吗?

  • 线性一致的系统惊人的少。
  • 现代多核CPU上的内存甚至都不是线性一致的(除非使用了内存屏障(memory barrier)围栏(fence)
  • 因为每个CPU核都有自己的内存缓存和存储缓冲区,和内存之间是异步的。
  • CPU 牺牲线性一致性的原因是性能(performance),而不是容错。
  • 许多分布式数据库也是如此:它们是为了提高性能而选择了牺牲线性一致性,而不是为了容错。
  • 线性一致的速度很慢——这始终是事实,而不仅仅是网络故障期间。

能找到一个更高效的线性一致存储实现吗?

  • 看起来答案是否定的:Attiya和Welch证明,如果你想要线性一致性,读写请求的响应时间至少与网络延迟的不确定性成正比。

更快地线性一致算法不存在,但更弱的一致性模型可以快得多,所以对延迟敏感的系统而言,这类权衡非常重要。

顺序保证

  • 前文讲到:线性一致寄存器的行为就好像只有单个数据副本一样,且每个操作似乎都是在某个时间点以原子性的方式生效的。这个定义意味着操作是按照某种良好定义的顺序执行的。

**顺序(ordering)**在本书中反复出现,回顾一下:

  • 第五章讲到:领导者在单主复制中的主要目的就是,在复制日志中确定**写入顺序(order of write)。**如果不存在一个领导者,则并发操作可能导致冲突
  • 第七章讲到:可串行化,是关于事务表现的像按某种先后顺序(some sequential order) 执行的保证。它可以字面意义上地以串行顺序(serial order) 执行事务来实现,或者允许并行执行,但同时防止序列化冲突来实现(通过锁或中止事务)。
  • 第八章讲到:时间戳和时钟是另一种将顺序引入无序世界的尝试。

事实证明,顺序、线性一致性和共识之间有着深刻的联系。

顺序与因果关系

  • 顺序反复出现有几个原因,其中一个原因是,它有助于保持因果关系(causality)

因果关系很重要,前文已经提到:

  • 在“一致前缀读”中,有个让人困惑的例子:一个对话的观察者首先看到问题的答案,然后才看到被回答的问题。我们认为在问题和答案之间存在因果依赖(causal dependency)
  • 图 5-9 中出现了类似的模式,我们看到三位领导者之间的复制,并注意到由于网络延迟,一些写入可能会“压倒”其他写入。从其中一个副本的角度来看,好像有一个对尚不存在的记录的更新操作。这里的因果意味着,一条记录必须先被创建,然后才能被更新。
  • 在“检测并发写入”中我们观察到,如果有两个操作A和B,则存在三种可能性:A发生在B之前,或B发生在A之前,或者A和B并发。此前发生的含义:如果A在B前发生,那么意味着B可能已经知道了A,或者建立在A的基础上,或者依赖于A。如果A和B是并发的,那么它们之间并没有因果联系;换句话说,我们确信A和B不知道彼此。
  • 在事务快照隔离的上下文中:我们说事务是从一致性快照中读取的。但此语境中“一致”到底又是什么意思?这意味着与因果关系保持一致(consistent with causality):如果快照包含答案,它也必须包含被回答的问题。在某个时间点观察整个数据库,与因果关系保持一致意味着:因果上在该时间点之前发生的所有操作,其影响都是可见的,但因果上在该时间点之后发生的操作,其影响对观察者不可见。读偏差(read skew) 意味着读取的数据处于违反因果关系的状态。
  • 事务之间写偏差(write skew) 的例子(请参阅“写入偏斜与幻读”)也说明了因果依赖:在图7-8中,Alice 被允许离班,因为事务认为 Bob 仍在值班,反之亦然。在这种情况下,离班的动作因果依赖于对当前值班情况的观察。可串行化快照隔离通过跟踪事务之间的因果依赖来检测写偏差。
  • 在Alice和Bob看球的例子中(图9-1),在听到Alice惊呼比赛结果后,Bob从服务器得到陈旧结果的事实违背了因果关系:Alice的惊呼因果依赖于得分宣告,所以Bob应该也能在听到爱丽斯惊呼后查询到比分。相同的模式在“跨信道的时序依赖”一节中,以“图像大小调整服务”的伪装再次出现。

什么是因果一致?

  • 因果关系对事件施加了一种顺序:因在果之前;消息发送在消息收取之前。
  • 这些因果依赖的操作链定义了系统中的因果顺序,即,什么在什么之前发生。
  • 如果一个系统服从因果关系所规定的顺序,我们说它是因果一致(causally consistent) 的。

因果顺序不是全序的

什么是全序、偏序?

  • 全序(total order) 允许任意两个元素进行比较,所以如果有两个元素,你总是可以说出哪个更大,哪个更小。
  • 数学集合 {a, b} 比 {b, c} 更大吗?你没法真正比较它们,因为二者都不是对方的子集。我们说它们是无法比较(incomparable) 的,因此数学集合是偏序(partially order) 的:在某些情况下,可以说一个集合大于另一个(如果一个集合包含另一个集合的所有元素),但在其他情况下它们是无法比较的。

全序和偏序之间的差异反映在不同的数据库一致性模型中: 线性一致性

  • 操作是全序的:如果系统表现的就好像只有一个数据副本,并且所有操作都是原子性的,这意味着对任何两个操作,我们总是能判定哪个操作先发生。

因果性

  • 着因果关系定义了一个偏序,而不是一个全序:一些操作相互之间是有顺序的(先后),但有些则是无法比较的(并发)。

因此,在线性一致的数据存储中是不存在并发操作的:必须有且仅有一条时间线,所有的操作都在这条时间线上,构成一个全序关系。

怎么理解并发?

  • 并发意味着时间线会分岔然后合并。
  • Git 的版本历史与因果关系图极其相似(并发创建与融合)。

线性一致性强于因果一致性

因果顺序和线性一致性之间的关系是什么?

  • 线性一致性隐含着(implies) 因果关系:任何线性一致的系统都能正确保持因果性。

线性一致性损害性能和可用性,那么有没有更好的方法保障因果一致性?

  • 非线性一致的系统,也可以因果一致。
  • 实际上在所有的不会被网络延迟拖慢的一致性模型中,因果一致性是可行的最强的一致性模型。而且在网络故障时仍能保持可用。
  • 在许多情况下,看上去需要线性一致性的系统,实际上需要的只是因果一致性,因果一致性可以更高效地实现。
  • 基于这种观察结果,研究人员正在探索新型的数据库,既能保证因果一致性,且性能与可用性与最终一致的系统类似。

捕获因果关系

本文不探讨非线性一致的系统如何保证因果性的细节,而只是简要地探讨一些关键的思想。

为了维持因果性,需要知道什么?

  • 为了维持因果性,你需要知道哪个操作发生在哪个其他操作之前(happened before)。这是一个偏序:并发操作可以以任意顺序进行,但如果一个操作发生在另一个操作之前,那它们必须在所有副本上以那个顺序被处理。
  • 因此,当一个副本处理一个操作时,它必须确保所有因果前驱的操作(之前发生的所有操作)已经被处理;如果前面的某个操作丢失了,后面的操作必须等待,直到前面的操作被处理完毕。

为了确定因果依赖,我们需要什么方法?

  • 我们需要一些方法来描述系统中节点的“知识”。
  • 如果节点在发出写入Y 的请求时已经看到了 X的值,则 X 和 Y 可能存在因果关系。
  • 这个分析使用了那些在欺诈指控刑事调查中常见的问题:CEO在做出决定 Y 时是否知道 X ?

怎么确定因果顺序?

  • 用于确定_哪些操作发生在其他操作之前_ 的技术,与我们在“检测并发写入”中所讨论的内容类似。那一节讨论了无领导者数据存储中的因果性:为了防止丢失更新,我们需要检测到对同一个键的并发写入。
  • 因果一致性则更进一步:它需要跟踪整个数据库中的因果依赖,而不仅仅是一个键。可以推广版本向量以解决此类问题。
  • 为了确定因果顺序,数据库需要知道应用读取了哪个版本的数据。

序列号顺序

可以跟踪所有的因果吗?

  • 因果很重要,但是跟踪所有的因果是不切实际的:比如客户端在写入了大量的数据,我们无法弄清因果依赖的是先前全部读取内容,还是部分内容。
  • 显式跟踪所有已读数据意味着巨大的额外开销。

有更好的办法排序事件吗?

  • 更好的办法:我们可以使用序列号(sequence nunber)时间戳(timestamp) 来排序事件。
  • 时间戳不一定来自日历时钟(或物理时钟,它们存在许多问题,如 “不可靠的时钟” 中所述)。它可以来自一个逻辑时钟(logical clock),这是一个用来生成标识操作的数字序列的算法,典型实现是使用一个每次操作自增的计数器

怎么生成序列号?

  • 这样的序列号或时间戳是紧凑的(只有几个字节大小),它提供了一个全序关系:也就是说每个操作都有一个唯一的序列号,而且总是可以比较两个序列号,确定哪一个更大(即哪些操作后发生)。
  • 特别是,我们可以使用与因果一致(consistent with causality) 的全序来生成序列号:我们保证,如果操作 A 因果地发生在操作 B 前,那么在这个全序中 A 在 B 前( A 具有比 B 更小的序列号)。并行操作之间可以任意排序。这样一个全序关系捕获了所有关于因果的信息,但也施加了一个比因果性要求更为严格的顺序。
  • 在单主复制的数据库中(请参阅“领导者与追随者”),复制日志定义了与因果一致的写操作。**主库可以简单地为每个操作自增一个计数器,从而为复制日志中的每个操作分配一个单调递增的序列号。**如果一个从库按照它们在复制日志中出现的顺序来应用写操作,那么从库的状态始终是因果一致的(即使它落后于领导者)。

非因果序列号生成器

如果主库不存在(可能因为使用了多主数据库或无主数据库,或者因为使用了分区的数据库)怎么生成序列号?

  • 每个节点都可以生成自己独立的一组序列号
    • 例如有两个节点,一个节点只能生成奇数,而另一个节点只能生成偶数。
    • 通常,可以在序列号的二进制表示中预留一些位,用于唯一的节点标识符,这样可以确保两个不同的节点永远不会生成相同的序列号。
  • 可以将日历时钟(物理时钟)的时间戳附加到每个操作上
    • 这种时间戳并不连续,但是如果它具有足够高的分辨率,那也许足以提供一个操作的全序关系。
    • 这应用于 最后写入胜利 的冲突解决方法中
  • 可以预先分配序列号区块
    • 例如,节点 A 可能要求从序列号1到1,000区块的所有权,而节点 B 可能要求序列号1,001到2,000区块的所有权。
    • 然后每个节点可以独立分配所属区块中的序列号,并在序列号告急时请求分配一个新的区块。

上述操作的优缺点? 优点:

  • 这三个选项都比单一主库的自增计数器表现要好,并且更具可伸缩性。
  • 它们为每个操作生成一个唯一的,近似自增的序列号。

缺点:

  • 然而它们都有同一个问题:生成的序列号与因果不一致

因为这些序列号生成器不能正确地捕获跨节点的操作顺序,所以会出现因果关系的问题:

  • 每个节点每秒可以处理不同数量的操作,奇偶分配的方式增长速率不一致
  • 来自物理时钟的时间戳会受到时钟偏移的影响,这可能会使其与因果不一致。
  • 在分配区块的情况下,不同节点的序列号没有大小关系

兰伯特时间戳

一个简单的方法来产生与因果关系一致的序列号。它被称为兰伯特时间戳。

兰伯特时间戳怎么实现?

  • 每个节点都有一个唯一标识符,和一个保存自己执行操作数量的计数器。
  • 兰伯特时间戳就是两者的简单组合:(计数器,节点ID)
  • 两个节点有时可能具有相同的计数器值,但通过在时间戳中包含节点ID,每个时间戳都是唯一的。

image.png

兰伯特时间戳的含义?

  • 兰伯特时间戳与物理的日历时钟没有任何关系,但是它提供了一个全序:
    • 如果你有两个时间戳,则计数器值大者是更大的时间戳。
    • 如果计数器值相同,则节点ID越大的,时间戳越大。

使兰伯特时间戳因果一致的关键思想?

  • 每个节点和每个客户端跟踪迄今为止所见到的最大计数器值,并在每个请求中包含这个最大计数器值。
  • 当一个节点收到最大计数器值大于自身计数器值的请求或响应时,它立即将自己的计数器设置为这个最大值。
  • 如上图,客户端 A 从节点2 接收计数器值 5 ,然后将最大值 5 发送到节点1 。此时,节点1 的计数器仅为 1 ,但是它立即前移至 5 ,所以下一个操作的计数器的值为 6 。
  • 只要每一个操作都携带着最大计数器值,这个方案确保兰伯特时间戳的排序与因果一致,因为每个因果依赖都会导致时间戳增长。

兰伯特时间戳与「检测并发写入」中看到的版本向量的区别?

  • 版本向量可以区分两个操作是并发的,还是一个因果依赖另一个;
  • 而兰伯特时间戳总是施行一个全序。
  • 从兰伯特时间戳的全序中,你无法分辨两个操作是并发的还是因果依赖的。
  • 兰伯特时间戳优于版本向量的地方是,它更加紧凑。

光有时间戳排序还不够

兰伯特时间戳的问题:

  • 虽然它定义了一个与因果一致的全序,但它还不足以解决分布式系统中的许多常见问题。
  • 例如,考虑一个需要确保用户名能唯一标识用户帐户的系统。如果两个用户同时尝试使用相同的用户名创建帐户,则其中一个应该成功,另一个应该失败。
    • 乍看之下,似乎操作的全序关系足以解决这一问题:如果创建了两个具有相同用户名的帐户,选择时间戳较小的那个作为胜者(第一个抓到用户名的人),并让带有更大时间戳者失败。
    • 实际上,上面是一种事后确定胜利者:一旦你收集了系统中的所有用户名创建操作,就可以比较它们的时间戳。
      • 然而节点需要马上(right now) 决定这个请求是成功还是失败。在那个时刻,节点并不知道是否存在其他节点正在并发执行创建同样用户名的操作,罔论其它节点可能分配给那个操作的时间戳。
    • 为了确保没有其他节点正在使用相同的用户名和较小的时间戳并发创建同名账户,你必须检查其它每个节点,看看它在做什么。
      • 如果网络出现问题,那么系统可能停机。

问题出现在哪?

  • 只有在所有的操作都被收集之后,操作的全序才会出现。
  • 如果另一个节点已经产生了一些操作,但你还不知道那些操作是什么,那就无法构造所有操作最终的全序关系:来自另一个节点的未知操作可能需要被插入到全序中的不同位置。

怎么办?

  • 为了实现诸如用户名上的唯一约束这种东西,仅有操作的全序是不够的,你还需要知道这个全序何时会尘埃落定。
  • 如果你有一个创建用户名的操作,并且确定在全序中没有任何其他节点可以在你的操作之前插入对同一用户名的声称,那么你就可以安全地宣告操作执行成功。

全序广播

操作的全序关系的最方便方法?

  • 在上一节中,我们讨论了按时间戳或序列号进行排序,但发现它还不如单主复制给力。
  • 接下来的挑战是,如果吞吐量超出单个主库的处理能力,这种情况下如何扩展系统;以及,如果主库失效(“处理节点宕机”),如何处理故障切换。在分布式系统文献中,这个问题被称为全序广播(total order broadcast)原子广播(atomic broadcast)

全序广播通常被描述为在节点间交换消息的协议。 非正式地讲,它要满足两个安全属性: 可靠交付(reliable delivery) 没有消息丢失:如果消息被传递到一个节点,它将被传递到所有节点。 全序交付(totally ordered delivery) 消息以相同的顺序传递给每个节点。 当网络出现故障的时候,算法需要不断重试,以便网络恢复的时候,消息能及时通知并送达(按照正确顺序)

使用全序广播

**像ZooKeeper和etcd这样的共识服务实际上实现了全序广播。**这一事实暗示了全序广播与共识之间有着紧密联系(稍后讨论)。

全序广播的特点?

  • 全序广播正是数据库复制所需的
    • 如果每个消息都代表一次数据库的写入,且每个副本都按相同的顺序处理相同的写入,那么副本间将相互保持一致(除了临时的复制延迟)。
    • 这个原理被称为状态机复制(state machine replication)
  • 顺序在消息送达时被固化
    • 如果后续的消息已经送达,节点就不允许追溯地将(先前)消息插入顺序中的较早位置。
    • 这个事实使得全序广播比时间戳排序更强。
  • 是一种创建日志的方式(如在复制日志、事务日志或预写式日志中):传递消息就像追加写入日志
    • 由于所有节点必须以相同的顺序传递相同的消息,因此所有节点都可以读取日志,并看到相同的消息序列。
  • 全序广播对于实现提供防护令牌的锁服务也很有用
    • 每个获取锁的请求都作为一条消息追加到日志末尾,并且所有的消息都按它们在日志中出现的顺序依次编号。
    • 序列号可以当成防护令牌用,因为它是单调递增的。
    • 在ZooKeeper中,这个序列号被称为zxid。

使用全序广播实现线性一致的存储

线性一致与全序广播的区别?

  • 全序广播是异步的:消息被保证以固定的顺序可靠地传送,但是不能保证消息何时被送达(所以一个接收者可能落后于其他接收者)。
    • 相比之下,线性一致性是新鲜性的保证:读取一定能看见最新的写入值。
    • 但如果有了全序广播,你就可以在此基础上构建线性一致的存储。例如,你可以确保用户名能唯一标识用户帐户。

防止用户名重复写入的例子: 可以通过将全序广播当成仅追加日志的方式来实现线性一致的CAS操作

  1. 在日志中追加一条消息,试探性地指明你要声明的用户名。
  2. 读日志,并等待你刚才追加的消息被读回。
  3. 检查是否有任何消息声称目标用户名的所有权。
    1. 如果这些消息中的第一条就是你自己的消息,那么你就成功了。
    2. 如果所需用户名的第一条消息来自其他用户,则中止操作。

全序广播对并发写入可以达到线性一致:

  • 由于日志项是以相同顺序送达至所有节点,因此如果有多个并发写入,则所有节点会对最先到达者达成一致。
  • 选择冲突写入中的第一个作为胜利者,并中止后来者,以此确定所有节点对某个写入是提交还是中止达成一致。
  • 类似的方法可以在一个日志的基础上实现可串行化的多对象事务

全序广播不保证读取可以达到线性一致:

  • 如果你从与日志异步更新的存储中读取数据,结果可能是陈旧的。 (精确地说,这里描述的过程提供了顺序一致性(sequential consistency),有时也称为时间线一致性(timeline consistency),比线性一致性稍微弱一些的保证)。

为了使读取也线性一致,有几个选项:

  • 你可以通过在日志中追加一条消息,然后读取日志,直到该消息被读回才执行实际的读取操作。消息在日志中的位置因此定义了读取发生的时间点。 (etcd的法定人数读取有些类似这种情况)
  • 如果日志允许以线性一致的方式获取最新日志消息的位置,则可以查询该位置,等待该位置前的所有消息都传达到你,然后执行读取。 (这是Zookeeper sync() 操作背后的思想)。
  • 你可以从同步更新的副本中进行读取,因此可以确保结果是最新的。 (这种技术用于链式复制(chain replication);请参阅“关于复制的研究”。)

使用线性一致性存储实现全序广播

我们也可以把它反过来,假设我们有线性一致的存储,接下来会展示如何在此基础上构建全序广播。

  • 最简单的方法是假设你有一个线性一致的寄存器来存储一个整数,并且有一个原子自增并返回操作。或者原子CAS操作也可以完成这项工作。
  • 该算法很简单:每个要通过全序广播发送的消息首先对线性一致寄存器执行自增并返回操作。然后将从寄存器获得的值作为序列号附加到消息中。然后你可以将消息发送到所有节点(重新发送任何丢失的消息),而收件人将按序列号依序传递(deliver)消息。
  • 注意:与兰伯特时间戳不同,通过自增线性一致性寄存器获得的数字形式上是一个没有间隙的序列。因此,如果一个节点已经发送了消息 4 并且接收到序列号为 6 的传入消息,则它知道它在传递消息 6 之前必须等待消息 5 。兰伯特时间戳则与之不同 —— 事实上,这是全序广播和时间戳排序间的关键区别。

实现一个带有原子性自增并返回操作的线性一致寄存器有多困难?

  • 像往常一样,如果事情从来不出差错,那很容易:你可以简单地把它保存在单个节点内的变量中。
  • 问题在于处理当该节点的网络连接中断时的情况,并在该节点失效时能恢复这个值。一般来说,如果你对线性一致性的序列号生成器进行过足够深入的思考,你不可避免地会得出一个共识算法

这并非巧合:可以证明,**线性一致的CAS(或自增并返回)寄存器与全序广播都都等价于共识问题!也就是说,如果你能解决其中的一个问题,你可以把它转化成为其他问题的解决方案。**这是相当深刻和令人惊讶的洞察!

分布式事务与共识

共识是什么?

  • 共识是分布式计算中最重要也是最基本的问题之一。
  • 从表面上看似乎很简单:非正式地讲,目标只是让几个节点达成一致(get serveral nodes to agree on something)
  • 你也许会认为这不会太难。不幸的是,许多出故障的系统都是因为错误地轻信这个问题很容易解决。

节点能达成一致,在很多场景下都非常重要,例如: 领导选举

  • 在单主复制的数据库中,所有节点需要就哪个节点是领导者达成一致。
  • 如果一些节点由于网络故障而无法与其他节点通信,则可能会对领导权的归属引起争议。
  • 在这种情况下,共识对于避免错误的故障切换非常重要。
  • 错误的故障切换会导致两个节点都认为自己是领导者(脑裂,请参阅“处理节点宕机”)。
  • 如果有两个领导者,它们都会接受写入,它们的数据会发生分歧,从而导致不一致和数据丢失。

原子提交

  • 在支持跨多节点或跨多分区事务的数据库中,一个事务可能在某些节点上失败,但在其他节点上成功。
  • 如果我们想要维护事务的原子性(就ACID而言,请参阅“原子性”),我们必须让所有节点对事务的结果达成一致:要么全部中止/回滚(如果出现任何错误),要么它们全部提交(如果没有出错)。
  • 这个共识的例子被称为原子提交(atomic commit) 问题。

本节顺序:原子提交(两阶段提交) -> ZooKeeper/etcd 使用的算法。

原子提交与两阶段提交

什么是事务原子性?

  • 事务原子性的目的是在多次写操作中途出错的情况下,提供一种简单的语义。事务的结果要么是成功提交,在这种情况下,事务的所有写入都是持久化的;要么是中止,在这种情况下,事务的所有写入都被回滚(即撤消或丢弃)。
  • 原子性可以防止失败的事务搅乱数据库,避免数据库陷入半成品结果和半更新状态。

从单节点到分布式原子提交

单节点如何做到原子提交?

  • 对于在单个数据库节点执行的事务,原子性通常由存储引擎实现。
  • 当客户端请求数据库节点提交事务时,数据库将使事务的写入持久化(通常在预写式日志中,请参阅“让B树更可靠”),然后将提交记录追加到磁盘中的日志里。
  • 如果数据库在这个过程中间崩溃,当节点重启时,事务会从日志中恢复:如果提交记录在崩溃之前成功地写入磁盘,则认为事务被提交;否则来自该事务的任何写入都被回滚。

单节点中为什么能做到原子提交?

  • 在单个节点上,事务的提交主要取决于数据持久化落盘的顺序:首先是数据,然后是提交记录。
  • 事务提交或终止的关键决定时刻是磁盘完成写入提交记录的时刻:在此之前,仍有可能中止(由于崩溃),但在此之后,事务已经提交(即使数据库崩溃)。
  • 因此,是单一的设备(连接到单个磁盘的控制器,且挂载在单台机器上)使得提交具有原子性。

如果一个事务中涉及多个节点呢?

  • 仅向所有节点发送提交请求并独立提交每个节点的事务是不够的。这样很容易发生违反原子性的情况:提交在某些节点上成功,而在其他节点上失败:
    • 某些节点可能会检测到违反约束或冲突,因此需要中止,而其他节点则可以成功进行提交。
    • 某些提交请求可能在网络中丢失,最终由于超时而中止,而其他提交请求则通过。
    • 在提交记录完全写入之前,某些节点可能会崩溃,并在恢复时回滚,而其他节点则成功提交。
  • 如果某些节点上提交了事务,但其他节点却放弃了这些事务,那么这些节点就会彼此不一致
  • 而且一旦在某个节点上提交了一个事务,如果事后发现它在其它节点上被中止了,它是无法撤回的。出于这个原因,一旦确定事务中的所有其他节点也将提交,节点就必须进行提交。

事务提交可以撤销吗?

  • 事务提交必须是不可撤销的 —— 事务提交之后,你不能改变主意,并追溯性地中止事务。
  • 这个规则的原因是,一旦数据被提交,其结果就对其他事务可见,因此其他客户端可能会开始依赖这些数据。
  • 这个原则构成了读已提交隔离等级的基础,在“读已提交”一节中讨论了这个问题。
  • 如果一个事务在提交后被允许中止,所有那些读取了已提交却又被追溯声明不存在数据的事务也必须回滚。
  • 如果想进行修改的话,必须新建一个事务。

两阶段提交简介

什么是两阶段提交?

  • 两阶段提交(two-phase commit) 是一种用于实现跨多个节点的原子事务提交的算法,即确保所有节点提交或所有节点中止。
  • 它是分布式数据库中的经典算法。
  • 2PC在某些数据库内部使用,也以XA事务的形式对应用可用(例如Java Transaction API支持)或以SOAP Web服务的WS-AtomicTransaction 形式提供给应用。

两阶段提交的原理?

  • 2PC中的提交/中止过程分为两个阶段(因此而得名),而不是单节点事务中的单个提交请求。

image.png

  • 2PC使用一个通常不会出现在单节点事务中的新组件:协调者(coordinator)(也称为事务管理器(transaction manager)
  • 正常情况下,2PC事务以应用在多个数据库节点上读写数据开始。我们称这些数据库节点为参与者(participants)
    • 当应用准备提交时,协调者开始阶段 1 :它发送一个准备(prepare) 请求到每个节点,询问它们是否能够提交。
    • 然后协调者会跟踪参与者的响应:
      • 如果所有参与者都回答“是”,表示它们已经准备好提交,那么协调者在阶段 2 发出提交(commit) 请求,然后提交真正发生。
      • 如果任意一个参与者回复了“否”,则协调者在阶段2 中向所有节点发送中止(abort) 请求。
    • 类似于西方婚礼💒上的司仪问新郎🤵🏻和新娘👰🏻是否愿意结婚,两者都回答“我愿意”的时候,才宣布结婚。

系统承诺

为了理解两阶段提交的工作原理,我们必须更详细地分解这个过程(简单来说,只要参与者回复了「同意」,那么必须保证能提交成功。):

  1. 当应用想要启动一个分布式事务时,它向协调者请求一个事务ID。此事务ID是全局唯一的。
  2. 应用在每个参与者上启动单节点事务,并在单节点事务上捎带上这个全局事务ID。所有的读写都是在这些单节点事务中各自完成的。如果在这个阶段出现任何问题(例如,节点崩溃或请求超时),则协调者或任何参与者都可以中止。
  3. 当应用准备提交时,协调者向所有参与者发送一个准备请求,并打上全局事务ID的标记。如果任意一个请求失败或超时,则协调者向所有参与者发送针对该事务ID的中止请求。
  4. **参与者收到准备请求时,需要确保在任意情况下都的确可以提交事务。**这包括将所有事务数据写入磁盘(出现故障,电源故障,或硬盘空间不足都不能是稍后拒绝提交的理由)以及检查是否存在任何冲突或违反约束。通过向协调者回答“是”,节点承诺,只要请求,这个事务一定可以不出差错地提交。换句话说,参与者放弃了中止事务的权利,但没有实际提交。
  5. 当协调者收到所有准备请求的答复时,会就提交或中止事务作出明确的决定(只有在所有参与者投赞成票的情况下才会提交)。协调者必须把这个决定写到磁盘上的事务日志中,如果它随后就崩溃,恢复后也能知道自己所做的决定。这被称为提交点(commit point)
  6. 一旦协调者的决定落盘,提交或放弃请求会发送给所有参与者。如果这个请求失败或超时,协调者必须永远保持重试,直到成功为止。**没有回头路:如果已经做出决定,不管需要多少次重试它都必须被执行。**如果参与者在此期间崩溃,事务将在其恢复后提交——由于参与者投了赞成,因此恢复后它不能拒绝提交。

问什么两阶段提交能保证原子性呢? 两阶段提交协议包含两个关键的“不归路”点::

  • 当参与者投票“是”时,它承诺它稍后肯定能够提交(尽管协调者可能仍然选择放弃);
  • 以及一旦协调者做出决定,这一决定是不可撤销的。
  • 这些承诺保证了2PC的原子性。 (单节点原子提交将这两个事件合为了一体:将提交记录写入事务日志。)

协调者失效

如果协调者崩溃,会发生什么?

  • 如果协调者在发送准备请求之前失败,参与者可以安全地中止事务。
  • 但是,一旦参与者收到了准备请求并投了“是”,就不能再单方面放弃 —— 必须等待协调者回答事务是否已经提交或中止。如果此时协调者崩溃或网络出现故障,参与者什么也做不了只能等待。参与者的这种事务状态称为存疑(in doubt) 的或不确定(uncertain) 的。

下图为例

  • 协调者实际上决定提交,数据库2 收到提交请求。但是,协调者在将提交请求发送到数据库1 之前发生崩溃,因此数据库1 不知道是否提交或中止。
  • 即使超时在这里也没有帮助:如果数据库1 在超时后单方面中止,它将最终与执行提交的数据库2 不一致。
  • 同样,单方面提交也是不安全的,因为另一个参与者可能已经中止了。

image.png

没有协调者的时候怎么办?

  • 没有协调者的消息,参与者无法知道是提交还是放弃。
  • 原则上参与者可以相互沟通,找出每个参与者是如何投票的,并达成一致,但这不是2PC协议的一部分。
  • 可以完成2PC的唯一方法是等待协调者恢复
    • 这就是为什么协调者必须在向参与者发送提交或中止请求之前,将其提交或中止决定写入磁盘上的事务日志:协调者恢复后,通过读取其事务日志来确定所有存疑事务的状态。
    • 任何在协调者日志中没有提交记录的事务都会中止。
    • 因此,2PC的提交点归结为协调者上的常规单节点原子提交。

三阶段提交

三阶段提交是什么,为什么大家还在用两阶段提交?

  • 两阶段提交被称为阻塞(blocking)- 原子提交协议,因为存在2PC可能卡住并等待协调者恢复的情况。
  • 理论上,可以使一个原子提交协议变为非阻塞(nonblocking) 的,但是比较麻烦。
  • 作为2PC的替代方案,已经提出了一种称为三阶段提交(3PC) 的算法,但它并不能保证原子性:
    • 通常,非阻塞原子提交需要一个**完美的故障检测器(perfect failure detector),**即一个可靠的机制来判断一个节点是否已经崩溃。但是无线延迟的网络中,超时并不是一种可靠的故障检测机制。
  • 因此,2PC仍然被使用,尽管大家都清楚可能存在协调者故障的问题。

实践中的分布式事务

分布式事务的现状:

  • 分布式事务的名声毁誉参半,尤其是那些通过两阶段提交实现的。
  • 一方面,它被视作提供了一个难以实现的重要的安全性保证;
  • 另一方面,它们因为导致运维问题,造成性能下降,做出超过能力范围的承诺而饱受批评。
  • 许多云服务由于其导致的运维问题,而选择不实现分布式事务。

分布式事务带来的性能影响:

  • MySQL中的分布式事务比单节点事务慢10倍以上

为什么还要用分布式事务呢? 首先区分两种不同的分布式事务: 数据库内部的分布式事务 一些分布式数据库支持数据库节点之间的内部事务,所有参与事务的节点都运行相同的数据库软件。 异构分布式事务 异构事务中,参与者是两种或者两种以上的不同技术:例如两种不同供应商的数据库,甚至非数据库(如消息代理)。跨系统的分布式事务必须确保原子提交,尽管系统可能完全不同。 可以使用任意协议,可以针对性的优化。也更有挑战性。

恰好一次的消息处理

异构的分布式事务处理能够以强大的方式集成不同的系统。 举例:

  • 消息队列中的一条消息可以被确认为已处理,当且仅当用于处理消息的数据库事务成功提交
  • 怎么实现?
    • 在同一个事务中原子提交消息确认数据库写入两个操作来实现的。
    • 藉由分布式事务的支持,即使消息代理和数据库是在不同机器上运行的两种不相关的技术,这种操作也是可能的。
  • 如果消息传递或数据库事务任意一者失败怎么办?
    • 两者都会中止,因此消息代理可能会在稍后安全地重传消息。
    • 因此,通过原子提交消息处理及其副作用,即使在成功之前需要几次重试,也可以确保消息被有效地(effectively) 恰好处理一次。中止会抛弃部分完成事务所导致的任何副作用。

然而,只有当所有受事务影响的系统都使用同样的原子提交协议(atomic commit protocol) 时,这样的分布式事务才是可能的。 例如:

  • 假设处理消息的副作用是发送一封邮件,而邮件服务器并不支持两阶段提交:
    • 如果消息处理失败并重试,则可能会发送两次或更多次的邮件。
    • 但如果处理消息的所有副作用都可以在事务中止时回滚,那么这样的处理流程就可以安全地重试,就好像什么都没有发生过一样。

XA事务

XA 事务是什么?

  • X/Open XA扩展架构(eXtended Architecture) 的缩写)是跨异构技术实现两阶段提交的标准。
  • 它于1991年推出并得到了广泛的实现:许多传统关系数据库(包括PostgreSQL,MySQL,DB2,SQL Server和Oracle)和消息代理(包括ActiveMQ,HornetQ,MSMQ和IBM MQ) 都支持XA。

XA 是网络协议吗?

  • XA 不是一个网络协议——它只是一个用来与事务协调者连接的C API。
  • 其他语言也有这种API的绑定;例如在Java EE应用的世界中,XA事务是使用Java事务API(JTA, Java Transaction API) 实现的,而许多使用Java数据库连接(JDBC, Java Database Connectivity) 的数据库驱动,以及许多使用Java消息服务(JMS) API的消息代理都支持Java事务API(JTA)

XA 原理?

  • XA假定你的应用使用网络驱动或客户端库来与参与者(数据库或消息服务)进行通信。
  • 如果驱动支持XA,则意味着它会调用XA API 以查明操作是否为分布式事务的一部分 —— 如果是,则将必要的信息发往数据库服务器。
  • 驱动还会向协调者暴露回调接口,协调者可以通过回调来要求参与者准备、提交或中止。

事务协调者是什么?

  • 事务协调者需要实现XA API。
  • 标准没有指明应该如何实现,但实际上协调者通常只是一个库,被加载到发起事务的应用的同一个进程中(而不是单独的服务)。
  • 它在事务中跟踪所有的参与者,并在要求它们准备之后收集参与者的响应(通过驱动回调),并使用本地磁盘上的日志记录每次事务的决定(提交/中止)。

出现异常情况怎么办?

  • 如果应用进程崩溃,或者运行应用的机器报销了,协调者也随之往生极乐。
  • 然后任何带有准备了但未提交事务的参与者都会在疑虑中卡死。
  • 由于协调程序的日志位于应用服务器的本地磁盘上,因此必须重启该服务器,且协调程序库必须读取日志以恢复每个事务的提交/中止结果。
  • 只有这样,协调者才能使用数据库驱动的XA回调来要求参与者提交或中止。
  • 数据库服务器不能直接联系协调者,因为所有通信都必须通过客户端库。

怀疑时持有锁

为什么我们这么关心存疑事务?

  • 问题在于锁(locking)
  • 数据库事务通常获取待修改的行上的行级排他锁,以防止脏写。
  • 此外,如果要使用可串行化的隔离等级,则使用两阶段锁定的数据库也必须为事务所读取的行加上共享锁。
  • 在事务提交或中止之前,数据库不能释放这些锁。
  • 因此,在使用两阶段提交时,事务必须在整个存疑期间持有这些锁
  • 如果协调者已经崩溃,需要20分钟才能重启,那么这些锁将会被持有20分钟。
  • 如果协调者的日志由于某种原因彻底丢失,这些锁将被永久持有 —— 或至少在管理员手动解决该情况之前。

当这些锁被持有时,会发生什么?

  • 其他事务不能修改这些行。
  • 根据数据库的不同,其他事务甚至可能因为读取这些行而被阻塞;
  • 因此,其他事务没法儿简单地继续它们的业务了 —— 如果它们要访问同样的数据,就会被阻塞。
  • 这可能会导致应用大面积进入不可用状态,直到存疑事务被解决。

从协调者故障中恢复

协调者从崩溃中重启,会发生什么?

  • 理论上,如果协调者崩溃并重新启动,它应该干净地从日志中恢复其状态,并解决任何存疑事务。
  • 实际上,孤立(orphaned) 的存疑事务确实会出现,即无论出于何种理由,协调者无法确定事务的结果(例如事务日志已经由于软件错误丢失或损坏)。这些事务无法自动解决,所以它们永远待在数据库中,持有锁并阻塞其他事务。
  • 重启数据库服务器可以吗?
    • 不行,因为在2PC的正确实现中,即使重启也必须保留存疑事务的锁(否则就会冒违反原子性保证的风险)。
    • 这是一种棘手的情况。

那怎么解决孤立(orphaned) 的存疑事务?

  • 唯一的出路是让管理员手动决定提交还是回滚事务。
  • 管理员必须检查每个存疑事务的参与者,确定是否有任何参与者已经提交或中止,然后将相同的结果应用于其他参与者。
  • 解决这个问题潜在地需要大量的人力,并且可能发生在严重的生产中断期间(不然为什么协调者处于这种糟糕的状态),并很可能要在巨大精神压力和时间压力下完成。

有没有痛快点的解决方案?

  • 许多XA的实现都有一个叫做启发式决策(heuristic decisions) 的紧急逃生舱口:允许参与者单方面决定放弃或提交一个存疑事务,而无需协调者做出最终决定。
  • 要清楚的是,这里启发式可能破坏原子性(probably breaking atomicity) 的委婉说法,因为它违背了两阶段提交的系统承诺。
  • 因此,启发式决策只是为了逃出灾难性的情况而准备的,而不是为了日常使用的。

分布式事务的限制

XA 事务导致了严重的运维问题。 特别来讲,这里的核心认识是:事务协调者本身就是一种数据库(存储了事务的结果),因此需要像其他重要数据库一样小心地打交道:

  • 如果协调者没有复制,而是只在单台机器上运行,那么它是整个系统的失效单点(因为它的失效会导致其他应用服务器阻塞在存疑事务持有的锁上)。令人惊讶的是,许多协调者实现默认情况下并不是高可用的,或者只有基本的复制支持。
  • 许多服务器端应用都是使用无状态模式开发的(受HTTP的青睐),所有持久状态都存储在数据库中,因此具有应用服务器可随意按需添加删除的优点。但是,当协调者成为应用服务器的一部分时,它会改变部署的性质。突然间,协调者的日志成为持久系统状态的关键部分—— 与数据库本身一样重要,因为协调者日志是为了在崩溃后恢复存疑事务所必需的。这样的应用服务器不再是无状态的了
  • 由于XA需要兼容各种数据系统,因此它必须是所有系统的最小公分母。例如,它不能检测不同系统间的死锁(因为这将需要一个标准协议来让系统交换每个事务正在等待的锁的信息),而且它无法与SSI(请参阅可串行化快照隔离)协同工作,因为这需要一个跨系统定位冲突的协议。
  • 对于数据库内部的分布式事务(不是XA),限制没有这么大 —— 例如,分布式版本的SSI是可能的。然而仍然存在问题:2PC成功提交一个事务需要所有参与者的响应。因此,如果系统的任何部分损坏,事务也会失败。因此,分布式事务又有扩大失效(amplifying failures) 的趋势,这又与我们构建容错系统的目标背道而驰。

这些事实是否意味着我们应该放弃保持几个系统相互一致的所有希望?

  • 不完全是 —— 还有其他的办法,可以让我们在没有异构分布式事务的痛苦的情况下实现同样的事情。我们将在第十一章第十二章 回到这些话题。

容错共识

共识是什么?

  • 共识意味着让几个节点就某事达成一致。
  • 共识算法可以用来确定互不相容(mutually incompatible) 的操作中,哪一个才是赢家。

共识问题的形式化描述:

  • 一个或多个节点可以提议(propose) 某些值,而共识算法决定(decides) 采用其中的某个值。
  • 在座位预订的例子中,当几个顾客同时试图订购最后一个座位时,处理顾客请求的每个节点可以提议将要服务的顾客的ID,而决定指明了哪个顾客获得了座位。

共识算法必须满足以下性质: 一致同意(Uniform agreement) 没有两个节点的决定不同。 完整性(Integrity) 没有节点决定两次。 有效性(Validity) 如果一个节点决定了值 v ,则 v 由某个节点所提议。 终止(Termination) 由所有未崩溃的节点来最终决定值。

上面几个性质的作用?

  • 一致同意完整性属性定义了共识的核心思想:所有人都决定了相同的结果,一旦决定了,你就不能改变主意。
  • 有效性属性主要是为了排除平凡的解决方案:例如,无论提议了什么值,你都可以有一个始终决定值为null的算法;该算法满足一致同意完整性属性,但不满足有效性属性。
  • 终止属性形式化了容错的思想。它实质上说的是,一个共识算法不能简单地永远闲坐着等死 —— 换句话说,它必须取得进展。即使部分节点出现故障,其他节点也必须达成一项决定。 (终止是一种活性属性,而另外三种是安全属性 —— 请参阅“安全性和活性”。)

节点崩溃时,怎么达成共识?

  • 共识的系统模型假设,当一个节点“崩溃”时,它会突然消失而且永远不会回来。
  • 算法可以容忍的失效数量是有限的:事实上可以证明,任何共识算法都需要至少占总体多数(majority) 的节点正确工作,以确保终止属性。多数可以安全地组成法定人数。
  • 因此终止属性取决于一个假设,不超过一半的节点崩溃或不可达
  • 实际上,大规模的中断不会破坏共识系统。

拜占庭错误?

  • 大多数共识算法假设不存在拜占庭式错误
  • 考虑拜占庭错误来达成共识也是可以的,只要少于三分之一的节点存在拜占庭故障。

共识算法和全序广播

有哪些著名的容错共识算法?

  • 最著名的容错共识算法是视图戳复制(VSR, Viewstamped Replication),Paxos,Raft 以及 Zab。
  • 大多数这些算法实际上并不直接使用这里描述的形式化模型,而是决定了值的顺序,这使它们成为全序广播算法。

什么是全序广播算法?

  • 全序广播要求将消息按照相同的顺序,恰好传递一次,准确传送到所有节点。
  • 这相当于进行了几轮共识:在每一轮中,节点提议下一条要发送的消息,然后决定在全序中下一条要发送的消息。

全序广播相当于重复进行多轮共识(每次共识决定与一次消息传递相对应):

  • 由于一致同意属性,所有节点决定以相同的顺序传递相同的消息。
  • 由于完整性属性,消息不会重复。
  • 由于有效性属性,消息不会被损坏,也不能凭空编造。
  • 由于终止属性,消息不会丢失。

视图戳复制,Raft和Zab直接实现了全序广播,因为这样做比重复一次一值(one value a time) 的共识更高效。在Paxos的情况下,这种优化被称为Multi-Paxos。

单领导者复制与共识

第五章中的单领导者复制,把所有的写入操作都交给主库,并以相同的顺序将它们应用到从库,从而使副本保持在最新状态。这就是全序广播,为什么没有共识问题?

  • 答案取决于如何选择领导者。
  • 如果主库是由运维人员手动选择和配置的,那么你实际上拥有一种独裁类型的“共识算法”:只有一个节点被允许接受写入(即决定写入复制日志的顺序),如果该节点发生故障,则系统将无法写入,直到运维手动配置其他节点作为主库。
  • 这样的系统在实践中可以表现良好,但它无法满足共识的终止属性,因为它需要人为干预才能取得进展
  • 一些数据库会自动执行领导者选举和故障切换,如果旧主库失效,会提拔一个从库为新主库(请参阅“处理节点宕机”)。这使我们向容错的全序广播更进一步,从而达成共识。

但存在脑裂问题:选出一位领导者需要共识。但如果这里描述的共识算法实际上是全序广播算法,并且全序广播就像单主复制,而单主复制需要一个领导者,那么...就形成了死循环。

纪元编号和法定人数

怎么保证领导者是独一无二的?

  • 之前讨论的共识协议,内部都是一个领导者,不能保证领导者是独一无二的。
  • 相反,它们可以做出更弱的保证:协议定义了一个纪元编号(epoch number)(在Paxos中称为投票编号(ballot number),视图戳复制中的视图编号(view number),以及Raft中的任期号码(term number)),并确保在每个时代中,领导者都是唯一的。
  • 每次当现任领导被认为挂掉的时候,节点间就会开始一场投票,以选出一个新领导。这次选举被赋予一个递增的纪元编号,因此纪元编号是全序且单调递增的。如果两个不同的时代的领导者之间出现冲突(也许是因为前任领导者实际上并未死亡),那么带有更高纪元编号的领导说了算。

领导者如何知道自己没有被另一个节点赶下台?

  • 一个节点不一定能相信自己的判断—— 因为只有节点自己认为自己是领导者,并不一定意味着其他节点接受它作为它们的领导者。
  • 任何领导者做事情之前,都要检查一下是否有其他更高纪元编号的领导者。
  • 领导者想要做出的每一个决定,都必须将提议值发送给其他节点,并等待法定人数的节点响应并赞成提案。法定人数通常(但不总是)由多数节点组成。只有在没有意识到任何带有更高纪元编号的领导者的情况下,一个节点才会投票赞成提议。

两轮投票?

  • 第一次是为了选出一位领导者,第二次是对领导者的提议进行表决。
  • 关键的洞察在于,这两次投票的法定人群必须相互重叠(overlap):如果一个提案的表决通过,则至少得有一个参与投票的节点也必须参加过最近的领导者选举

共识的局限性

共识算法的巨大突破:

  • 它为其他充满不确定性的系统带来了基础的安全属性(一致同意,完整性和有效性),然而它们还能保持容错(只要多数节点正常工作且可达,就能取得进展)。
  • 它们提供了全序广播,因此它们也可以以一种容错的方式实现线性一致的原子操作

共识算法的缺点:

  • 节点在做出决定之前对提议进行投票的过程是一种同步复制。而通常数据库会配置为异步复制模式。
  • 共识系统总是需要严格多数来运转,那能容忍故障度是一半机器。
  • 大多数共识算法假定参与投票的节点是固定的集合,这意味着你不能简单的在集群中添加或删除节点。共识算法的动态成员扩展(dynamic membership extension) 允许集群中的节点集随时间推移而变化,但是它们比静态成员算法要难理解得多。
  • 共识系统通常依靠超时来检测失效的节点。而短暂的网络问题容易造成误判,导致频繁切换领导者。

成员与协调服务

ZooKeeper或etcd这样的项目通常被描述为“分布式键值存储”或“协调与配置服务”,它们的服务 API 看起来很像数据库,那么它们和数据库的区别是什么呢?

  • ZooKeeper和etcd被设计为容纳少量完全可以放在内存中的数据(虽然它们仍然会写入磁盘以保证持久性),所以你不会想着把所有应用数据放到这里。
  • 这些少量数据会通过容错的全序广播算法复制到所有节点上。正如前面所讨论的那样,数据库复制需要的就是全序广播:如果每条消息代表对数据库的写入,则以相同的顺序应用相同的写入操作可以使副本之间保持一致。

ZooKeeper模仿了Google的Chubby锁服务,不仅实现了全序广播(因此也实现了共识),而且还构建了一组有趣的其他特性,这些特性在构建分布式系统时变得特别有用: 线性一致性的原子操作

  • 使用原子CAS操作可以实现锁:如果多个节点同时尝试执行相同的操作,只有一个节点会成功。
  • 共识协议保证了操作的原子性和线性一致性,即使节点发生故障或网络在任意时刻中断。
  • 分布式锁通常以租约(lease) 的形式实现,租约有一个到期时间,以便在客户端失效的情况下最终能被释放

操作的全序排序

  • 当某个资源受到锁或租约的保护时,你需要一个防护令牌来防止客户端在进程暂停的情况下彼此冲突。
  • 防护令牌是每次锁被获取时单调增加的数字。 ZooKeeper通过全序化所有操作来提供这个功能,它为每个操作提供一个单调递增的事务ID(zxid)和版本号(cversion)

失效检测

  • 客户端在ZooKeeper服务器上维护一个长期会话,客户端和服务器周期性地交换心跳包来检查节点是否还活着。
  • 短暂心跳停止,会话仍在活跃状态
  • 但是如果心跳停止超时,ZooKeeper会宣告该会话已死亡。
  • 当会话超时时,会话持有的任何锁都可以配置为自动释放。

变更通知

  • 客户端不仅可以读取其他客户端创建的锁和值,还可以监听它们的变更。
  • 因此,客户端可以知道另一个客户端何时加入集群(基于新客户端写入ZooKeeper的值),或发生故障(因其会话超时,而其临时节点消失)。
  • 通过订阅通知,客户端不用再通过频繁轮询的方式来找出变更。

将工作分配给节点

ZooKeeper/Chubby模型运行良好的一个例子是:

  • 选择多个主库中其中一个实例作为主库或首选服务。
  • 如果领导者失败,其他节点之一应该接管。
  • 这对单主数据库当然非常实用,但对作业调度程序和类似的有状态系统也很好用。

另一个例子是:

  • 当你有一些分区资源(数据库,消息流,文件存储,分布式Actor系统等),并需要决定将哪个分区分配给哪个节点时。
  • 当新节点加入集群时,需要将某些分区从现有节点移动到新节点,以便重新平衡负载。
  • 当节点被移除或失效时,其他节点需要接管失效节点的工作。

上面这类任务可以用 ZooKepper 中明智地使用原子操作,临时节点与通知来实现。

节点多了的时候,共识算法的投票是不是很低效?

  • ZooKeeper在固定数量的节点(通常是三到五个)上运行,并在这些节点之间执行其多数票,同时支持潜在的大量客户端。
  • 因此,ZooKeeper提供了一种将协调节点(共识,操作排序和故障检测)的一些工作“外包”到外部服务的方式。

服务发现

ZooKeeper,etcd和Consul也经常用于服务发现——也就是找出你需要连接到哪个IP地址才能到达特定的服务。

为什么需要服务发现?

  • 在云数据中心环境中,虚拟机来来往往很常见,你通常不会事先知道服务的IP地址。
  • 相反,你可以配置你的服务,使其在启动时注册服务注册表中的网络端点,然后可以由其他服务找到它们。

服务发现一定需要共识吗?

  • 不一定,比如 DNS 有多级缓存,查询结果可能就比较陈旧。

成员资格服务

  • ZooKeeper和它的小伙伴们可以看作是成员资格服务(membership services)研究的悠久历史的一部分,这个历史可以追溯到20世纪80年代,并且对建立高度可靠的系统(例如空中交通管制)非常重要。

为什么需要成员资格服务?

  • 成员资格服务确定哪些节点当前处于活动状态并且是集群的活动成员。
  • 由于无限的网络延迟,无法可靠地检测到另一个节点是否发生故障。
  • 但是,如果你通过共识来进行故障检测,那么节点可以就哪些节点应该被认为是存在或不存在达成一致。

当错误地被宣告死亡怎么办?

  • 即使一个节点确实存在,仍然可能发生一个节点被共识错误地宣告死亡。
  • 但是对于一个系统来说,知道哪些节点构成了当前的成员关系是非常有用的。

本章小结

在本章中,我们从几个不同的角度审视了关于一致性与共识的话题。

  • 我们深入研究了线性一致性(一种流行的一致性模型):其目标是使多副本数据看起来好像只有一个副本一样,并使其上所有操作都原子性地生效。虽然线性一致性因为简单易懂而很吸引人 —— 它使数据库表现的好像单线程程序中的一个变量一样,但它有着速度缓慢的缺点,特别是在网络延迟很大的环境中。
  • 我们还探讨了因果性,因果性对系统中的事件施加了顺序(什么发生在什么之前,基于因与果)。与线性一致不同,线性一致性将所有操作放在单一的全序时间线中,因果一致性为我们提供了一个较弱的一致性模型:某些事件可以是并发的,所以版本历史就像是一条不断分叉与合并的时间线。因果一致性没有线性一致性的协调开销,而且对网络问题的敏感性要低得多。
  • 但即使捕获到因果顺序(例如使用兰伯特时间戳),我们发现有些事情也不能通过这种方式实现:在“光有时间戳排序还不够”一节的例子中,我们需要确保用户名是唯一的,并拒绝同一用户名的其他并发注册。如果一个节点要通过注册,则需要知道其他的节点没有在并发抢注同一用户名的过程中。这个问题引领我们走向共识
  • 我们看到,达成共识意味着以这样一种方式决定某件事:所有节点一致同意所做决定,且这一决定不可撤销。通过深入挖掘,结果我们发现很广泛的一系列问题实际上都可以归结为共识问题,并且彼此等价(从这个意义上来讲,如果你有其中之一的解决方案,就可以轻易将它转换为其他问题的解决方案)。这些等价的问题包括:

线性一致性的CAS寄存器 寄存器需要基于当前值是否等于操作给出的参数,原子地决定是否设置新值。 原子事务提交 数据库必须决定是否提交或中止分布式事务。 全序广播 消息系统必须决定传递消息的顺序。 锁和租约 当几个客户端争抢锁或租约时,由锁来决定哪个客户端成功获得锁。 成员/协调服务 给定某种故障检测器(例如超时),系统必须决定哪些节点活着,哪些节点因为会话超时需要被宣告死亡。 唯一性约束 当多个事务同时尝试使用相同的键创建冲突记录时,约束必须决定哪一个被允许,哪些因为违反约束而失败。

  • 尽管单领导者数据库可以提供线性一致性,且无需对每个写操作都执行共识算法,但共识对于保持及变更领导权仍然是必须的。因此从某种意义上说,使用单个领导者不过是“缓兵之计”:共识仍然是需要的,只是在另一个地方,而且没那么频繁。好消息是,容错的共识算法与容错的共识系统是存在的,我们在本章中简要地讨论了它们。
  • 像ZooKeeper这样的工具为应用提供了“外包”的共识、故障检测和成员服务。
  • 尽管如此,并不是所有系统都需要共识:例如,无领导者复制和多领导者复制系统通常不会使用全局的共识。