1

现在,我们讨论构建容错式分布式系统的相关算法和协议。为了构建容错系统,最好先建立一套通用的抽象机制和与之对应的技术保证,这样,其上的各种应用程序都可以安全地信赖底层的保证。这与事务的道理相同:通过事务,应用程序可以假装没有崩溃(原子性),没有与其他人并发访问数据库(隔离性),且存储设备是完全可靠的(持久性)。总之,抽象的事务机制可以屏蔽系统内部很多复杂的问题,使得应用层轻松无忧。

现在继续沿着这个思路,尝试建立可以让分布式应用忽略内部各种问题的抽象机制。例如,分布式系统最重要的抽象之一就是共识:所有的节点就某一项提议达成一致。

一旦解决了共识问题,就可以服务于应用层很多的目标需求。例如,对于一个主从复制的数据库,如果主节点发生失效,就需要切换到另一个节点,此时数据库节点可以采用共识算法来选举新的主节点。某一时刻必须只有一个主节点,所有的节点必须就此达成一致。如果有两个节点都自认为是主节点,就会发生脑裂,导致数据丢失。正确实现共识算法则可以避免此类问题。

一致性保证

在复制环境下,如果在同一时刻查询数据库的两个节点,则可能会看到不同的数据,这主要是因为写请求会在不同的时间点到达不同的节点。无论数据库采用何种复制方法,都无法完全避免这种不一致情况。

大多数多副本的数据库都至少提供了最终的一致性,这意味着如果停止更新数据库,并等待一段时间之后,最终所有读请求会返回相同的内容。换言之,最终一致性意味着“收敛”,即预期所有的副本最终会收敛到相同的值。但是,这是一个非常弱的保证,它无法告诉我们系统何时会收敛。而在收敛之前,读请求可能会返回任何值甚至读失败。

当面对只提供了弱保证的数据库时,需要清醒地认清系统的局限性。应用可能在大多数情况下都运行良好,但数据库内部可能已经发生了非常微妙的错误,只有当系统出现故障或高并发压力时,最终一致性的临界条件或者错误才会对外暴露出来,因而测试与发现错误变得非常困难。

这里我们将探索更强的一致性模型。不过,这也意味着更多的代价,例如性能降低或容错性差。尽管如此,更强的保证的好处是使上层应用逻辑更简单,更不容易出错。当了解、对比了多种不同的一致性模型之后,可以结合自身需求,从中选择最合适的。

可线性化

在最终一致性数据库中,同时查询两个不同的副本可能会得到两个不同的答案。如果数据库能够对上提供只有单个副本的假象,情况会不会大为简化呢?这样让每个客户端都拥有相同的数据视图,而不必担心复制滞后。

这就是可线性化(也称为原子一致性,强一致性等)的思想,其基本的想法是让一个系统看起来好像只有一个数据副本,且所有的操作都是原子的。

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

如何达到线性化?

可线性化背后的基本思想很简单:使系统看起来好像只有一个数据副本。

如图展示了三个客户端在线性化数据库中同时读写相同的主键 x。在分布式语义下,x 被称为寄存器,例如,它可以是键-值存储中的一个键,关系数据库中的一行或文档数据库中的一个文档。

image.png

每条线代表一个客户端请求,虚线的开始表示发送请求的时间,结尾则是收到响应的时间。由于网络延迟不确定,客户端并不清楚数据库具体何时处理请求,而只知道它是在发送之后、响应之前的某个中间时间点。

在这个例子中,寄存器有两类操作:读和写(写可能会发生失败)。x 的初始值为 0,客户端 C 提交写请求将其设置为 1。同时,客户端 A 和 B 在反复轮询数据库以读取最新值。A 和 B 可能会分别读到什么样的返回值呢?

  • 客户端 A 的第一个读取操作在写入开始之前已完成,因此返回的是旧值 0。
  • 客户端 A 的最后一次读操作是在写操作完成之后才开始的,如果数据库是可线性化的,它肯定会返回新值 1。
  • 与写操作有时间重叠的任何读取操作则可能返回 0 或者 1,这是因为读写之间存在并发,无法确切知道在执行读取时,写入是否已经生效。

然而,这还没有精确描述线性化:如果与写并发的读操作可能返回旧值或新值,那么在这个过程中,不同的读客户端会看到旧值和新值之间来回跳变的情况。

为使系统可线性化,我们需要添加一个重要的约束:一旦某个读操作返回了新值,之后所有的读都必须返回新值。

可以进一步细化时序图来可视化每步操作具体在哪个时间点生效,如图所示。除了读写之外,我们引入了第三种类型的操作 cas:表示一个原子比较-设置操作。

图中的每个操作都有一条竖线,表示可能的执行时间点。这些标记以前后关系依次连接起来,最终的结果必须是一个有效的寄存器读写顺序,即每个读操作须返回最近写操作所设置的值。

可线性化要求,如果连接这些标记的竖线,它们必须总是按时间箭头向前移动,而不能向后移动。这个要求确保了之前所讨论的就近性保证:一旦新值被写入或读取,所有后续的读都看到的是最新的值,直到被再次覆盖。

image.png

图中有一些有趣的细节值得仔细分析:

  • 客户端 B 首先发送读 x 的请求,接下来客户端 D 发送请求将 x 置为 0,紧接着客户端 A 又发送请求将 x 置为 1,而最终返回给 B 的值为 1。这是可能的,它意味着数据库执行的顺序是:首先处理 D 的写入 0,然后是 A 的写入 1,最后是 B 的读取。虽然这并不是请求发送的顺序,但考虑到请求并发以及网络延迟等情况,这是一个合法的可接受的处理顺序。
  • 客户端 A 在收到数据库写响应之前,客户端 B 即读到了值 1,这表明写入已成功。这是可能的,但它并不代表执行读发生在执行写之前,只是意味着很可能由于网络延迟而耽搁了客户端 A 接受响应。
  • 模型没有假定事务间的隔离,即另一个并发客户端可能随时会修改值。例如,C 首先读取到 1,然后读到 2,原因是两次读取之间值被客户端 B 修改了。我们可以使用 cas 操作来检查值是否被其他并发客户端修改,例如客户端 B 和 C 的 cas 请求成功,但是 D 的 cas 操作失败。
  • 客户 B 的最后一次读取不满足线性化。该操作与 C 的 cas 写操作同时发生,后者将 x 从 2 更新为 4。在没有其他请求时,B 读取可以返回 2。但是在 B 读取开始之前,客户端 A 已经读取了新值 4,所以不允许 B 读到比 A 更老的值。

依赖线性化的场景

加锁与主节点选举

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

提供协调者服务的系统如 ZooKeeper 和 etcd 等通常用来实现分布式锁和主节点选举。它们都使用了支持容错的共识算法确保可线性化。

约束与唯一性保证

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

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

其他类似约束包括银行账户余额不应出现负值,或者避免出售库存里已经没有的商品,或者不能同时预定航班或者剧院的相同的座位。这样的约束条件都要求所有节点就某个最新值达成一致(例如账户余额,库存水平,座位占用率)。

硬性的唯一性约束,常见如关系型数据库中主键的约束,则需要线性化保证。其他如外键或属性约束,则并不要求一定线性化。

跨通道的时间依赖

有些时候,线性化违例之所以被注意到,是因为系统中存在其他的通信渠道。例如,用户可以上传照片到某网站,有一个后台进程将照片调整为更低的分辨率以方便更快下载。该网站架构和数据流如图所示。

image.png

这里需要明确通知图像调整模块来调整哪些图片,系统采用了消息队列将此命令从 Web 服务器发送到调整器。因为大多数消息队列系统并不适合大数据流,而考虑到照片的大小可能到数兆字节,因此 Web 服务器并不会把照片直接放在队列中。相反,照片会先写入文件存储服务,当写入完成后,把调整的命令放入队列。

如果文件存储服务是可线性化的,那么系统应该可以正常工作。否则,这里就会引入竞争条件:消息队列可能比存储服务内部的复制执行更快。在这种情况下,当调整模块在读取图像时,可能会看到图像的某个旧版本,或者根本读不到任何内容。如果它碰巧读到了旧版本的图像并进行处理,会导致文件存储中的全尺寸图片与调整之后图片出现永久的不一致。

实现线性化系统

由于线性化本质上意味着“表现得好像只有一个数据副本,且其上的所有操作都是原子的”,所以最简单的方案自然是只用一个数据副本。但显然,该方法无法容错,容错最常见的方法就是采用复制机制。我们回顾一下各种复制方案,看看哪些满足可线性化:

主从复制(部分支持可线性化)

在主从复制的系统中,只有主节点承担数据写入,从节点则在各自节点上维护数据的备份副本。如果从主节点或者同步更新的从节点上读取,则可以满足线性化。但并非每个主从复制的具体数据库实例都是可线性化的,主要是因为它们可能采用了快照隔离的设计,或者实现时存在并发方面的 bug。

而从主节点上读取的前提是确定知道哪个节点是主节点。某节点可能自认为是主节点,但事实并非如此,这个“自以为是”的主节点如果对外提供服务,就会违反线性化。如果使用了异步复制,故障切换过程中甚至可能会丢失一些已提交的写入,结果是同时违反持久性和线性化。

共识算法(可线性化)

共识算法与主从复制机制相似,不过共识协议通常内置一些措施来防止脑裂和过期的副本。正是由于这些专门的设计,共识算法可以安全地实现线性化存储。

多主复制(不可线性化)

具有多主节点复制的系统通常无法线性化,主要由于它们同时在多个节点上执行并发写入,并将数据异步复制到其他节点。因此它们可能会产生冲突的写入,需要额外的解决方案。

无主复制(可能不可线性化)

对于无主节点复制的系统,有些人认为只要配置法定读取和写入满足(w + r > n)就可以获得“强一致性”。但这完全取决于具体的 quorum 的配置,以及如何定义强一致性,它可能并不保证线性化。

例如基于墙上时钟的“最后写入获胜”冲突解决方法几乎肯定是非线性化,因为这种时间戳无法保证与实际事件顺序一致(例如由于时钟偏移)。不规范的 quorum 也会破坏线性化,甚至如下面将要介绍的,严格的 quorum 也会发生违背线性化的情况。

线性化与 quorum

直觉上,对于无主复制模型,如果读写遵从了严格 quorum,应该是可线性化的。然而如果遭遇不确定的网络延迟,就会出现竞争条件,如图所示。

image.png

x 的初始值为 0,写客户端向所有三个副本(n = 3,w = 3)发送写请求将 x 更新为 1。与此同时,客户端 A 从两个节点(r = 2)读取数据,然后在其中一个节点上看到新值 1。与此同时,客户端 B 从两个节点的读取,两者都返回了旧值 0。

我们发现它虽然满足了仲裁条件(w + r > n),但很明显这不是线性化的:B 的请求在 A 的请求完成之后才开始,A 返回了新值,但 B 却得到了旧值。

不过,可以以牺牲性能为代价来满足线性化:读操作在返回结果给应用之前,必须同步执行读修复;而写操作在发送结果之前,必须读取 quorum 节点以获取最新值。而且,如果使用了“最后写入获胜”冲突解决方案,当出现同一个主键的并发写入时,就会丧失线性化。

此外,这种方式只能实现线性化读、写操作,但无法支持线性化的“比较和设置”操作,后者需要共识算法的支持。总而言之,最安全的假定是无主复制系统无法保证线性化

线性化的代价

我们已经讨论了不同复制方案各自适合的场景。例如,多主复制非常适合多数据中心。如果两个数据中心之间发生网络中断,会发生什么情况?基于多主复制的数据库,每个数据中心内都可以继续正常运行:由于从一个数据中心到另一个数据中心的复制是异步,期间发生的写操作都暂存在本地队列,等网络恢复之后再继续同步。

与之对比,如果是主从复制,则主节点肯定位于其中的某一个数据中心。所有写请求和线性化读取都必须发送给主节点,因此,对于那些连接到非主节点所在数据中心的客户端,读写请求都必须通过数据中心之间的网络,同步发送到主节点所在的数据中心。

因此,对于这样的主从复制系统,数据中心之间的网络一旦中断,连接到从数据中心的客户端无法再联系上主节点,也就无法完成任何数据库写入和线性化读取。从节点可以提供读服务,但内容可能是过期的。所以,如果应用程序要求线性化读写,则网络中断一定会违背这样的要求。

另一种情况,如果客户端可以直接连接到主节点所在的数据中心,则可以避免此问题。否则,只能等到数据中心之间的网络恢复之后才能继续正常工作。

CAP 理论

不仅仅是主从复制和多主复制才有上面的问题,无论如何实现,任何可线性化的数据库都有这样问题。事实上,这个问题也不局限于多数据中心部署的情况,即使在一个数据中心内部,只要有不可靠的网络,都会发生违背线性化的风险。我们可以做以下的权衡考虑:

  • 如果应用要求线性化,但由于网络方面的问题,某些副本与其他副本断开连接之后无法继续处理请求,就必须等待网络修复,或者直接返回错误。无论哪种方式,结果是服务不可用。
  • 如果应用不要求线性化,那么断开连接之后,每个副本可独立处理请求例如写操作。此时,服务可用,但结果行为不符合线性化。

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

CAP 有时也代表一致性,可用性和分区容错性,系统只能支持其中两个特性。不过,这种理解存在误导性,网络分区是一种故障,它都有可能发生,所以无法选择或逃避分区的问题。

在网络正常的时候,系统可以同时保证一致性和可用性。而一旦发生了网络故障,必须要么选择线性,要么可用性。因此,更准确的称呼应该是“网络分区情况下,选择一致还是可用”。高可靠的网络会帮助减少发生的概率,但无法做到彻底避免。

尽管 CAP 在历史上具有重大的影响力,但对于一个具体的系统设计来说,它可能没有太大的实际价值。

可线性化与网络延迟

虽然线性化是个很有用的保证,但实际上很少有系统真正满足线性化。例如,现代多核 CPU 上的内存甚至就是非线性化:如果某个 CPU 核上运行的线程修改一个内存地址,紧接着另一个 CPU 核上的线程尝试读取,则系统无法保证可以读到刚刚写入的值,除非使用了内存屏障或 fence 指令。

出现这种现象的原因是每个 CPU 核都有自己独立的 cache 和寄存器。内存访问首先进入 cache 系统,所有修改默认会异步地刷新到主存。由于访问 cache 比访问主存要快得多,所以这样的异步刷新特性对于现代 CPU 的性能至关重要。但是,这就导致出现了多个数据副本,而副本更新是异步方式,无法保证线性化。

在计算机内部,我们通常假设通信是可靠的,例如我们不会假定一个 CPU 核在与其他核断开之后还能安然工作。所以,放弃线性化的原因就是性能,而不是为了容错

许多分布式数据库也是类似,它们选择不支持线性化是为了提高性能,而不是为了保住容错特性。无论是否发生了网络故障,线性化对性能的影响都是巨大的。

顺序保证

线性化寄存器对外呈现的好像只有一份数据拷贝,而且每一个操作似乎都是原子性生效。这意味着操作是按照某种顺序执行。

让我们简要回顾一下讨论顺序时涉及的前后上下文:

  • 主从复制系统中主节点的主要作用是确定复制日志中的写入顺序,这样使从节点遵从相同的顺序执行写入。如果没有这样的唯一主节点,则可能由于并发操作而引发冲突。
  • 可串行化隔离则是确保事务的执行结果与按照某种顺序方式执行一样。实现方式可以是严格顺序执行,或者允许并发但需要相应的冲突解决方案。
  • 分布式系统的时间戳与时钟,试图将顺序引入到无序的操作世界,例如确定两个写操作哪一个先发生。

排序、可线性化与共识之间存在着某种深刻的联系。

顺序与因果关系

之所以反复出现“顺序”问题,其中的一个原因是它有助于保持因果关系。已经出现了好几个例子:

  • 在“一致前缀读”中,一个对话的观察者首先看到了问题的答案接着才是问题本身,这违背了我们对因果的直觉认识。此时,问题与回答之间存在因果关系。
  • 三个主节点之间进行数据复制,由于网络延迟,一些写操作会覆盖其他的写入。从某个副本的角度来看,好像是发生了一个对不存在数据行的更新。这里的因果意味着首先必须先创建数据行,然后才能去更新。
  • 在“检测并发写”中,如果有两个操作 A 和 B 是并发关系,则它们之间不存在因果关系。
  • 在事务的快照隔离上下文中,事务是从一致性快照读取。这意味着与因果关系一致:如果快照中包含了答案,则它也必须包含所提的问题。这样才能确保在某个时间点观察数据库时符合因果关系:快照创建时刻点之前的所有数据都要可见,但此后发生的事件则不可见。

因果关系对所发生的事件施加了某种排序:发送消息先于收到消息;问题出现在答案之前等。这些因果关系的依赖链条定义了系统中的因果顺序,即某件事应该发生另一件事情之前。

如果系统服从因果关系所规定的顺序,我们称之为因果一致性。例如,快照隔离提供了因果一致性:当从数据库中读数据时,如果查询到了某些数据,也一定能看到触发该数据的前序事件。

因果顺序并非全序

全序关系支持任何两个元素之间进行比较,即对于任意两个元素,总是可以指出哪个更大,哪个更小。例如,自然数符合全序关系,随便给出两个数字,都可以进行比较。

但是,有些集合并不符合全序,例如集合{a,b}大于集合{b,c}么?因为它们都不是对方的子集,所以无法直接比较它们。我们称之为不可比较,数学集合只能是偏序。

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

可线性化

在一个可线性化的系统中,存在全序操作关系。系统的行为就好像只有一个数据副本,且每个操作都是原子的,这意味着对于任何两个操作,我们总是可以指出哪个操作在先。

因果关系

如果两个操作都没有发生在对方之前,那么这两个操作是并发关系。换言之,如果两个事件是因果关系,那么这两个事件可以被排序;而并发的事件则无法排序比较。这表明因果关系至少可以定义为偏序,而非全序。

因此,在可线性化数据存储中不存在并发操作,一定有一个时间线将所有操作都全序执行。并发意味着时间线会出现分支和合并,而不同分支上的操作无法直接比较。

可线性化强于因果一致性

那么因果序和可线性化之间是什么关系呢?答案是可线性化一定意味着因果关系:任何可线性化的系统都将正确地保证因果关系。特别是,如果系统存在多个通信通道,可线性化确保了因果关系会自动全部保留,而不需要额外的工作。

可线性化可以确保因果性这一结论,使线性化系统更加简单易懂而富有吸引力。但是,线性化会显著降低性能和可用性,尤其是在严重网络延迟的情况下。正因如此,一些分布式数据系统已经放弃了线性化,以换来更好的性能,但也存在可能无法正确工作的风险。

线性化并非是保证因果关系的唯一途径,还有其他方法使得系统可以满足因果一致性而免于线性化所带来的性能问题。事实上,因果一致性可以认为是,不会由于网络延迟而显著影响性能,又能对网络故障提供容错的最强的一致性模型。在许多情况下,许多看似需要线性化的系统实际上真正需要的是因果一致性,后者的实现可以高效很多。

捕获因果依赖关系

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

为了确定请求的因果依赖关系,我们需要一些手段来描述系统中节点所知道的“知识”。如果节点在写入 Y 时已经看到 X 值,则 X 和 Y 可能是属于因果关系。

确定请求的先后顺序与第五章“检测并发写”中所讨论的技巧类似。后者针对的是无主复制中的因果关系,该场景需要去检测对同一个主键的并发写请求,从而避免更新丢失。因果一致性则要更进一步,它需要跟踪整个数据库请求的因果关系,而不仅仅是针对某个主键。版本向量技术可以推广为一种通用的解决方案。

序列号排序

虽然因果关系很重要,但实际上跟踪所有的因果关系不切实际。这里还有一个更好的方法:我们可以使用序列号或时间戳来排序事件。它可以只是一个逻辑时钟,通常是递增的计数器。

这样的序列号或时间戳非常紧凑,但它们保证了全序关系。也就是说,每一个操作都有唯一的顺序号,并且总是可以通过比较来确定哪个发生在前。

我们可以按照与因果关系一致的顺序来创建序列号:保证如果操作 A 发生在 B 之前,那么 A 一定在全序中出现在 B 之前。并行操作的序列可能是任意的。这样的全局排序可以捕获所有的因果信息,但也强加了比因果关系更为严格的顺序性。

在主从复制数据库中,复制日志定义了与因果关系一致的写操作全序关系。主节点可以简单地为每个操作递增某个计数器,从而为复制日志中的每个操作赋值一个单调递增的序列号。从节点按照复制日志出现的顺序来应用写操作,那结果一定满足因果一致性。

非因果序列发生器

如果系统不存在这样唯一的主节点,如何产生序列号就不是那么简单了。实践中可以采用以下方法:

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

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

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

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

刚才所描述的三个序列号发生器可能与因果关系存在不一致,但还有一个简单的方法可以产生与因果关系一致的序列号。它被称为兰伯特时间戳(Lamport timestamp) 。

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

image.png

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

每个节点以及每个客户端都跟踪迄今为止所见到的最大计数器值,并在每个请求中附带该最大计数器值。当节点收到某个请求(或者回复)时,如果发现请求内嵌的最大计数器值大于节点自身的计数器值,则它立即把自己的计数器修改为该最大值。

如图所示,客户端 A 从节点 2 收到计数器值 5,然后将最大值 5 发送到节点 1。此时,节点 1 的计数器仅为 1,但是它立即向前跳到 5,所以下一个操作将获得计数器值 6。只要把最大计数器值嵌入到每一个请求中,该方案可以确保 Lamport 时间戳与因果关系一致,而请求的因果依赖性一定会保证后发生的请求得到更大的时间戳。

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

时间戳排序依然不够

虽然 Lamport 时间戳定义了与因果序一致的全序关系,但还不足以解决实际分布式系统中许多常见的问题。

例如,一个账户系统需要确保用户名唯一标识用户。即两个用户如果同时尝试使用相同的用户名创建账户时,确保其中一个成功,另一个必须失败。

乍看之下,似乎全序关系应该可以解决问题:如果有这样并发的操作,则选择时间戳较低的那个作为获胜者,而让时间戳大的请求失败。由于时间戳有序,所以这样的比较方法也应该是可行的。但是,这种方法确定胜利者有这样一个前提条件:需要收集系统中所有的用户创建请求,然后才可以比较它们的时间戳。然而,当节点刚刚收到用户的创建请求时,它无法当时就做出决定该请求应该成功还是失败。此时,节点根本不知道是否有另一个节点在同时创建相同用户名。

而为了获得上述两点信息,系统就必须检查每个节点,询问它们在做什么。如果万一某个节点出现故障或者由于网络问题而无法连接,那么方法就无法正常运转。显然这不是我们所期望的容错系统。

总而言之,为了实现像用户名唯一性约束这样的目标,仅仅对操作进行全序排列还是不够的,还需要知道这些操作是否发生、何时确定等。假如能够在创建用户名时,已经确定知道了没有其他节点正在执行相同用户名的创建,你大可以直接安全返回创建成功。

要想知道什么时候全序关系已经确定,就需要下面介绍的“全序关系广播”。

全序关系广播

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

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

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

使用全序关系广播

像 ZooKeeper 和 etcd 这样的共识服务实际上就实现了全序关系广播。全序关系广播是数据库复制所需要的:如果每条消息代表数据库写请求,并且每个副本都按相同的顺序处理这些写请求,那么所有副本可以保持一致(或许有些滞后)。该原则也被称为状态机复制。

可以使用全序关系广播来实现可串行化事务。如果每条消息表示一个确定性事务并且作为存储过程来执行,且每个节点都遵从相同的执行顺序,那么可以保证数据库各分区以及各副本之间的一致性。

全序关系广播另一个要点是顺序在发送消息时已经确定,如果消息发送成功,节点不允许将某条消息插入到先前的某个位置上。这一点使得全序关系广播比基于时间戳排序要求更强。

理解全序关系广播的另一种方式是将其视为日志(如复制日志,事务日志或预写日志)。传递消息就像追加方式更新日志。由于所有节点必须以相同的顺序发送消息,因此所有节点都可以读取日志并看到相同的消息序列。

全序关系广播对于提供 fencing 令牌的锁服务也很有用。每个获取锁的请求都作为消息附加到日志中,所有消息按照日志中的顺序依次编号。序列号还可以作为令牌,它符合单调递增要求。

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

可线性化与全序关系广播之间有着密切的联系,也有不同之处。全序关系广播是基于异步模型:保证消息以固定的顺序可靠地发送,但是不保证消息何时发送成功(因此某个接收者可能明显落后于其他接收者)。而可线性化则强调就近性:读取时保证能够看到最新的写入值。

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

对于每一个可能的用户名,都可以有一个带有原子比较-设置操作的线性化寄存器。每个寄存器初始值为空。当用户创建一个用户名时,对该用户名的寄存器执行比较设置操作。如果多个用户试图同时获取相同的用户名,则只有一个原子比较-设置操作成功。

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

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

由于日志条目以相同的顺序发送到所有节点,而如果存在多个并发写入,则所有节点将首先决定哪个请求在先。选择第一个写请求作为获胜者,并中止其他请求,以确保所有节点同意一个写请求最终要么提交成功要么中止。类似的方法还可以用来在日志之上实现可串行化的多对象事务。

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

  • 可以采用追加的方式把读请求排序、广播,然后各个节点获取该日志,当本节点收到消息时才执行真正的读操作。消息在日志中的位置已经决定了读取发生的时间点。etcd 的 quorum 读取和这个思路有相似之处。
  • 如果可以以线性化的方式获取当前最新日志中消息的位置,则查询位置,等待直到该位置之前的所有条目都已经发送给你,接下来再执行读取。这与 ZooKeeper 的 sync() 操作思路相同。
  • 可以从同步更新的副本上进行读取,这样确保总是读取最新值。这种技术可以用于链式复制。
采用线性化存储实现全序关系广播

最简单的方法是假设有一个线性化的寄存器来存储一个计数,然后使其支持原子自增-读取操作或者原子比较-设置操作。

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

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

难点在于处理节点的网络中断,以及节点失效时如何恢复该值。线性化的原子比较-设置(或自增)寄存器与全序关系广播二者都等价于共识问题。

分布式事务与共识

共识问题是分布式计算中最重要也是最基本的问题之一,目标是让几个节点就某件事情达成一致。有很多重要的场景都需要集群节点达成某种一致,例如:

主节点选举

对于主从复制的数据库,所有节点需要就谁来充当主节点达成一致。如果由于网络故障原因出现节点之间无法通信,就很容易出现争议。此时,共识对于避免错误的故障切换非常重要,后者会导致两个节点都自认为是主节点即脑裂。

原子事务提交

对于支持跨节点或跨分区事务的数据库,会面临这样的问题:某个事务可能在一些节点上执行成功,但在其他节点却不幸发生了失败。为了维护事务的原子性,所有节点必须对事务的结果达成一致:要么全部成功提交,要么中止/回滚。

我们先探究一下原子提交问题,后面将探索其他的共识算法。

原子提交与两阶段提交

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

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

但是,如果一个事务涉及多个节点呢?例如,一个分区数据库中多对象事务,或者是基于词条分区的二级索引。向所有节点简单地发送一个提交请求,然后各个节点独立执行事务提交是绝对不够的。这样做很容易发生部分节点提交成功,而其他一些节点发生失败,从而违反了原子性保证:

  • 某些节点可能会检测到违反约束或有冲突,因而决定中止,而其他节点则可能成功提交。
  • 某些提交请求可能在网络中丢失,最终由于超时而中止,而其他提交请求则顺利通过。
  • 某些节点可能在日志记录写入之前发生崩溃,然后在恢复时回滚,而其他节点则成功提交。

如果一部分节点提交了事务,而其他节点却放弃了事务,节点之间就会变得不一致。而且某个节点一旦提交了事务,即使事后发现其他节点发生中止,它也无法再撤销已提交的事务。正因如此,如果有部分节点提交了事务,则所有节点也必须跟着提交事务。

两阶段提交

两阶段提交(two-phase commit,2PC)是一种在多节点之间实现事务原子提交的算法,用来确保所有节点要么全部提交,要么全部中止。2PC 的基本流程如图所示。不同于单节点上的请求提交,2PC 中的提交/中止过程分为两个阶段。

image.png

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

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

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

我们来更详细地分解这个过程:

  1. 当应用程序启动一个分布式事务时,它首先向协调者请求事务 ID。该 ID 全局唯一。
  2. 应用程序在每个参与节点上执行单节点事务,并将全局唯一事务 ID 附加到事务上。此时,读写都是在单节点内完成。如果在这个阶段出现问题,则协调者和其他参与者都可以安全中止。
  3. 当应用程序准备提交时,协调者向所有参与者发送准备请求,并附带全局事务 ID。如果准备请求有任何一个发生失败或者超时,则协调者会通知所有参与者放弃事务。
  4. 参与者在收到准备请求之后,确保在任何情况下都可以提交事务,包括安全地将事务数据写入磁盘,并检查是否存在冲突或约束违规。一旦向协调者回答“是”,节点就承诺会提交事务。
  5. 当协调者收到所有准备请求的答复时,就是否提交事务要做出明确的决定(即只有所有参与者都投赞成票时才会提交)。协调者把最后的决定写入到磁盘的事务日志中,防止稍后系统崩溃,并可以恢复之前的决定。这个时刻称为提交点。
  6. 协调者的决定写入磁盘之后,接下来向所有参与者发送提交(或放弃)请求。如果此请求出现失败或超时,则协调者必须一直重试,直到成功为止。此时,所有节点不允许有任何反悔:一旦做了决定,就必须贯彻执行,即使需要很多次重试。而如果有参与者在此期间出现故障,在其恢复之后,也必须继续执行。

由此可见,该协议有两个关键点:首先,当参与者投票“是”时,它做出了肯定提交的承诺。其次,协调者做出了提交(或者放弃)的决定,这个决定也是不可撤销。正是这两个承诺确保了 2PC 的原子性。

协调者发生故障

如果协调者在发送准备请求之前就已失败,则参与者可以安全地中止交易。但是,一旦参与者收到了准备请求并做了投票“是”,则参与者不能单方面放弃,它必须等待协调者的决定。如果在决定到达之前,出现协调者崩溃或网络故障,则参与者只能无奈等待。此时参与者处在一种不确定的状态。

情况如图所示。在该例子中,协调者实际上做出了提交决定,数据库 2 已经收到了提交请求。但是,协调者在将提交请求发送到数据库 1 之前发生了崩溃,因此数据库 1 不知道该提交还是中止。超时机制也无法解决问题:如果超时之后数据库 1 决定单方面中止,最终将与完成提交的数据库 2 产生不一致。同理,参与者也不能单方面决定提交,因为可能有些参与者投了否决票导致协调者最终的决定是放弃。

image.png

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

三阶段提交

两阶段提交也被称为阻塞式原子提交协议,因为 2PC 可能在等待协调者恢复时卡住。理论上,可以使其改进为非阻塞式从而避免这种情况。但是,实践中要想做到这一点并不容易。

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

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

实践中的分布式事务

分布式事务的某些实现存在严重的性能问题。例如,有报告显示 MySQL 的分布式事务比单节点事务慢 10 倍以上。两阶段提交性能下降的主要原因是为了防崩溃恢复而做的磁盘 I/O 以及额外的网络往返开销。

但是,我们不应该就这么直接地抛弃分布式事务,而应该更加审慎的对待。首先,我们还是要明确“分布式事务”的确切含义。目前有两种截然不同的分布式事务概念:

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

数据库内部事务由于不必考虑与其他系统的兼容,因此可以使用任何形式的内部协议并采取有针对性的优化。因此,数据库内部的分布式事务往往可行且工作不错,但异构环境的事务则充满了挑战。

exactly-once 消息处理

异构的分布式事务旨在无缝集成多种不同的系统。例如,当且仅当数据库中处理消息的事务成功提交,消息队列才会标记该消息已处理完毕。如果消息发送或数据库事务任何一个发生失败,则两者都须中止,消息队列可以在稍后再次重传消息。因此,通过自动提交消息和消息处理的结果,可以确保消息可以有效处理有且仅有一次(成功之前有可能需要重试)。而如果事务最后发生中止,则会放弃所有部分完成的结果。

需要指出,只有在所有受影响的系统都使用相同的原子提交协议的前提下,这种分布式事务才是可行。例如,如果处理结果之一是发送一封邮件,而邮件服务器却不支持两阶段提交,此时如果某个环节出错需要重试,就会导致邮件系统重复发送两次或更多。但如果假定所有结果或者副作用都可以在事务中止时回滚,就可以安全地重新处理消息。

XA 交易

X/Open XA(eXtended Architecture,XA)是异构环境下实施两阶段提交的一个工业标准,目前,许多传统关系数据库和消息队列都支持 XA。

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

事务协调者需要实现 XA API。虽然标准并没有详细要求该如何实现,但实际上,协调者通常也是一个 API 库,它与产生事务的应用程序运行在相同的进程中。这些 API 会跟踪事务中所有的参与者,协调节点进行准备(通过回调)工作,然后负责收集参与者的投票,并在本地磁盘的日志文件里记录事务最终的决定。

如果应用程序进程发生崩溃,或者所在的节点出现故障,协调者就需要做相应的处理。此时,所有完成了准备阶段但尚未提交的参与者就会陷入停顿。由于事务日志保存在应用服务器的本地磁盘上,该节点必须先重启,然后协调者通过 XA API 读取日志、进而恢复事务的决定。完成这些之后,协调者才能继续使用数据库驱动 XA 回调来要求所有参与者执行提交(或中止)。数据库服务器无法直接与协调者进行通信,而须通过相应的 API 接口。

问题:停顿时仍持有锁

数据库事务通常持有待修改行的行级独占锁,用以防止脏写。此外,如果要使用可串行化的隔离,则两阶段锁的数据库还会对事务曾经读取的行持有读-共享锁。

在事务提交(或中止)之前,数据库都不会释放这些锁。因此,在两阶段提交时,事务在整个停顿期间一直持有锁。如果协调者的日志由于某种原因而彻底丢失,这些数据对象在管理员手动解决之前,将永久处于加锁状态。

数据处于加锁时,其他事务就无法执行修改。取决于数据库的具体实现,其他事务甚至无法读取这些行。因此,其他的事务事实上无法有效执行。这可能会导致很多上层应用基本处于不可用状态,所以必须解决处于停顿状态的那些事务。

从协调者故障中恢复

理论上,如果协调者崩溃之后重新启动,它应该可以从日志中恢复那些停顿的事务。然而,在实践中,孤立的不确定事务确实会发生。例如由于软件 bug 导致交易日志丢失或者损坏,最终协调者还是出现了恢复失败。

即使重启那些处于停顿状态的数据库节点也无法解决这个问题,这是由于 2PC 的正确实现要求即使发生了重启,也要继续保持重启之前事务的加锁(否则就会违背原子性保证)。唯一的出路只能是让管理员手动决定究竟是执行提交还是回滚。

许多 XA 的实现都支持某种紧急避险措施称之为启发式决策(会破坏原子性):参与者节点可以在紧急情况下单方面做出决定,放弃或者继续那些停顿的事务,而不需要等到协调者发出指令。这种启发式决策只是为了应急,不能作为常规手段来使用。

分布式事务的限制

XA 事务解决了多个参与者之间如何达成一致这样一个非常现实而重要的问题,但正如上面所看到的,它也引入了不少操作方面的限制。特别是,核心的事务协调者本身就是一种数据库(存储事务的投票结果),因此需要和其他重要的数据库一样格外小心:

  • 如果协调者不支持数据复制,而是在单节点上运行,那么它就是整个系统的单点故障(因为它的故障导致了很多应用阻塞在停顿事务所持有的锁上)。
  • 许多服务器端应用程序都倾向于无状态模式,而所有的持久状态都保存在数据库中,这样应用服务器可以轻松地添加或删除实例。但是,当协调者就是应用服务器的一部分时,部署方式就发生了根本的变化。协调者的日志成为可靠系统的重要组成部分,它要求与数据库本身一样重要。这样的应用服务器已经不再是无状态。
  • 由于XA需要与各种数据系统保持兼容,它最终其实是多系统可兼容的最低标准,也即无法提供更多高级功能。

支持容错的共识

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

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

  • 协商一致性(Uniform agreement):所有的节点都接受相同的决议。
  • 诚实性(Integrity):所有节点不能反悔,即对一项提议不能有两次决定。
  • 合法性(Validity):如果决定了值v,则v一定是由某个节点所提议的。
  • 可终止性(Termination):非极端情况下最终一定可以达成决议。

协商一致性和诚实性属性定义了共识的核心思想:决定一致的结果,一旦决定,就不能改变。合法性主要是为了排除一些无意义的方案。

如果不关心容错,那么满足前三个属性很容易:可以强行指定某个节点为“独裁者”,由它做出所有的决定。但是,如果该节点失败,系统就无法继续做出任何决定。其实这就是在两阶段提交时所看到的:如果协调者失败了,那些处于不确定状态的参与者就无从知道下一步该做什么。

可终止性则引入了容错的思想。它重点强调一个共识算法不能原地空转,必须取得实质性进展。即使某些节点出现了故障,其他节点也必须最终做出决定。

上述共识的系统模型假定当某个节点发生崩溃后,节点就彻底消失。在这样的系统模型下,所有采取等待节点恢复的算法都无法满足可终止性,特别是 2PC 不符合可终止性要求。

当然,如果所有的节点都崩溃了,那么无论何种算法都不可能继续做出决定。算法所能够容忍的失败次数和规模都有一定的限制。事实上,可以证明任何共识算法都需要至少大部分节点正确运行才能确保终止性。而这个多数就可以安全地构成 quorum。因此,可终止性的前提是,发生崩溃或者不可用的节点数必须小于半数节点

共识算法与全序广播

最著名的容错式共识算法包括 VSR,Paxos,Raft 和 Zab。这些算法存在诸多相似之处,但又不完全相同。

这些算法大部分其实并不是直接使用上述的形式化模型(提议并决定某个值,同时满足上面4个属性)。相反,他们是决定了一系列值,然后采用全序关系广播算法

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

主从复制与共识

主从复制中,所有的写入操作都由主节点负责,并以相同的顺序发送到从节点来保持副本更新。这不就是基本的全序关系广播么?那在主从复制时我们怎么没有考虑共识问题呢?

答案取决于如何选择主节点。如果主节点是由运营人员手动选择和配置的,那基本上就是一个独裁性质的“一致性算法”:只允许一个节点接受写入,如果该节点发生故障,系统将无法写入,直到操作人员再手动配置新的节点成为主节点。这样的方案也能在实践中很好地发挥作用,但它需要人为干预才能取得进展,不满足共识的可终止性。

一些数据库支持自动选举主节点和故障切换,通过选举把某个从节点者提升为新的主节点。这样更接近容错式全序关系广播,从而达成共识。

但是,还有一个问题,我们之前曾讨论过脑裂:所有的节点都需要同意主节点,否则两个主节点会导致数据库出现不一致。因此,我们需要共识算法选出一位主节点。但是,如果这里描述的共识算法实际上是全序关系广播,且全序关系广播很像主从复制,但主从复制现在又需要选举主节点等。

看起来要选举一个新的主节点,我们首先需要有一个主节点。怎么摆脱这样一个奇怪的循环?

Epoch 和 Quorum

目前所讨论的所有共识协议在其内部都使用了某种形式的主节点,虽然主节点并不是固定的。相反,他们都采用了一种弱化的保证:协议定义了一个世代编号(epoch number,对应于 Paxos 中的 ballot number,Raft 中的 termnumber),并保证在每个世代里,主节点是唯一确定的

如果发现当前的主节点失效,节点就开始一轮投票选举新的主节点。选举会赋予一个单调递增的 epoch 号。如果出现了两个不同的主节点对应于不同 epoch 号码,则具有更高 epoch 号码的主节点将获胜。

在主节点做出任何决定之前,它必须首先检查是否存在比它更高的 epoch 号码,否则就会产生冲突的决定。主节点如何知道它是否已被其他节点所取代了呢?它必须从 quorum 节点中收集投票。主节点如果想要做出某个决定,须将提议发送给其他所有节点,等待 quorum 节点的响应。quorum 通常(但不总是)由多数节点组成。并且,只有当没有发现更高 epoch 主节点存在时,节点才会对当前的提议(带有 epoch 号码)进行投票。

因此,这里面实际存在两轮不同的投票:首先是投票决定谁是主节点,然后是对主节点的提议进行投票。其中的关键一点是,参与两轮的 quorum 必须有重叠:如果某个提议获得通过,那么其中参与投票的节点中必须至少有一个也参加了最近一次的主节点选举。换言之,如果在针对提议的投票中没有出现更高 epoch 号码,那么可以得出这样的结论:因为没有发生更高 epoch 的主节点选举,当前的主节点地位没有改变,所以可以安全地就提议进行投票。

投票过程看起来很像两阶段提交。最大的区别是,2PC 的协调者并不是依靠选举产生;另外容错共识算法只需要收到多数节点的投票结果即可通过决议,而 2PC 则要求每个参与者都必须做出“是”才能最终通过。此外,共识算法还定义了恢复过程,出现故障之后,通过该过程节点可以选举出新的主节点然后进入一致的状态,确保总是能够满足安全属性。所有这些差异之处都是确保共识算法正确性和容错性的关键。

共识的局限性

不过,也不是所有系统都采用了共识,因为好处的背后都是有代价的。这包括:

在达成一致性决议之前,节点投票的过程是一个同步复制过程。数据库通常配置为异步复制,而非同步复制,原因正是为了更好的性能。

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

多数共识算法假定一组固定参与投票的节点集,这意味着不能动态添加或删除节点。动态成员资格的扩展特性可以在集群中的按需调整节点数,但相比于静态的成员组成,其理解程度和接受程度要低很多。

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

此外,共识算法往往对网络问题特别敏感

成员与协调服务

ZooKeeper或etcd这样的项目通常称为“协调与配置服务”。应用程序开发者其实很少直接使用ZooKeeper,主要因为它并非通用的数据库。绝大多数情况是通过其他很多项目来间接地依赖于Zookeeper,例如HBase,Hadoop YARN,OpenStack Nova和Kafka等。

ZooKeeper和etcd主要针对保存少量、可完全载入内存的数据(虽然它们最终仍要写人磁盘以支持持久性)而设计,所以不要用它们保存大量的数据。它们通常采用容错的全序广播算法在所有节点上复制这些数据从而实现高可靠。

ZooKeeper不仅实现了全序广播(因此实现了共识),还提供了其他很多有趣的特性:

线性化的原子操作

使用原子比较-设置操作,可以实现加锁服务。例如如果多个节点同时尝试执行相同的操作,则确保其中只有一个会成功。共识协议保证了操作满足原子性和线性化,即使某些节点发生故障或网络随时被中断。分布式锁通常实现为一个带有到期时间的租约,这样万一某些客户端发生故障,可以最终释放锁。

操作全序

当资源被锁或者租约保护时,需要fencing令牌来防止某些客户端由于发生进程暂停而引起锁冲突。fencing令牌确保每次加锁时数字总是单调增加。ZooKeeper在实现该功能时,采用了对所有操作执行全局排序,然后为每个操作都赋予一个单调递增的事务ID 和版本号。

故障检测

客户端与ZooKeeper节点维护一个长期会话,客户端会周期性地与ZooKeeper服务节点互相交换心跳信息,以检查对方是否存活。即使连接出现闪断,或者某个ZooKeeper节点发生失效,会话仍处于活动状态。但是,如果长时间心跳停止且超过了会话超时设置,ZooKeeper会声明会话失败。此时,所有该会话持有的锁资源可以配置为自动全部释放。

更改通知

客户端不仅可以读取其他客户端所创建的锁和键值,还可以监视它们的变化。因此,客户端可以知道其他客户端何时加入了集群(基于它写入ZooKeeper的值)以及客户端是否发生了故障(会话超时导致节点消失)。通过订阅通知机制,客户端不需要频繁地轮询服务即可知道感兴趣对象的变化情况。

节点任务分配

ZooKeeper非常适合的一个场景是,如果系统有多个流程或服务的实例,并且需求其中的一个实例充当主节点;而如果主节点失效,由其他某个节点来接管。显然,这非常吻合主从复制数据库,此外,它对于作业调度系统(或类似的有状态服务)也非常有用。

还有另一个场景,对于一些分区资源(可以是数据库,消息流,文件存储,分布式actor system等),需要决定将哪个分区分配给哪个节点。当有新节点加入集群时,需要将某些现有分区从当前节点迁移到新节点,从而实现负载动态平衡。而当节点移除或失败时,其他节点还需要接管失败节点。

上述场景中的任务,可以借助ZooKeeper中的原子操作,ephemeral nodes和通知机制来实现。如果实现无误,它可以非常方便地使应用程序达到自动故障中恢复,减少人工干预。

应用程序最初可能只运行在单节点,之后可能最终扩展到数千节点。试图在如此庞大的集群上进行多数者投票会非常低效。ZooKeeper通常是在固定数量的节点(通常三到五个)上运行投票,可以非常高效地支持大量的客户端。因此,ZooKeeper其实提供了一种将跨节点协调服务(包括共识,操作排序和故障检测)专业外包的方式。通常情况下,ZooKeeper所管理的数据变化非常缓慢,类似“分区7的主节点在10.1.1.23”这样的信息,其变化频率往往在分钟甚至是小时级别。它不适合保存那些应用实时运行的状态数据,后者可能每秒产生数千甚至百万次更改。

服务发现

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

成员服务

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

这里依然存在发生误判的可能性,即节点其实处于活动状态却被错误地宣判为故障。即便这样,系统就成员资格问题的决定是全体一致的,这是最重要的。


与昊
225 声望636 粉丝

IT民工,主要从事web方向,喜欢研究技术和投资之道