凡是做服务器开发的技术同学,估计都对分布式系统以及相关的理论感兴趣。而对于分布式理论,大家讨论的最多的恐怕就是「分布式一致性」问题了。然而,不管是学术界还是业界的发展历史上,对于「一致性」这个概念的理解,始终充满了混乱。
如果你问一个技术同学,到底什么是分布式一致性,估计会得到五花八门的答案。这其中比较常见的说法可能是这样的:一致性就是多个服务器节点中的数据保持一致(至少百度百科上差不多就是这么说的)。而如果再讨论得深入一点,可能就会谈到所谓的分布式一致性协议,比如Paxos之类的;还有CAP定理,也跟「一致性」有关。
但是,「一致性」这个词是非常有迷惑性的。如果用英文来表达的话,跟「一致性」有关的至少有两个词:consistency和consensus。它们经常都被翻译成「一致性」,这进一步加剧了这个概念被滥用的程度。为了接下来讨论方便,我们先简单地澄清一下:
下面,我们就对这些概念进行详细的解析。
ACID是数据库事务的四个特性,分别是原子性 (Atomicity)、一致性 (Consistency)、隔离性 (Isolation)和持久性 (Durability)。
我们现在关注的是其中的C,即一致性Consistency。它是什么意思呢?通俗地说,它指的是任何一个数据库事务的执行,都应该让整个数据库保持在「一致」的状态。那怎样的状态才算「一致」呢?举个例子,假设在银行账户之间进行转账。显然,「转账」这个操作应该确保在转账前后账户总额保持不变,这是任何一个转账操作必须要遵守的规定。现在假设要从账户A向账户B转账100元,于是我们启动了一个数据库事务。在这个事务中,可以先从账号A中减去100元,再往账户B中增加100元。这样的一个事务操作,满足了“转账前后账户总额保持不变”的规定,因此我们说:这个事务操作保持了数据库的「一致性」;同时,在这个事务执行前后,数据库都处于一种「一致」的状态。
从上一段的描述中,我们容易看出:
我们再来看一下,为了让事务总是能保持ACID的一致性,我们需要在实现上考虑哪些因素呢?
至少两个方面需要考虑:一个是出错情况 (failure/error);一个是并发 (concurrency) 行为。
首先,对于任何系统来说,错误都是在所难免的。而错误又可以细分为两类。
第一类,事务本身的实现逻辑可能存在错误。比如,从账户A向账户B转账100元,在这个事务中,如果我们只从账号A中减去了100元,但忘记了往账户B中增加100元,那么这个事务就是错误的。显然,避免第一类错误,是保持一致性的前提,这需要应用层进行恰当的编码来保证。
第二类,则是意想不到的各种软硬件错误。比如,还是从账户A向账户B转账100元,事务本身的实现逻辑没有问题,它先执行了从账号A中减去了100元,但在执行往账户B中增加100元之前,却发生了意想不到的错误,比如进程突然crash了,或是磁盘满了,或是网络突然不通了,或是其它任何可能的硬件错误。这时候,事务只执行了前一半,势必会破坏数据库整体状态的一致性。那怎么办呢?这其实就需要ACID中的A(原子性)来保障了。简言之,原子性保障了事务的执行要么全部成功,要么全部失败,而不允许出现“只执行了一半”这种“部分成功”的情况。
其次,并发行为也可能会影响事务的一致性。在数据库系统中,并发行为体现在可能存在多个事务同时操作同一份数据的情况。还是拿前面转账的例子来说,假设有两个事务:事务1从账户A向账户B转账100元,事务2从账户A向账户C转账50元。如果两个事务先后顺序执行,自然没有问题。但如果两个事务同时执行了,那么可能会出现下面的执行序列(假设账号A的初始余额为x元):
上面的执行过程,账户A中最后被写入的值是(x-50)元,显然是不对的(事务的一致性会被破坏)。如果两个转账的事务能正确执行完,那么账户A的余额应该是(x-150)元才对。
这个并发的问题怎么处理呢?这就需要ACID中的I(隔离性)来保障了。什么是隔离性呢?它对于并发执行的多个事务进行合理的排序,保障了不同事务的执行互不干扰。换言之,隔离性这种特性,能够让并发执行的多个事务就好像是按照「先后顺序」执行的一样。
经过上面的分析,现在关于ACID中的一致性,我们可以得到一些结论了:
总之,ACID中的一致性,是一个非常特殊的概念。除了数据库事务处理,它很难扩展到其它场景,也跟分布式理论中的其它「一致性」概念没有什么关系。
先说共识问题 (consensus problem)。这是分布式系统中的一个十分基础而核心的问题,它表示如何在分布式系统中的多个节点之间就某事达成共识。
网上通常提到的「分布式一致性协议」,或者「分布式一致性算法」,一般来说就是解决这里的共识问题的算法。用词的不同,是由于中英文翻译造成的。这些算法或协议,经常包含Paxos之类,但也可能包括两阶段提交协议(2PC)或三阶段提交协议(3PC)。
人们既然经常将Paxos、2PC、3PC这些算法放在一起讨论,那么它们之间势必存在着某种相似性的。但这种相似性是怎么来的呢?我们仔细分析一下。Paxos,是解决共识问题的通用算法。它允许每个节点提出自己的提议(称为proposal),而Paxos算法能够不借助于任何中心化节点,保证各个节点之间对于提议最终达成一致。这里的proposal,是一个抽象的概念,它可以包含任何你想达成共识的数值。2PC和3PC,则是为了解决分布式事务提交问题的。
这样从表面看起来,Paxos和2PC、3PC,这两类算法似乎没有多少相似性。2PC和3PC是跟分布式事务强相关的,而Paxos跟分布式事务没有什么特别的关系。为了分析更深层次的本质,我们探究一下2PC和3PC产生的背景。
回到事务的概念。事务本来和分布式没什么直接关系的,就算在一个单节点的数据库上,要实现出事务的ACID特性,也不是那么容易的。只是,如同在前一章节的结尾我们提到的,在分布式环境下,事务的ACID特性更难实现。在前一章中我们主要关注ACID中的一致性,现在我们关注一下ACID中的原子性 (Atomicity)。
ACID中的原子性,要求事务的执行要么全部成功,要么全部失败,而不允许出现“部分成功”的情况。在分布式事务中,这要求参与事务的所有节点,要么全部执行Commit操作,要么全部执行Abort操作。换句话说,参与事务的所有节点,需要在“执行Commit还是Abort”这一点上达成一致(其实就是共识)。这个问题在学术界被称为原子提交问题(Atomic Commitment Problem)[2],而能够解决原子提交问题的算法,则被称为原子提交协议(Atomic Commitment Protocal,简称ACP)[3]。2PC和3PC,属于原子提交协议两种不同的具体实现。
分析到这里,我们似乎发现了原子提交问题与共识问题的关联性:
这个酷似「三段论」式的论述,看起来“合情合理”。实际上,学术界在很长一段时间内都认为,分布式事务的原子提交问题是拜占庭将军问题(Byzantine Generals Problem)的一个退化形式[4]。什么是拜占庭将军问题呢?简单来说,它也是分布式系统的一种共识问题,而且是容错性要求最高的一种共识问题。我们在这里不展开讨论拜占庭将军问题了,如果你对细节感兴趣,欢迎阅读我之前的另一篇文章“漫谈分布式系统、拜占庭将军问题与区块链”。总之,学术界以前的这种观点,跟我们刚刚分析得到的结论(原子提交问题是共识问题的一个特例)差不多。
但是,分布式系统的诡异之处就要体现在这里,一些细节的不同,可能导致非常大的差异。如果你仔细看前文的描述,会发现这样一个细节:当我们描述共识问题的时候,我们说的是在多个节点之间达成共识;而当我们描述原子提交问题的时候,我们说的是在所有节点之间达成共识。这个细微的差别,让这两类问题,几乎变成了完全不同的问题(谁也替代不了谁)。
从两类问题各自的应用场景来看,这个差异是合理的,也是容易理解的。以解决共识问题的Paxos协议为例,它只要求网络中的大部分节点达成共识就可以了,这样Paxos才能提供一定的容错性,只要网络中发生故障的节点不超过一半仍然能够正常工作(不会被阻塞)。然而,解决原子提交问题的2PC或3PC则不同。即使只有一个节点发生故障了,其它节点也不能擅自决策进行Commit操作。因为这样的话,这个事务就只是「部分地执行成功了」,违反了ACID原子性的要求。所以,原子提交协议必须保证在参与分布式事务的所有节点(包括故障的节点)上对于“执行Commit还是Abort”达成共识。
故障的节点可能什么都做不了,如何参与达成共识呢?这里的意思是,等故障节点恢复之后,它的决策(Commit或是Abort)必须与其它所有节点保持一致。那么,这是不是意味着,只要有节点发生故障,原子提交协议就一定会阻塞呢?这里有点让人奇怪,答案是「不一定」。根源就在于Abort和Commit并不是对等的决策。假设有一个节点宕机了,其它节点大可以选择Abort决策(注意不能选择Commit),从而让整个事务Abort掉(没有被阻塞住,等待宕机的节点恢复)。等宕机的那个节点恢复了,它会发现相应的事务已经执行Abort了,那么它也按照Abort处理就好了。在这个过程中,参与分布式事务的所有节点(包括宕机的这个节点)对于“执行Commit还是Abort”也是达成了共识的(这个共识是Abort)。正是这些细微却至关重要的细节,让2PC和3PC这种看似简单的协议实现起来没有那么容易。
论文[5]进一步澄清了这一问题,原子提交问题被抽象成一个新的一致性问题,称为uniform consensus问题,它是与通常的共识问题(consensus problem)不同的问题,而且是更难的问题。uniform consensus,要求所有节点(包括故障节点)都要达成共识;而consensus问题只关注没有发生故障的节点达成共识。
至此,我们总结一下本章节的结论:
CAP的三个字母分别代表了分布式系统的三个特性:一致性(Consistency)、可用性(Availability)和分区容错性(Partition-tolerance)。而CAP定理指出:任何一个分布式系统只能同时满足三个特性中的两个。但是,这一描述曾经引发了非常多的误解。
为了进一步展开讨论,我们先关注CAP中的C,也就是一致性。它是什么意思呢?在证明CAP定理的原始论文中[6],C指的是linearizable consistency,也就是「线性一致性」。更精简的英文表达则是linearizability。
这听起来可能稍微有点奇怪,但事实就是这样。线性一致性(linearizability)是CAP中的C的原始定义。而很多人在谈到CAP时,则会把这个C看成是强一致性(strong consistency)。这其实也没错,因为线性一致性的另一个名字,就是强一致性[1]。只不过,相比「线性一致性」来说,「强一致性」并不是一个好名字。因为,从这个名字你看不出来它真实的含义(到底「强」在哪?)。在下面的讨论中,我们统一使用线性一致性(linearizability)这个词汇。
那线性一致性是什么意思呢?它精确的形式化定义[7]非常抽象,且难以理解。大体上是说,在一个并发执行的环境中,不同的操作之间可能是有严格的先后关系的(一个操作执行结束之后另一个操作才开始执行),也可能是并发执行的(一个操作还没执行结束,另一个操作就开始执行了);如果能够把所有操作排列成一个「合法」的全局线性顺序,那么这些操作就是满足线性一致性的。当然,在这个重新排列的过程中,原来就存在的严格的先后关系,必须得以保持。
但是,怎么才算合法呢?我们具体到一个存储系统中通过例子来说明。假如我们先往某个数据对象中写入了一个值(假设是1),然后在写入操作结束之后,我们再把这个数据对象读出来(在写入和读取操作之间没有其它的写入操作了)。如果我们发现读取到的值是1,那么就是合法的;而如果读出来的值不是1,那么就是非法的。再假设,我们又执行了一次读取操作,发现读出来的值仍然是1,那么就是合法的;否则就是非法的。也就是说,如果一个读操作已经读到了某个值,那么下一个对于同一个数据对象的读操作就必须读取到同样的值(除非在两次读操作之间还存在别的写入操作)。
这些例子都比较容易理解,因为站在观察者的角度它们是符合逻辑的。因此,对于一个分布式存储系统来说,线性一致性的含义可以用一个具体的描述来取代:对于任何一个数据对象来说,系统表现得就像它只有一个副本一样[1]。“表现得像只有一个副本”,也就相当于满足了前面的「合法」条件。显然,如果系统对于每个数据对象真的只存一个副本,那么肯定是满足线性一致性的。但是单一副本不具有容错性,所以分布式存储系统一般都会对数据进行复制(replication),也就是保存多个副本。这时,在一个分布式多副本的存储系统中,要提供线性一致性的保证,就需要付出额外的成本了。
网上对于CAP的一致性的通俗解释,通常有两种:
我们前面提到,线性一致性,也被称为强一致性。之所以这么说,大概是因为线性一致性要求多个副本上的数据必须保持如此之「强」的一致性,以至于“让系统表现得就像只有一个副本”。
另外,网上的资料提到强一致性的时候,还有可能会关联到分布式事务上面,比如2PC/3PC这些原子提交协议。但把它们关联到一起的说法,其深层次含义到底是什么,只能靠猜测。分布式事务处理的并不是同一个数据对象的多个副本的问题,而指的是将针对多个数据对象的各种操作组合起来,提供ACID的特性。将分布式事务看成是强一致性的保证,猜测可能实际上指的就是ACID的原子性。总之,「强一致性」这个词很容易产生误解,所以建议谨慎使用。
在历史上,CAP定理具有巨大的知名度,但它实际的影响力却没有想象的那么大。随着分布式理论的发展,我们逐渐认识到,CAP并不是一个「大一统」的理论,远不能涵盖分布式系统设计中方方面面的问题。相反,CAP引发了很多误解和理解上的混乱(细节不讨论了)。因此,可以预见到,未来CAP定理的影响将会进一步被削弱。
本文我们辨析了分布式系统中的诸多被称为「一致性」的概念。但这个话题还没有完,我将在下一篇文章中跟大家继续讨论顺序一致性、线性一致性、最终一致性等概念。
(正文完)
其它精选文章: