万字长文-详解分布式算法


先缓解下疲劳、兄弟们、下面的内容比较费脑子、先清醒一波、手动狗头!

正如 第八章 所讨论的,分布式系统中的许多事情可能会出错。处理这种故障的最简单方法是简单地让整个服务失效,并向用户显示错误消息。如果无法接受这个解决方案,我们就需要找到容错的方法 —— 即使某些内部组件出现故障,服务也能正常运行。
在本章中,我们将讨论构建容错分布式系统的算法和协议的一些例子。我们将假设 第八章 的所有问题都可能发生:网络中的数据包可能会丢失、重新排序、重复推送或任意延迟;时钟只是尽其所能地近似;且节点可以暂停(例如,由于垃圾收集)或随时崩溃。
构建容错系统的最好方法,是找到一些带有实用保证的通用抽象,实现一次,然后让应用依赖这些保证。这与 第七章 中的事务处理方法相同:通过使用事务,应用可以假装没有崩溃(原子性),没有其他人同时访问数据库(隔离),存储设备是完全可靠的(持久性)。即使发生崩溃,竞态条件和磁盘故障,事务抽象隐藏了这些问题,因此应用不必担心它们。
现在我们将继续沿着同样的路线前进,寻求可以让应用忽略分布式系统部分问题的抽象概念。例如,分布式系统最重要的抽象之一就是 共识(consensus):就是让所有的节点对某件事达成一致。正如我们在本章中将会看到的那样,要可靠地达成共识,且不被网络故障和进程故障所影响,是一个令人惊讶的棘手问题。
一旦达成共识,应用可以将其用于各种目的。例如,假设你有一个单主复制的数据库。如果主库挂掉,并且需要故障切换到另一个节点,剩余的数据库节点可以使用共识来选举新的领导者。正如在 “处理节点宕机” 中所讨论的那样,重要的是只有一个领导者,且所有的节点都认同其领导。如果两个节点都认为自己是领导者,这种情况被称为 脑裂(split brain),它经常会导致数据丢失。正确实现共识有助于避免这种问题。
在本章后面的 “分布式事务与共识” 中,我们将研究解决共识和相关问题的算法。但首先,我们首先需要探索可以在分布式系统中提供的保证和抽象的范围。
我们需要了解可以做什么和不可以做什么的范围:在某些情况下,系统可以容忍故障并继续工作;在其他情况下,这是不可能的。我们将深入研究什么可能而什么不可能的限制,既通过理论证明,也通过实际实现。我们将在本章中概述这些基本限制。
分布式系统领域的研究人员几十年来一直在研究这些主题,所以有很多资料 —— 我们只能介绍一些皮毛。在本书中,我们没有空间去详细介绍形式模型和证明的细节,所以我们会按照直觉来介绍。如果你有兴趣,参考文献可以提供更多的深度。
一致性保证
在 “复制延迟问题” 中,我们看到了数据库复制中发生的一些时序问题。如果你在同一时刻查看两个数据库节点,则可能在两个节点上看到不同的数据,因为写请求在不同的时间到达不同的节点。无论数据库使用何种复制方法(单主复制,多主复制或无主复制),都会出现这些不一致情况。
大多数复制的数据库至少提供了 最终一致性,这意味着如果你停止向数据库写入数据并等待一段不确定的时间,那么最终所有的读取请求都会返回相同的值【1】。换句话说,不一致性是暂时的,最终会自行解决(假设网络中的任何故障最终都会被修复)。最终一致性的一个更好的名字可能是 收敛(convergence),因为我们预计所有的副本最终会收敛到相同的值【2】。
然而,这是一个非常弱的保证 —— 它并没有说什么时候副本会收敛。在收敛之前,读操作可能会返回任何东西或什么都没有【1】。例如,如果你写入了一个值,然后立即再次读取,这并不能保证你能看到刚才写入的值,因为读请求可能会被路由到另外的副本上。(请参阅 “读己之写” )。
对于应用开发人员而言,最终一致性是很困难的,因为它与普通单线程程序中变量的行为有很大区别。对于后者,如果将一个值赋给一个变量,然后很快地再次读取,不可能读到旧的值,或者读取失败。数据库表面上看起来像一个你可以读写的变量,但实际上它有更复杂的语义【3】。
在与只提供弱保证的数据库打交道时,你需要始终意识到它的局限性,而不是意外地作出太多假设。错误往往是微妙的,很难找到,也很难测试,因为应用可能在大多数情况下运行良好。当系统出现故障(例如网络中断)或高并发时,最终一致性的边缘情况才会显现出来。
本章将探索数据系统可能选择提供的更强一致性模型。它不是免费的:具有较强保证的系统可能会比保证较差的系统具有更差的性能或更少的容错性。尽管如此,更强的保证能够吸引人,因为它们更容易用对。只有见过不同的一致性模型后,才能更好地决定哪一个最适合自己的需求。
分布式一致性模型 和我们之前讨论的事务隔离级别的层次结构有一些相似之处【4,5】(请参阅 “弱隔离级别”)。尽管两者有一部分内容重叠,但它们大多是无关的问题:事务隔离主要是为了 避免由于同时执行事务而导致的竞争状态,而分布式一致性主要关于 在面对延迟和故障时如何协调副本间的状态。
本章涵盖了广泛的话题,但我们将会看到这些领域实际上是紧密联系在一起的:
首先看一下常用的 最强一致性模型 之一,线性一致性(linearizability),并考察其优缺点。
然后我们将检查分布式系统中 事件顺序 的问题,特别是因果关系和全局顺序的问题。
在第三节的(“分布式事务与共识”)中将探讨如何原子地提交分布式事务,这将最终引领我们走向共识问题的解决方案。
线性一致性
在 最终一致 的数据库,如果你在同一时刻问两个不同副本相同的问题,可能会得到两个不同的答案。这很让人困惑。如果数据库可以提供只有一个副本的假象(即,只有一个数据副本),那么事情就简单太多了。那么每个客户端都会有相同的数据视图,且不必担心复制滞后了。
这就是 线性一致性(linearizability) 背后的想法【6】(也称为 原子一致性(atomic consistency)【7】,强一致性(strong consistency),立即一致性(immediate consistency) 或 外部一致性(external consistency )【8】)。线性一致性的精确定义相当微妙,我们将在本节的剩余部分探讨它。但是基本的想法是让一个系统看起来好像只有一个数据副本,而且所有的操作都是原子性的。有了这个保证,即使实际中可能有多个副本,应用也不需要担心它们。
在一个线性一致的系统中,只要一个客户端成功完成写操作,所有客户端从数据库中读取数据必须能够看到刚刚写入的值。要维护数据的单个副本的假象,系统应保障读到的值是最近的、最新的,而不是来自陈旧的缓存或副本。换句话说,线性一致性是一个 新鲜度保证(recency guarantee)。为了阐明这个想法,我们来看看一个非线性一致系统的例子。

图 9-1 展示了一个关于体育网站的非线性一致例子【9】。Alice 和 Bob 正坐在同一个房间里,都盯着各自的手机,关注着 2014 年 FIFA 世界杯决赛的结果。在最后得分公布后,Alice 刷新页面,看到宣布了获胜者,并兴奋地告诉 Bob。Bob 难以置信地刷新了自己的手机,但他的请求路由到了一个落后的数据库副本上,手机显示比赛仍在进行。
如果 Alice 和 Bob 在同一时间刷新并获得了两个不同的查询结果,也许就没有那么令人惊讶了。因为他们不知道服务器处理他们请求的精确时刻。然而 Bob 是在听到 Alice 惊呼最后得分 之后,点击了刷新按钮(启动了他的查询),因此他希望查询结果至少与爱丽丝一样新鲜。但他的查询返回了陈旧结果,这一事实违背了线性一致性的要求。
什么使得系统线性一致?
线性一致性背后的基本思想很简单:使系统看起来好像只有一个数据副本。然而确切来讲,实际上有更多要操心的地方。为了更好地理解线性一致性,让我们再看几个例子。
图 9-2 显示了三个客户端在线性一致数据库中同时读写相同的键 x
。在分布式系统文献中,x
被称为 寄存器(register),例如,它可以是键值存储中的一个 键,关系数据库中的一 行,或文档数据库中的一个 文档。

图 9-2 如果读取请求与写入请求并发,则可能会返回旧值或新值
为了简单起见,图 9-2 采用了用户请求的视角,而不是数据库内部的视角。每个柱都是由客户端发出的请求,其中柱头是请求发送的时刻,柱尾是客户端收到响应的时刻。因为网络延迟变化无常,客户端不知道数据库处理其请求的精确时间 —— 只知道它发生在发送请求和接收响应之间的某个时刻。1
在这个例子中,寄存器有两种类型的操作:
表示客户端请求读取寄存器
x
的值,数据库返回值v
。表示客户端请求将寄存器
x
设置为值v
,数据库返回响应r
(可能正确,可能错误)。客户端 A 的第一个读操作,完成于写操作开始之前,因此必须返回旧值
0
。客户端 A 的最后一个读操作,开始于写操作完成之后。如果数据库是线性一致性的,它必然返回新值
1
:因为读操作和写操作一定是在其各自的起止区间内的某个时刻被处理。如果在写入结束后开始读取,则读取处理一定发生在写入完成之后,因此它必须看到写入的新值。与写操作在时间上重叠的任何读操作,可能会返回
0
或1
,因为我们不知道读取时,写操作是否已经生效。这些操作是 并发(concurrent) 的。
在 图 9-2 中,x
的值最初为 0
,客户端 C 执行写请求将其设置为 1
。发生这种情况时,客户端 A 和 B 反复轮询数据库以读取最新值。A 和 B 的请求可能会收到怎样的响应?
但是,这还不足以完全描述线性一致性:如果与写入同时发生的读取可以返回旧值或新值,那么读者可能会在写入期间看到数值在旧值和新值之间来回翻转。这不是我们所期望的仿真 “单一数据副本” 的系统。2
为了使系统线性一致,我们需要添加另一个约束,如 图 9-3 所示

图 9-3 任何一个读取返回新值后,所有后续读取(在相同或其他客户端上)也必须返回新值。
在一个线性一致的系统中,我们可以想象,在 x
的值从 0
自动翻转到 1
的时候(在写操作的开始和结束之间)必定有一个时间点。因此,如果一个客户端的读取返回新的值 1
,即使写操作尚未完成,所有后续读取也必须返回新值。
图 9-3 中的箭头说明了这个时序依赖关系。客户端 A 是第一个读取新的值 1
的位置。在 A 的读取返回之后,B 开始新的读取。由于 B 的读取严格在发生于 A 的读取之后,因此即使 C 的写入仍在进行中,也必须返回 1
(与 图 9-1 中的 Alice 和 Bob 的情况相同:在 Alice 读取新值之后,Bob 也希望读取新的值)。
我们可以进一步细化这个时序图,展示每个操作是如何在特定时刻原子性生效的。图 9-4 显示了一个更复杂的例子【10】。
在 图 9-4 中,除了读写之外,还增加了第三种类型的操作:
表示客户端请求进行原子性的 比较与设置 操作。如果寄存器的当前值等于,则应该原子地设置为。如果不等于
,则操作应该保持寄存器不变并返回一个错误。$r$ 是数据库的响应(正确或错误)。
图 9-4 中的每个操作都在我们认为执行操作的时候用竖线标出(在每个操作的条柱之内)。这些标记按顺序连在一起,其结果必须是一个有效的寄存器读写序列(每次读取都必须返回最近一次写入设置的值)。
线性一致性的要求是,操作标记的连线总是按时间(从左到右)向前移动,而不是向后移动。这个要求确保了我们之前讨论的新鲜度保证:一旦新的值被写入或读取,所有后续的读都会看到写入的值,直到它被再次覆盖。

未完待续、兄弟们、欲知后事如何、倾听下回分解!
