DDIA 读书分享会,会逐章进行分享,结合我在工业界分布式存储和数据库的一些经验,补充一些细节。每两周左右分享一次,欢迎加入,Schedule 在这里[1]。我们有个对应的分布式&数据库讨论群,每次分享前会在群里通知。如想加入,可以加我的微信号:qtmuniao,简单自我介绍下,并注明:分布式系统群。
我们之前提到,一个线性化的寄存器对外表现得像:
该定义暗含着:所有操作会形成一个确定的执行顺序。在图 9-4 中,我们就根据读到的结果来推测出了一个服务器端所有操作的看起来的执行顺序。
顺序(ordering)是本书中不断强调的一个主题,这也确实说明顺序是一个非常重要的基础概念。我们回忆一下本书所提到顺序的相关上下文:
顺序性(ordering)、线性一致性(linearizability)和共识协议(consensus)三个概念间有很深的联系。相比本书其他部分,尽管这几个概念更偏理论和抽象,但理解他们却有助于来厘清系统的功能边界——哪些可以做,哪些做不了。在接下来的几小节中,我们会对此进行详细探讨。
顺序在本书中反复被提及的原因有很多,其中一个是:它可以维持因果性。关于因果关系的重要性,本书也举过很多例子:
因果将顺序施加于事件(event):
这和现实生活一样,一件事的发生会引起另一件的事的出现:一个节点读取数据之后,依据读取内容,(依赖于读取)写入了一些数据;另一个节点读取这些写入,进而写入了另外一些数据,循环往复。操作(operation)发生的依赖链条定义了系统中事件的因果——也即,谁居先,谁处后。
如果一个系统遵循因果约束,则我们称其为因果一致的(_causally consistent_)。比如,快照隔离就可以提供因果一致性:当从数据库读取数据的时候,如果你能读到某个时间点的数据,就一定能读到其之前的数据(当然,要在该数据还没有被删除的情况下)。
全序(total order)意味着系统内任意两个元素可比大小。如,自然数是全序:任举两个自然数,比如 5 和 13,我们可以确定 13 是比 5 大的。但与此相对,数学中的集合就不是全序的,比如我们无从比较 {a, b} 和 {b, c} 的大小关系,因为他们互不为对方子集。对于这种情况,我们称其不可比(incomparable)。反之,集合是偏序(partially ordered):在某些情况下,我们可以说一个集合比另一个集合大(两个集合间有包含关系);但在另外一些情况下,两个集合间没有可比关系。
全序和偏序的区别还反应在不同强度数据库一致性模型(database consistency models)上:
根据上述解释,在线性一致性的数据存储服务中,是不存在并发操作的:因为必然存在一个时间线能将所有操作进行排序。同一时刻可能会有多个请求到来,但是线性化的存储服务可以保证:所有请求都会在单个副本上、一个单向向前的时间线上的某个时间点被原子地处理,而没有任何并发。
并发(concurrency)意味着时间线的分叉与合并。但在重新合并之时,来自两个时间分支的操作就有可能出现不可比的情况。在第五章的图 5-14(参见确定 Happens-Before 关系)中我们见过类似的现象,所有的事件不在一条时间线上,而是有相当复杂的图形依赖。图中的每个箭头,本质上定义了一种因果依赖,也即偏序关系。
理解全序和偏序、线性一致性和因果一致性的一个关键模型是有向图。在该图中,点代表事件,有向边代表因果关系,并且从因事件指向果事件,很自然的,因果性满足传递性。如果该图中有一条单一的路径能串起所有点,且不存在环,则该系统是线性一致的。可以看出,因果关系是一种局部特性(也即偏序关系),定义在两个点之间(如果两个点之间存在着一条单向途径,则这两点有因果关系);而线性关系是一种全局特性(也即全序关系),定义在整个图上。
如果你熟悉一些分布式的版本管理工具,如 Git,你就会发现相似的特性。在 Git 中,版本间的依赖就类似于一个因果依赖图。大部分时候,所有人的提交(commit)是前后相继的,组成一个直线;但有时,如果团队中的几个人同时(并发的)为一个项目工作,版本历史就会产生分叉,并且在提交的时候重又合并。
在 Spark 的多个 RDD 之间,也有类似的感觉。如果所有算子都是单输入算子,则执行图会是一条线,即全序没有并发;如果有的算子有多个输入,则不同输入之间可以并发,此时为偏序关系。
那么因果顺序和线性一致性的关系是什么?答曰:线性一致性是因果一致性的充分(implies)条件。所有提供线性一致性的系统都能够能够保证因果性。尤其对于有多个通信通道的系统来说,我们不需要做任何额外努力(比如在多个组件间传递时间戳,以建立因果关系),线性一致性就能够保证系统中发生的所有事件满足因果性。
用我们之前的图模型来说,就是不存在环。即,因果一致性(有向无环图) ⇒ 线性一致性(在有向无环图的基础上,存在一条能串起所有点的单向路径)。
线性一致性能能够保证因果关系,该特点让系统易于使用,从而对应用层很有吸引力。但任何事情都是有代价的,如我们在之前线性一致性的代价一节中所讨论的:提供线性一致性非常伤性能和可用性,在网络有显著延迟时(如全球部署的系统),该副作用尤其明显。因此,很多系统会舍弃线性一致性以换取更好的性能,但当然,代价是更难用了。
好消息是存在折中路线。线性一致性并非保持因果关系的唯一途径,还有很多其他办法。也即,一个系统可以不必承担线性一致性所带来的性能损耗,而仍然是因果一致的(consistent)。当然,在这种情况下,CAP 定理是不适用的。事实上,因果一致性是系统在保证有网络延迟而不降低性能、在有网络故障而仍然可用的情况下,能够提供的最强一致性模型。
在大多数情况下,我们以为我们需要线性一致模型,其实我们真实需要因果一致模型,而后者允许我们实现性能更好的系统。基于这个观察,研究人员在探寻新型数据库的设计,让系统既可以提供因果关系保证,也可以提供(堪比最终一致性系统的)高性能和高可用性。
这些研究都比较新,还存在很多挑战,也没有进行落地。但无疑,是分布式系统在将来一个很有前景发展方向。
真实系统中,在所有的事件集中,只有部分事件是有因果依赖的,这些事件需要在执行时保证因果顺序执行;而其他的大部分事件是没有因果依赖的,因此可以乱序、按需执行以保证性能。但这件事情的难点在于,因果关系是应用层定义的。而我们在系统层,就很难识别。可能需要提供某种接口,可以让应用层显示指定因果,但一来不确定这种接口是否能做的足够宽泛;二来,这种因果追踪的额外代价是非常大的。
我们不会事无巨细的去探究非线性化系统如何保持因果关系的每个细节,仅就其中的一些关键点进行探讨。
为了保证因果一致性,我们首先需要知道哪些操作存在着因果关系。如之前所说,因果关系是偏序关系,有些操作是并发的,但如果确定某个操作发生在另外一个之前,则在所有的副本上都要以同样的顺序处理这两个操作。因此,当某个副本处理一个操作时,它必须确保所有其前驱(happens before)操作都已经完成;如果有任何前驱操作还未出现,则之后的操作必须等待直到其被处理。
为了确定因果依赖,我们需要某种手段来描述系统中节点的“知识”(knowledge)。如果某个节点在收到 Y 的写入请求时已经看到了值 X,则 X 和 Y 间可能会存在着因果关系。就如在调查公司的欺诈案时,CEO 常被问到,“你在做出 Y 决定时知道 X 吗”?
确定哪些操作先于哪些些操作发生的方法类似于我们在“并发写入检测”一节讨论的技术。那一节针对无主模型讨论了如何检测针对单个 Key 的并发写入,以防止更新丢失问题。因果一致性所需更多:需要在整个数据库范围内追踪所有 Key 间操作的因果依赖,而非仅仅单个 Key 上。版本向量(version vectors)常用于此道。
为了解决确定因果顺序,数据库需要知道应用读取数据的版本信息。这也是为什么在图 5-13 中(参见 确定 Happens-Before 关系),我们在写入数据时需要知道先前读取操作中数据库返回的版本号。在 SSI 的冲突检测(参见可串行的快照隔离)中也有类似的思想:当一个事务提交时,数据库需要检查其读取集合中的数据版本是否仍然是最新的。为此,数据库需要跟踪一个事务读取了哪些数据的哪些版本。
理论上来说,因果关系很重要;但在实践中,追踪所有的因果依赖非常不切实际。在很多应用场景,客户端会先读取大量数据,才会进行写入。并且我们也无从得知,之后的写入和先前有没有关系,和哪些有关系。显式的追踪所有读集合所带来的开销会非常大。
不过,我们有一种更简单的手段:使用序列号(sequence numbers)或者时间戳(timestamps)来给事件定序。我们不非得用物理时间戳(如日历时钟,time-of-day clock,参见日历时钟),而可以使用逻辑时钟(logic clock),即使用某种算法来产生一系列的数值以关联操作。最简单的,可以用一个计数器来递增地为每个操作安排一个序列号。
此种序列号和时间戳通常都非常紧凑,只占几个字节,但却能提供一种全序关系。通过给每个操作关联一个序列号,就能比较任何两个操作的先后关系。
进一步,我们可以保证我们产生序列号的方式满足因果关系:如果操作 A 发生在 B 之前,则 A 获取到的序列号比 B 小。并发的(无法比较谁先谁后)操作获取到的序列号顺序不确定。序列号本质上是一种全序,通过这种方式可以追踪因果关系,但也施加了一个比因果关系更为严格的全序。
联想我们之前用以理解的有向图,相当于在满足原来有向边(因果关系)的基础上,增加了一些有向边,串出了一条能串起所有点(操作)的路径。
在使用单主模型的多副本系统中,主节点上操作日志的追加顺序确定了一个对所有操作的全序,且满足操作发生的因果关系。主节点可以为每条日志按顺序关联一个全局递增的序列号,如果从节点上也按都按此序列号顺序应用操作日志到状态机,则每个副本总能保持一致的状态(但有可能稍落后于主节点)。
如果系统中没有唯一的单主节点(比如你用的是多主模型或无主模型,又或者你的系统存在多个分区),则如何为每个操作产生一个序列号就变得不那么简单直观了。常用的方式有以下几种:
这三种方案都要比使用单点计数器生成序列号要性能好、扩展性更强,且能为系统中的每个操作产生全局唯一的、近似递增的序列号。但他们都存在着同样的问题:产生的序列号不是因果一致的。
由于这些序列号生成方法都不能够很好地捕捉跨节点的操作因果关系,因此都存在因果问题:
虽然上面的几种方式产生的序列号不满足因果一致性,但却有一种相对简洁的方式可以做到—— Lamport 时间戳。它是由 Lesilie Lamport 在 1978 年提出的,是分布式领域被引用最多的论文之一。
下图展示了 Lamport 时间戳的使用方法。在该系统中,每个节点有一个唯一的 id 和一个记录处理过多少个操作的计数器,Lamport 时间戳是上述两者组成的二元组:(counter, node ID)
。不同的节点可能会有相同的 counter 值,但通过引入 node ID,可以使所有时间戳都是全局唯一的。
Untitled
Lamport 时间戳不依赖于物理时钟,但可以提供全序保证,对于任意两个 Lamport 时间戳:
但到此为止,以上表述和之前提到的奇偶时间戳没有本质区别。让 Lamport 时间戳能够满足因果一致性的核心点在于:每个节点和客户端都会让 counter 追踪当前所看到(包括本机的和通信的)的最大值。当节点看到请求或者回复中携带的 counter 值比自己大,就会立即用其值设置本地 counter。
如上图,客户端 A 在收到节点 2 的 counter = 5 的回复后,会使用该值向节点 1 发送请求。节点 1 本来的 counter 值是 1,在收到该请求后,会立即前移为 5。尔后,下一个请求操作到来会将其增加为 6。
系统中所有的事件(event),和交互方(client,server)都要被纳入 Lamport Clock 体系内,才能追踪系统内的所有因果关系。
只要最大的 counter 值通过每个操作被传播,就能保证 Lamport 时间戳满足因果一致。因为每次因果依赖的交互都会推高时间戳。
有时候我们会将 Lamport 时间戳和之前提到的版本向量(version vector,参见并发写入检测)混淆。虽然看起来相似,但其根本目的却是不同:
对于 2,虽然 Lamport 时间戳能够追踪因果关系,即具有因果关系中的 happens-before 关系。但是反过来,并不能通过两个 Lamport 时间戳的大小来判断其是有因果关系、还是并发的。但相对于版本向量,Lamport 时间戳占用空间小,更为紧凑。
尽管 Lamport 时间戳能够给出一种能够追踪因果关系的全序时间戳生成算法,但并不足以解决分布式系统中所面临的的很多基本问题。
举个例子,考虑一个系统,在该系统中,以用户名唯一确定一个账户。如果两个用户并发的用同一个用户名创建账户,则一个成功,另一个失败(参见领导者和锁)。
第一感觉,对所有事件进行全序定序(如使用 Lamport 时间戳)能够解决该问题:如果系统收到两个具有相同用户名的账户创建请求,让具有较小时间戳的那个请求成功,让另一个失败。由于所有时间戳满足全序关系,这两个请求的时间戳总是可以比的。
该方法能够确定赢家基于一个隐藏假设:当你拿到系统中所有的账户创建操作后,你才可以比较他们的时间戳。然而,在收到某个账户创建请求时,系统中单个节点并不能立即独自的判断该请求成功还是失败。此时此刻,该节点并不知道其他节点是否收到了具有同样用户名的账户创建请求,以及其请求的时间戳是大还是小。
如果其他节点收到同名账户的创建请求,并且获得了较小的时间戳,本节点的创建请求就得失败。为了避免这一点,它需要不断和其他节点沟通,以知晓他们在做啥。但如果沟通时,其他节点宕机或者网络出现问题,则可能会导致系统陷入停顿而不能提供服务。这显然不符合我们对一个高可用系统的期望。
上述问题的核心在于,只有在收集到系统中所有操作之后,才能真正确定所有操作的全序。如果其他节点正在进行某些操作,但你并不知晓,也就自然不能确定最终的事件的全序:毕竟这些未知节点的操作可能被插入到不同位置。举个例子,本节点的事件顺序为 n1e1, n1e2, n1e3,另外一个节点有两个事件,顺序为 n2e1, n2e2,将两个序列进行合并时,会有多种可能的结果。这有点类似于多个并发事务中的读写序列定序。
小结一下,在分布式系统中,为了实现类似于针对用户名的唯一性约束,仅为所有时间进行全局定序是不够的,你还需要知道该定序何时完成。对于某个创建账户的操作,如果我们能够确定在最终的全序里,不会有其他操作插到该操作之前,我们便可以安全的让该操作成功。
确定全局定序何时收敛,将会在接下来的小节 —— 全序广播(_total order broadcast_)中讨论。
如果你的系统只跑在单核 CPU 上,那确定该系统中所有操作的全局顺序是相当简单直接的——即其在 CPU 上的执行顺序。然而,在一个分布式系统中,让所有节点就所有操作的某个确定的全局序列达成一致是相当棘手的。在上一小节,我们讨论了使用时间戳或者序列号进行定序的问题,但发现相比单主模型这种方法容错能力很弱鸡(在使用时间戳定序的系统中,如果你想实现唯一性约束,就不能容忍任何故障)。
如前所述,单主模型通过在所有节点中选出一个主,尔后在该节点上利用某个 CPU 对所有操作进行定序,从而确定一个唯一的全局序列。但使用单主模型的系统会面临两个问题:
在分布式系统的语境下,该问题也被称为全序广播(total order broadcast)或者原子广播(atomic broadcast)。
顺序保证的范围。 多分区的数据库,对于每个分区使用单主模型,能够维持每个分区的操作全局有序,但并不能提供跨分区的一致性保证(比如一致性快照,外键约束)。当然,跨分区的全序保证也是可以提供的,只不过需要进行额外的协调。
全序广播是一种多个节点间交换消息的协议。它要求系统满足两个安全性质:
一个正确的全序广播算法需要保证上述两条性质在任何情况下都能够满足,包括网络故障和节点宕机。但当然,如果网络出现故障时,消息肯定不能送达所有节点;但算法可以进行重试,直到网络最终恢复(当然,恢复后也要保证所有消息送达的顺序一致)。
像 Zookeeper 和 etcd 等共识服务都实现了全序广播算法。从这些共识服务我们能感觉到,全序广播和共识协议具有很强的关联性,我们会在本章稍后一探究竟。
在做数据库备份时其实我们真正需要的是全序广播:如果我们让“消息”具象为数据库中的写入,且每个副本以相同的顺序处理相同的输入集,则每个副本必然会保持一致(除却暂时的临时同步延迟外)。该准则也被称为:状态机复制(state machine replication),在第 11 章的时候我们将继续该主题。
类似的,全序广播也可以用于实现可串行化的事务:如之前物理上串行提到的,消息在此具象为作为存储过程执行的一个确定性的事务,如果所有节点按同样的顺序处理这些消息,则数据中的所有分区和副本最终都会在数据上保持一致。
需要注意到,全序广播的一个重要性质是:当收到消息时,其顺序已经确定。这是因为,节点不能将后收到的消息,插入之前的已经收到的消息序列。这让全序广播要强于时间戳排序(timestamp order)。
还可以从另外一个角度来理解全序广播——用来写日志(比如复制日志、事务日志或者写前日志):投递消息就像追加日志。由于所有节点都会按照同样的顺序发送消息,则所有节点在读取日志的时候也会得到同样的消息序列。
全序广播也可以用来实现提供防护令牌功能(fencing token,参见防护令牌,即关联了 id 的锁)的锁服务。每个上锁请求可以作为消息追加到日志中,依据其追加到日志中的顺序,所有请求可以被自然地编号。由于这个序列号是单调递增的,便可以充当防护令牌。在 Zookeeper 中,这个序列号便是 zxid。
如图 9-4,在线性一致系统中,所有操作存在着一个全局序列。这是否意味着全序广播就是线性一致性?不尽然,但他们间有很深的联系。
全序广播是异步的:系统保证以同样的顺序交付消息,但并不保证消息的交付时刻(即,有的消息接收者间可能存在着滞后)。与之相对,线性一致性是一种新鲜度保证:读取一定能看到最新成功的写。
不过,你可以基于全序广播来构建线性一致的存储系统。如,可以用于保证用户名的唯一性约束。
对于该问题,可以这样实现。对每一个可能的用户名,我们使用一个支持 CAS 操作的线性寄存器,初始值为 null(表示该用户名没有被占用)。当用户想使用某个用户名创建账户时,使用 CAS 操作,在寄存器旧值为 null 时,将其值设置为该用户 account-id。由于寄存器的原子性,最后只有一个用户会成功。
使用全序广播系统作为日志追加服务,便可以实现这样一个支持可线性化 CAS 操作的“寄存器”:
这里其实隐藏了一些细节,即我们会将追加消息请求发送给全序广播系统,全序广播系统会真正将消息按之前提到的两条保证的方式(可靠送达和全序送达)同步到每个节点。因此,对于每个节点来说,会首先发起消息追加请求,然后之后某个时刻,可以等到真正同步回来的消息。如果觉得绕,可以带入 Raft 的复制日志来类比。
由于所有日志条目都会以同样的顺序送达每个节点,若有并发写入,则所有节点都能依靠日志顺序就谁“先来后到”达成一致。当有同名冲突时,可以选择第一条作为赢家,并舍弃其后的冲突请求。可以使用类似的方式,基于日志来实现涉及到多对象的事务的可串行化。
尽管该方式能够提供线性化的写入,却不能保证线性化的读取。如果你从一个异步同步日志的节点读取日志,就有可能读到陈旧的数据(更精确一点说,上述过程能够提供顺序一致性,sequential consistency,有时也被称为时间线一致性,_timeline consistency_,比线性一致性稍弱)。在此基础上,如果想让读取也变得可线性化,有几种做法:
上节展示了如何使用全序广播来实现 CAS 操作的线性化存储。我们也可以反过来,设有一个线性一致的存储系统,看如何基于其实现全序广播。
最简单的方法,假设我们有一个整数寄存器,并提供 increment-and-get 原子操作,当然提供 CAS 原子操作也是可以的,只是稍微没那么直观。
算法相当简单:对于每一个发给全序广播系统的消息,使用整数寄存器 increment-and-get 操作关联一个序列号;然后将消息发送给所有节点(重试任何丢失的消息)。每个节点接收到消息后利用序列号顺序对外交付消息。这种机制很像 TCP,但并不是描述通信双方,而是一个分布式系统。
注意到,和 Lamport 时间戳不同,从线性化的寄存器中获取的数字是连续的,非跳跃的。如此一来,当某节点交付了消息 4 后,收到了消息 6,但不能立即交付,而需要等待消息 5 的到来。但在 Lamport 时间戳系统中则非如此——这(是否连续)也是全序广播和时间戳顺序的核心不同。
实现一个支持 increment-and-get 原子操作的线性化整数寄存器有多难?如果所有组件都不会出错,则很简单:你可以直接用单台机器上的一个变量。但如果连接到该节点的网络会出问题怎么办?这台机器机器宕机后,宕机前变量的值又该怎么恢复?理论上说,只要你对线性序列生成器思考的足够完备,就会不可避免的得到一个共识算法(consensus algorithm)。
这并不是巧合:可以证明,一个线性的 CAS 寄存器和全序广播都等价于共识协议(equivalent to consensus)。也即,如果你解决了其中任意一个问题,就可以通过某种手段将其转化为另一个问题的解决方案。这真是一个惊人的性质!
终于,说到了共识,这是本章余下将要全力探讨的问题。
[1]
DDIA 读书分享会: https://ddia.qtmuniao.com/
[2]
第五章: https://ddia.qtmuniao.com/#/ch05
[3]
处理写入冲突: https://ddia.qtmuniao.com/#/ch05?id=%e5%a4%84%e7%90%86%e5%86%99%e5%85%a5%e5%86%b2%e7%aa%81
[4]
第七章: https://ddia.qtmuniao.com/#/ch07
[5]
第八章: https://ddia.qtmuniao.com/#/ch08
题图故事