数据密集型应用系统设计 学习笔记(九):一致性与共识

一致性与共识

可线性化(强一致性)

在最终一致性数据库中,同时查询两个不同的副本可能会得到两个不同的答案。这会 使应用层感到困惑。如果数据库能够对上提供只有单个副本的假象,情况会不会大为简化呢?

这样让每个客户端都拥有相同的数据视图,而不必担心复制滞后的思想,就是可线性化(强一致性)其基本的想法是让一个系统看起来好像只有一个数据副本,且所有的操作都是原子的。 有了这个保证,应用程序就不需要关心系统内部的多个副本。

在一个可线性化的系统中,一旦某个客户端成功提交写请求,所有客户端的读请求一定都能看到刚刚写入的值。这种看似单一副本的假象意味着它可以保证读取最近最新值,而不是过期的缓存。换句话说,可线性化是一种就近的保证。

可线性化与可串行化

  • 可串行化:可串行化是事务的隔离级别,其中每个事务可以读写多个对象。它用来确保事务执行的结果与串行执行(即每次执行一个事务)的结果完全相同,即使串行执行的顺序可能与事务实际执行顺序不同。

  • 可线性化:可线性化是读写寄存器(单个对象)的最新值保证。它并不要求将操作组合到事务中,因此无法避免写倾斜等问题,除非采取其他额外措施。

数据库可以同时支持可串行化与线性化,这种组合又被称为严格的可串行化或者强的单副本可串行化。基于两阶段加锁或者实际以串行执行都是典型的可线性化。

使用场景

在有些场景下,线性化对于保证系统正确工作至关重要。

加锁与主节点选举

主从复制的系统需要确保有且只有一个主节点,否则会产生脑裂。选举新的主节点常见的方法是使用锁,即每个启动的节点都试图获得锁,其中只有一个可以成功即成为主节点。不管锁具体如何实现,它都必须满足可线性化,即所有节点都必须同意哪个节点持有锁,否则就会出现问题。

约束与唯一性保证

唯一性约束在数据库中很常见。例如,用户名或电子邮件地址必须唯一标识一个用户、文件存储服务中两个文件不能具有相同的路径和文件名。如果要在写入数据时强制执行这些约束(例如,如果两个人试图同时创建具有相同名称的用户或文件,其中一个必须返回错误),则也需要线性化。

这种情况本质上与加锁非常类似,用户注册等同于试图对用户名进行加锁操作。该操作也类似于原子比较和设置,如果当前用户名尚未被使用,就设置用户名与客户 ID 进行关联。

跨通道的时间依赖

如果某个系统存在多个通信通道(如消息队列、文件存储),如果没有线性化的就近性保证,这两个通道之间就会产生竞争(如消息队列比存储服务内部复制更快),在这种情况下就会导致数据的不一致出现。

实现

由于线性化本质上意味着表现得好像只有一个数据副本,且其上的所有操作都是原子的,所以最简单的方案自然是只用一个数据副本。但显然,该方法无法容错。如果仅有的副本所在的节点发生故障,就会导致数据丢失,或者至少在重启之前都无法访问服务。

系统容错最常见的方法就是采用复制机制,那就来分析以下各个复制方案能否满足可线性化:

  • 主从复制(部分支持线性化):在主从复制的系统中,只有主节点承担数据写入,从节点则在各自节点上维护数据的备份副本。如果从主节点或者同步更新的从节点上读取,则可以满足线性化。但并非每个主从复制的具体数据库示例都是可线性化的,主要是因为它们可能采用了快照隔离的设计,或者实现时存在并发方面的 bug(例如异步复制后数据丢失、从节点自认为是主节点等)。
  • 共识算法(可线性化):共识算法通常内置一些措施来防止脑裂和副本过期。正是由于这些专门的设计,共识算法可以安全地实现线性化存储。
  • 多主复制(不可线性化):具有多主节点复制的系统通常无法线性化的,主要由于它们同时在多个节点上执行并发写入,并将数据异步复制到其他节点 。因此它们可能会产生冲突的写入,需要额外的解决方案。
  • 无主复制(可能不可线性化):对于无主节点复制的系统,有些人认为只要配置法定读取和写入满足 w+r>n 就可以获得强一致性。但这完全取决于具体的 quorum 的配置,以及如何定义强一致性,它可能并不保证线性化。例如最后写入获胜几乎肯定是非线性化,因为这种时间戳无法保证与实际事件顺序一致(例如由于时钟偏移)不规范的 quorum 也会破坏线性化。

顺序保证

顺序与因果关系

如果系统服从因果关系所规定的顺序,我们称之为因果一致性。 例如,快照隔离提供了因果一致性,当从数据库中读数据时,如果查询到了某些数据,一定能看到触发该数据的前序事件( 假设期间没有发生删除操作)。

因果顺序并非全序

全序关系支持任何两个元素之间进行比较,即对于任意两个元素,总是可以指出哪个更大,哪个更小。但是,有些集合并不符合全序,因为它们都不是对方的子集,所以无法直接比较它们。我们称之为不可比较,数学集合只能是偏序

全序和偏序的差异也会体现在不同的数据库一致性模型中:

  • 可线性化:在一个可线性化的系统中,存在全序操作关系。系统的行为就好像只有一个数据副本,且每个操作都是原子的,这意味着对于任何两个操作,我们总是可以指出哪个操作在先。
  • 因果关系:如果两个操作都没有发生在对方之前,那么这两个操作是并发关系。换言之,如果两个事件是因果关系(一个发生在另一个之前),那么这两个事件可以被排序。而并发的事件则无法排序比较。这表明因果关系至少可以定义为偏序,而非全序。

因此,根据这个定义,在可线性化数据存储中不存在并发操作,一定有一个时间线将所有操作都全序执行。可能存在多个请求处于等待处理的状态,但是数据存储保证了在特定的时间点执行特定的操作,所以是单个时间轴,单个数据副本,没有并发。

可线性化强于因果一致性

那么因果序和可线性化之间是什么关系呢?

答案是可线性化一定意味着因果关系,任何可线性化的系统都将正确地保证因果关系。 特别是,如果系统存在多个通信通道,可线性化确保了因果关系会自动全部保留,而不需要额外的工作(比如在不同组件之间的传递时间戳)。

可线性化虽然可以确保因果性,但其会显著降低性能和可用性,尤其是在严重网络延迟的情况下。正因如此,一些分布式数据系统已经放弃了线性化,以换来更好的性能,但也存在可能无法正确工作的风险。好消息是线性化并非是保证因果关系的唯一途径 ,还有其他方法使得系统可以满足因果一致性而免于线性化所带来的性能问题。

因果一致性可以认为是,不会由于网络延迟而显著影响性能,又能对网络故障提供容错的最强的一致性模型。

捕获因果依赖关系

为保持因果关系,需要知道哪个操作发生在前。这里只需偏序关系,或并发操作会以任意顺序执行,但如果一个操作发生在另一个操作之前,那么每个副本都应该按照相同的顺序处理。因此,当某个副本在处理一个请求时,必须确保所有因果在前的请求都已完成处理,否则,后面的请求必须等待直到前序操作处理完毕。

为了确定请求的因果依赖关系,它需要跟踪整个数据库请求的因果关系,而不仅仅针对某个主键。版本向量技术可以推广为一种通用的解决方案。

序列号排序

虽然因果关系很重要,但实际上跟踪所有的因果关系不切实际 。在许多应用程序中,客户端在写入之前会先读取大量数据,系统无法了解之后的写入究竟是依赖于全部读取内容, 还是仅仅是一小部分。但很明显,显式跟踪所有已读数据意味着巨大的运行开销。

这里还有一个更好的方法,我们可以使用序列号时间戳来排序事件。这样的序列号或时间戳非常紧凑(只有几字节大小),但它保证了全序关系。也就是说,每一个操作都有唯一的顺序号,并且总是可以通过比较来确定哪个更大(即操作发生在后)。这样的全局排序可以捕获所有的因果信息,但也强加了比因果关系更为严格的顺序性。

非因果序列发生器

如果系统不存在这样唯一的主节点(例如可能是多主或者无主类型的数据库,或者数据库本身是分区的),产生序列号就不是那么简单了。实践中可以采用以下方法:

  • 每个节点都独立产生自己的一组序列号。 例如,如果有两个节点,则一个节点只生成奇数,而另一个节点只生成偶数。还可以在序列号中保留一些位用于嵌入所属节点的唯一标识符,确保不同的节点永远不会生成相同的序列号。
  • 可以把日历时钟信息(物理时钟)附加到每个操作上。 时间戳可能是不连续的,但是只要它们有足够高的分辨率,就可以用来区分操作。最后写获胜的冲突解决方案也使用类似的方法。
  • 可以预先分配序列号的区间范围。 例如,节点 A 负责区间 1~1000 的序列号,节点 B 负责 1001~2000。然后每个节点独立地从区间中分配序列号,当序列号出现紧张时就分配更多的区间。

上述三种思路都可行,相比于把所有请求全部压给唯一的主节点具有更好的扩展性。它们为每个操作生成一个唯 一的、近似增加的序列号。不过,它们也都存在一个问题,即所产生的序列号与因果关系井不严格一致。

所有这些序列号发生器都无法保证正确捕获跨节点操作的顺序,因而存在因果关系方面的问题:

  • 每个节点可能有不同的处理速度,如每秒请求数。因此,某个节点产生偶数而另一个产生奇数,偶数的计数器产生速度可能落后于奇数的计数器,反之亦然。这样就无法准确地知道哪个操作在先。
  • 物理时钟的时间戳会受到时钟偏移的影响,也可能导致与实际因果关系不一致。
  • 对于区间分配器,一个操作可能被赋予从 1001~2000 之间的某个序列号,而后发生的操作则路由到另一个节点,拿到了某个 1~1000 之间的序列号,导致与因果序不一致。

Lamport 时间戳

刚才所描述的三个序列号发生器可能与因果关系存在不一致,但还有一个简单的方法可以产生与因果关系一致的序列号,即 Lamport 时间戳

首先每个节点都有一个唯一的标识符,且每个节点都有一个计数器来记录各自已处理的请求总数。 Lamport 时间戳是一个键值对(计数器,节点 ID)。两个节点可能会有相同的计数器值,但时间戳中还包含节点 ID 信息,因此可以确保每个时间戳都是唯一的。 原理如下图所示:

Lamport 时间戳与物理时钟并不存在直接对应关系,但它可以保证全序。给定两个 Lamport 时间戳,计数器较大那个时间戳大,而如果计数器值正好相同,则节点 ID 越大,时间戳越大。

上面的描述看起来和前面提过的奇偶发生器类似,那它是如何保证因果一致性的呢?每个节点以及每个客户端都跟踪迄今为止所见到的最大计数器值,井在每个请求中附带该最大计数器值。当节点收到某个请求(或者回复)时,如果发现请求内嵌的最大计数器值大于节点自身的计数器值,则它立即把自己的计数器修改为该最大值。

Lamport 时间戳有时会与版本向量发生混淆。虽然存在一些相似之处,但它们的目的不同:版本向量用以区分两个操作是并发还是因果依赖,而 Lamport 时间戳则主要用于确保全序关系。 即使 Lamport 时间戳与因果序一致,但根据其全序关系却无法区分两个操作属于并发关系,还是因果依赖关系。Lamport 时间戳优于版本向量之处在于它更加紧凑和高效。

全序关系广播

如果程序只运行在 CPU 单核上,可以非常简单地定义出操作的全序关系,即在单核上执行的顺序。但是,在分布式系统中,让所有的节点就全序关系达成一致就面临巨大挑战。

如前所述,主从复制首先确定某个节点作为主节点,然后在主节点上顺序执行操作。接下来的主要挑战在于,如何扩展系统的吞吐量使之突破单主节点的限制,以及如何处理主节点失效时的故障切换。在分布式系统中,这些问题被称为全序关系广播或者原子广播

全序关系广播通常指节点之间交换消息的某种协议。 下面是个非正式的定义,它要求满足两个基本安全属性:

  • 可靠发送:没有消息丢失,如果消息发送到了某一个节点,则一定要发送到所有节点。
  • 严格有序:消息总是以相同的顺序发送给每个节点。

即使节点或网络出现了故障,全序关系广播算法的正确实现也必须保证上述两条。当然,网络中断时是不可能发送成功的,但算法要继续重试,直到最终网络修复,消息发送成功(且必须以正确的顺序发送)。

使用场景

  • 数据库复制:如果每条消息代表数据库写请求,并且每个副本都按相同的顺序处理这些写请求,那么所有副本可以保持一致(或许有些滞后)。该原则也被称为状态机复制。
  • 可串行化事务:如果每条消息表示一个确定性事务井且作为存储过程来执行,且每个节点都遵从相同的执行顺序,那么可以保证数据库各分区以及各副本之间的一致性。
  • 分布式锁:每个获取锁的请求都作为消息附加到日志中,所有消息按照日志中的顺序依次编号。序列号还可以作为令牌,它符合单调递增要求。
  • 复制日志、事务日志、预写日志:传递消息就像追加方式更新日志。由于所有节点必须以相同的顺序发送消息,因此所有节点都可以读取日志并看到相同的消息序列。

全序关系广播实现线性化存储

如果有了全序关系广播,就可以在其上构建线性化的存储系统。例如,确保用户名唯一标识一个用户。

可以通过使用全序关系广播以追加日志的方式来实现线性化的原子比较-设置操作,步骤如下所示:

  1. 在日志中追加一条消息,并指明想要的用户名。
  2. 读取日志,将其广播给所有节点,并等待回复。
  3. 检查是否有任何消息声称该用户名已被占用。 如果第一条这样的回复来自于当前节点,那么就成功获得该用户名,可以提交该获取声明(也许附加另一条消息到日志)并返回给客户端。反之,如果声称占用的第一条回复消息来自其他节点,则中止操作。

由于日志条目以相同的顺序发送到所有节点,而如果存在多个并发写入, 则所有节点将首先决定哪个请求在先。选择第一个写请求作为获胜者,并终止其他请求,以确保所有节点同意一个写请求最终要么提交成功要么终止。

虽然此过程可确保线性化写入,但它却无法保证线性化读取,即从异步日志更新的存储中读取数据时,可能是旧值。 具体来说,这里只提供了顺序一致性,有时也称为时间线一致性,它弱于线性化保证。为了同时满足线性化读取,有以下几个方案:

  • 可以采用追加的方式把读请求排序、广播,然后各个节点获取该日志,当本节点收到消息时才执行真正的读操作。消息在日志中的位置已经决定了读取发生的时间点。
  • 如果可以用线性化的方式获取当前最新日志中消息的位置,则查询位置,等待直到该位置之前的所有条目都 经发送给你,接下来再执行读取。
  • 可以从同步更新的副本上进行读取,这样确保总是读取最新值。

线性化存储实现全序关系广播

假设我们已经有了线性化的存储,那么如何在其上构建全序关系广播呢?最简单的方法是假设有一个线性化的寄存器来存储一个计数,然后使其支持原子自增-读取操作 或者原子比较-设置操作。

算法思路很简单,对于每个要通过全序关系广播的消息,原子递增井读取该线性化的计数,然后将其作为序列号附加到消息中。接下来,将消息广播到所有节点(如果发生丢失, 重新发送),而接受者 严格按照序列化来发送回复消息。

与 Lamport 时间戳不同,通过递增线性化寄存器获得的数字不会存在任何间隙。 因此,如果节点完成了消息 4 的发送,且接收到了序列化 6 的消息,那么在它对消息 6 回复之前必须等待消息 5。而 Lamport 时间戳则不是这样,而这也是区别全序关系广播与基于时间戳排序的关键。

分布式事务与共识

有很多重要的场景都需要集群节点达成某种一致,例如:

  • 主节点选举:对于主从复制的数据库,所有节点需要就谁来充当主节点达成一致。如果由于网络故障原因出现节点之间无法通信,就很容易出现争议。此时,共识对于避免错误的故障切换非常重要,后者会导致两个节点都自认为是主节点即脑裂。如果集群中存在两个这样的主节点,每个都在接受写请求,最终会导致数据产生分歧、不一致甚至数据丢失。
  • 原子事务提交:对于支持跨节点或跨分区事务的数据库,会面临这样的问题:某个事务可能在一些节点上执行成功,但在其他节点却不幸发生了失败。为了维护事务的原子性,所有节点必须对事务的结果达成一致:要么全部成功提交,要么中止/回滚。这个共识的例子被称为原子提交问题。

原子提交与两阶段提交

单节点原子提交

对于在单个数据库节点上执行的事务,原子性通常由存储引擎来负责。 当客户端请求数据库节点提交事务时,数据库首先使事务的写入持久化(通常保存在预写日志中),然后把提交记录追加写入到磁盘的日志文件中。如果数据库在该过程中间发生了崩溃,那么当节点重启后,事务可从日志中恢复:如果在崩溃之前提交记录已成功写入磁盘,则认为事务己安全提交;否则,回滚该事务的所有写入。

因此,在单节点上,事务提交非常依赖于数据持久写入磁盘的顺序关系:先写入数据,然后再提交记录事务提交(或中止)的关键点在于磁盘完成日志记录的时刻:在完成日志记录写之前如果发生了崩溃,则事务需要中止;如果在日志写入完成之后,即使发生崩溃,事务也会被安全提交。这就是在单一设备上上实现原子提交的核心思路。

两阶段提交

两阶段提交(two-phase commit,2PC) 是一种在多节点之间实现事务原子提交的算法,用来确保所有节点要么 部提交,要么全部中止。

2PC 中的提交/中止过程分为两个阶段(因此而得名),而不是单节点事务中的单个提交请求。2PC 的基本流程如下图所示:

2PC 引入了单节点事务所没有的一个新组件——协调者(又称为事务管理器)。协调者通常实现为共享库,运行在请求事务相同进程中(也可以是单独的进程或服务)。

通常,2PC 事务从应用程序在多个数据库节点上执行数据读/写开始。我们将这些数据库节点称为事务中的参与者。当应用程序准备提交事务时,协调者开始阶段 1:发送一个准备请求到所有节点,询问他们是否可以提交。协调者然后跟踪参与者的回应

  • 如果所有参与者回答是,表示他们已准备好提交,那么协调者接下来在阶段 2 会发出提交请求,提交开始实际执行。
  • 如果有任何参与者回答否,则协调者在阶段 2 中向所有节点发送放弃请求。
系统承诺

该协议有两个关键的不归路:首先,当参与者投票是时,它做出了肯定提交的承诺(尽管还取决于其他的参与者的投票,协调者才能做出最后决定)。其次,协调者做出了提交(或者放弃)的决定,这个决定也是不可撤销。正是这两个承诺确保了 2PC 的原子性(而单节点原子提交其实是将两个事件合并,写入事务日志即提交) 。

协调者故障

如果协调者本身发生了故障,那么此时该怎么处理呢?

2PC 能够顺利完成的唯一方法是等待协调者恢复。这就是为什么协调者必须在向参与者发送提交(或中止)请求之前要将决定写入磁盘的事务日志,协调者恢复之后通过读取事务日志来确定所有未决的事务状态。如果在协调 日志中没有完成提交记录就会中止。 此时 2PC 的提交点现在归结为协调者在常规单节点上的原子提交。

三阶段提交

作为 2PC 的替代方案,目前也有三阶段提交算法。然而,3PC 假定一个有界的网络延迟和节点在规定时间内响应。考虑到目前大多数实际场景中存在无限网络延迟和进程暂停的情况,因此它无法保证原子性。

通常,非阻塞原子提交依赖于一个完美的故障检测器,即有一个非常可靠的机制可以判断出节点是否已经崩溃。在无限延迟的网络环境中,超时机制并不是可靠的故障检测器,因为即使节点正常,请求也可能由于网络问题而最终超时。 正是由于这样的原因,尽管大家已经意识到上述协调者潜在的问题,但还在普遍使用 2PC。

实际中的分布式事务

目前有两种截然不同的分布式事务概念:

  • 数据库内部的分布式事务:某些分布式数据库(例如那些标配支持复制和分区的数据库)支持跨数据库节点 的内部事务。例如,VoltDB 和 MySQL Cluster 的 NDB 存储引擎就支持这样的内部分布式事务。此时,所有参与节点都运行着相同的数据库软件。
  • 异构分布式事务:在异构分布式事务中,存在两种或两种以上不同的参与者实现技术。例如来自不同供应商的数据库,甚至是非数据库系统(如消息中间件)。即使是完全不同的系统,跨系统的分布式事务也必须确保原子提交。

Exactly-Once消息处理

异构的分布式事务旨在无缝集成多种不同的系统。例如,当且仅当数据库中处理消息事务成功提交,消息队列才会标记该消息已处理完毕。这个过程是通过自动提交消息数据库写入来实现的。即使消息系统和数据库两种不同的技术运行在不同的节点上,采用分布式事务也能达到上述目标。

如果消息发送或数据库事务任何一个发生失败,则两者必须中止,消息队列可以在稍后再次重传消息。因此,通过自动提交消息和消息处理的结果,可以确保消息可以有效处理有且仅有一次(成功之前有可能需要重试)。而如果事务最后发生终止,则放弃所有部分完成的结果。

需要指出,只有在所有受影响的系统都使用相同的原子提交协议的前提下,这种分布式事务才是可行。

XA事务

X/Open XA(eXtended Architecture,XA) 是异构环境下实施两阶段提交的一个工业标准,于 1991 年推出并得到广泛推广。目前,许多传统关系数据库(包括 PostgreSQL、MySQL、DB2、SQL Server、Oracle)和消息队列(包括 ActiveMQ、HornetQ、MSMQ、IBM MQ)都支持 XA。

XA 并不是一个网络协议,而是一个与事务协调者进行通信的 API(它也支持其他语言的 API 绑定)。XA 假定应用程序通过网络或客户端的库函数与参与者(包括数据库、消息服务)节点进行通信。如果驱动程序支持 XA ,意味着应用可以调用 XA API 来确定操作是否是异构分布式事务的一部分。如果是,则发送必要的信息给数据库服务器。它还支持回调,这样协调者可以通过回调函数来通知所有参与者执行准备或者提交(或者中止)。

分布式事务的限制

XA 事务解决了多个参与者之间如何达成一致这样一个非常现实而重要的问题,但它也引入了不少操作方面的限制:

  • 如果协调者不支持数据复制,而是在单节点上运行,那么它就是整个系统的单点故障。而现实情况是,有许多协调者的实现默认情况下井非高可用,或者只支持最基本的复制。
  • 许多服务器端应用程序都倾向于无状态模式(因为更受 HTTP 青睐),而所有的持久状态都保存在数据库中,这样应用服务器可以轻松地添加或删除实例。但是,当协调者就是应用服务器的一部分时,部署方式就发生了根本的变化。突然间,协调者的日志成为可靠系统的重要组成部分,它要求与数据库本身一样重要(需要协调者日志恢复那些有疑问的事务)。这样的应用服务器已经不再是无状态。
  • 由于 XA 需要与各种数据系统保持兼容,所以它最终其实是多系统可兼容的最低标准。例如,它无法深入检测不同系统之间的死锁条件(因为这就将需要另一个标准化协议,使得多个系统交换事务所等待的锁信息),而且不适用于 SSI ,后者要求一个复杂的协议来识别不同系统间的写冲突。
  • 对于数据库内部的分布式事务(而不是 XA),限制则少很多,例如 SSI 的分布式版本是可行的 。然而 2PC 要成功提交事务还是存在潜在的限制,它要求必须所有参与者都投票赞成, 如果有任何部分发生故障,整个事务只能失败。所以分布式事务有扩大事务失败的风险,这与我们构建容错系统的目标有些背道而驰。

支持容错的共识

共识问题通常形式化描述如下:一个或多个节点可以提议某些值,由共识算法来决定最终值。

共识算法必须满足以下性质:

  • 协商一致性:所有的节点都接受相同的决议。
  • 诚实性:所有节点不能反悔,即对一项提议不能有两次决定。
  • 合法性:如果决定了值 v,则 v 一定是由某个节点所提议的。
  • 可终止性:节点如果不崩溃则最终一定可以达成决议。

协商一致性和诚实性属性定义了共识的核心思想:决定一致的结果,一旦决定,就不能改变。合法性属性主要是为了排除一些无意义的方案。可终止性则引入了容错的思想。 它重点强调一个共识算法不能原地空转,永远不做事情,换句话说,它必须取得实质性进展。即使某些节点出现了故障,其他节点也必须最终做出决定。

可终止性属于一种活性,而另外三种则属于安全性方面的属性。

共识算法与全序广播

最著名的容错式共识算法包括 VSR、Paxos、Raft、Zab。这些算法大部分其实并不是直接使用上述的形式化模型(提议井决定某个值,同时满足上面四个属性)。相反,他们是决定了一系列值,然后采用全序关系广播算法。

全序关系广播的要点是,消息按照相同的顺序发送到所有节点,有且只有一次。如果仔细想想,这其实相当于进行了多轮的共识过程:在每一轮,节点提出他们接下来想要发送的消息,然后决定下一个消息的全局顺序。

所以,全序关系广播相当于持续的多轮共识(每一轮共识的决定对应于一条消息):

  • 由于协商一致性,所有节点决定以相同的顺序发送相同的消息。
  • 由于诚实性,消息不能重复。
  • 由于合法性,消息不会被破坏,也不是凭空捏造的。
  • 由于可终止性,消息不会丢失。

共识的局限性

共识算法对于分布式系统来说是一个巨大的突破:它为其他充满不确定性的系统带来了基础的安全属性(一致性,完整性和合法性),然而它们还能保持容错(只要多数节点正常工作且可达,就能取得进展)。它们提供了全序广播,因此它们也可以以一种容错的方式实现线性一致的原子操作。

虽然有着上面这些好处,但这些好处的背后仍然存在一些代价:

  • 在达成一致性决议之前,节点投票的过程是一个同步复制过程。 而数据库通常配置为异步复制,存在某些已提交的数据在故障切换时丢失的风险。

  • 共识体系需要严格的多数节点才能运行。 如果由于网络故障切断了节点之间的连接,则只有多数节点所在的分区可以继续工作,剩下的少数节点分区则处于事实上的停顿状态

  • 多数共识算法假定一组固定参与投票的节点集,这意味着不能动态添加或删除节点。

  • 共识系统通常依靠超时机制来检测节点失效。 在网络延迟高度不确定的环境中,特别是那些跨区域分布的系统,经常由于网络延迟的原因,导致节点错误地认为主节点发生了故障。

  • 共识算法往往对网络问题特别敏感。 例如,如果整个网络中存在一条网络连接持续不可靠, Raft 会进入一种奇怪的状态:它不断在两个节点之间反复切换主节点,当前主节点不断被赶下台,这最导致系统根本无法安心提供服务。

成员与协调服务

ZooKeeper 或 etcd 这样的项目通常被描述为 “分布式键值存储” 或 “协调与配置服务”。它们通常采用容错的全序广播算法在所有节点上复制这些数据从而实现高可靠。不仅如此,Zookeepr 还提供了以下这些特性:

  • 线性化的原子操作:使用原子 CAS 操作可以实现锁:如果多个节点同时尝试执行相同的操作,只有一个节点会成功。共识协议保证了操作的原子性和线性一致性,即使节点发生故障或网络在任意时刻中断。分布式锁通常以租约(lease)的形式实现,租约有一个到期时间,以便在客户端失效的情况下最终能被释放。
  • 操作全序:当某个资源受到锁或租约的保护时,你需要一个防护令牌来防止客户端在进程暂停的情况下彼此冲突。防护令牌是每次锁被获取时单调增加的数字。ZooKeeper 通过全序化所有操作来提供这个功能,它为每个操作提供一个单调递增的事务 ID(zxid)和版本号(cversion)。
  • 故障检测:客户端在 ZooKeeper 服务器上维护一个长期会话,客户端和服务器周期性地交换心跳包来检查节点是否还活着。即使连接暂时中断,或者 ZooKeeper 节点失效,会话仍保持在活跃状态。但如果心跳停止的持续时间超出会话超时,ZooKeeper 会宣告该会话已死亡。当会话超时时(ZooKeeper 称这些节点为临时节点,即 ephemeral nodes),会话持有的任何锁都可以配置为自动释放。
  • 更改通知:客户端不仅可以读取其他客户端创建的锁和值,还可以监听它们的变更。因此,客户端可以知道另一个客户端何时加入集群(基于新客户端写入 ZooKeeper 的值),或发生故障(因其会话超时,而其临时节点消失)。通过订阅通知,客户端不用再通过频繁轮询的方式来找出变更。

节点任务分配

ZooKeeper 和 Chubby 系统非常适合节点任务分配这一场景,例如:

  • 如果系统有多个流程或服务的实例,并且需求其中的一个实例充当主节点;而如果主节点失效,由其他某个节点来接管。(例如主从复制数据库、作业调度系统等)
  • 对于一些分区资源(可以是数据库,消息流,文件存储,分布式 actor system 等),需要决定将哪个分区分配给哪个节点。当有新节点加入集群时, 需要将某些现有分区从当前节点迁移到新节点, 从而实现负载动态平衡。 当节点移除或失败时,其他节点还需要接管失败节点。

上述场景中的任务,可以借助 ZooKeeper 中的原子操作,ephemeral nodes 和通知机制来实现。

服务发现

ZooKeeper、Etcd、Consul 还经常用于服务发现。例如需要某项服务时,应该连接到哪个 IP 地址等。在典型的云环境中,虚拟机可能会起起停停,这种动态变化的节点无法提前知道服务节点的 IP 地址,因此,可以这样配置服务,每当节点启动时将其网络端口信息、向 ZooKeeper等服务注册,然后其他人只需向 ZooKeeper 的注册表中询问即可。

成员服务

ZooKeeper 等还可以看作是成员服务范畴的一部分。成员服务用来确定当前哪些节点处于活动状态并属于集群的有效成员。 由于无限的网络延迟,无法可靠地检测一个节点究竟是否发生了故障。但是,可以将故障检测与共识绑定在一起,让所有节点就节点的存活达成一致意见。

这里依然存在发生误判的可能性,即节点其实处于活动状态、却被错误地宣判为故障。 即使这样,系统就成员资格问题的决定是全体一致的,这是最重要的。例如,选举主节点的方式可能是简单地投票选择编号最小的节点, 一旦节点对于当前包含哪些成员出现了不同意见,那么共识过程就无法继续。

Built with Hugo
主题 StackJimmy 设计