其他分享
首页 > 其他分享> > 一致性与共识

一致性与共识

作者:互联网

数据密集型应用设计读书笔记第九章

分布式系统最重要的抽象之一就是共识(consensus)就是让所有的节点对某件事达成一致。 

一致性保证

分布式系统中,有很多场景都需要一致性保证。例如选举,如果同时存在两个节点认为自己是主节点,那就是“脑裂”。“复制延迟”中也提到,一个写入请求不可能同时到达两个节点,这时候一般讲究最终一致性(比较弱的一致性保证,但是性能好)。

要注意前面提到的”事务隔离“与现在要讨论的”分布式一致性“的区别,虽然可能有相似之处。

事务隔离主要是为了,避免由于同时执行事务而导致的竞争状态,而分布式一致性主要关于,面对延迟和故障时,如何协调副本间的状态。

这章的内容如下:

 

线性一致性

线性一致性(linearizability)*背后的想法【6】(也称为*原子一致性(atomic consistency)【7】,强一致性(strong consistency)立即一致性(immediate consistency)*或*外部一致性(external consistency )【8】)。线性一致性的精确定义相当微妙,我们将在本节的剩余部分探讨它。但是基本的想法是让一个系统看起来好像只有一个数据副本,而且所有的操作都是原子性的。有了这个保证,即使实际中可能有多个副本,应用也不需要担心它们。

实际的系统中,因为每个请求存在延迟和处理速度的差距(这里不考虑事务,请求分为发送->到达->处理->返回响应),线性一致性并不强制要求先发送的就一定先于后发送的。而是说当某个请求在处理时已经可以读到新的值时,其它的稍后处理的请求在处理时应该都已经能看到这个值。

线性一致性和可序列化是不同的概念:

可序列化(Serializability)*是事务的隔离属性,每个事务可以读写多个对象(行,文档,记录)——参阅“单对象和多对象操作”。它确保事务的行为,与它们按照*某种顺序依次执行的结果相同(每个事务在下一个事务开始之前运行完成)。这种执行顺序可以与事务实际执行的顺序不同。【12】。

线性一致性(Linearizability)*是读取和写入寄存器(单个对象)的*新鲜度保证。它不会将操作组合为事务,因此它也不会阻止写偏差等问题(参阅“写偏差和幻读”),除非采取其他措施(例如物化冲突)。

2PL和实际串行执行这两种实现基本是满足线性一致性的,而可序列化快照隔离就不满足线性一致性(因为读是基于快照的,有违线性一致性的新鲜度保证)

 

那么,哪些应用环境是必须要线性一致性的呢?

  1. 锁定和领导选举。分布式锁,需要多个节点对哪个节点持有锁达成一致,选举也是类似的情况。选举的一种实现方式就是分布式锁。诸如Apache ZooKeeper 【15】和etcd 【16】之类的协调服务通常用于实现分布式锁和领导者选举。它们使用一致性算法,以容错的方式实现线性一致的操作

  2. 约束和唯一性保证。数据库的唯一性约束,就需要线性一致性。

  3. 跨信道的时序依赖。举个例子,如果使用了一个分布式存储的数据库,并且不保证线性一致性,并且应用还使用了消息队列,那么存在竞争条件的风险:消息队列可能比存储服务内部的复制更快。(注意, 线性一致性并不是避免这种竞争条件的唯一方法,但它是最容易理解的。使用”读己之写“里的方案就可以解决这个问题)

 

那么,如何实现”线性一致“的系统呢?

  1. 单主复制和无主复制,都是可能实现线性一致的。

  2. 共识算法是能够实现线性一致的(基于容错)

  3. 多主复制是无法达成线性一致的。

一个直觉是认为实现了法定人数,肯定也实现了线性一致性,但是当我们有可变的网络延迟时,就可能存在竞争条件。例如n=3,w=3,r=2,一般认为肯定符合法定人数了,但是写3个副本时,可能先写了副本1,副本2和3暂时还没写入(延迟)。这时候这个写的动作肯定还没认为是成功的,但是这时候去读两个副本的话。一个请求读副本1和2会读到新值,紧接着下一个请求读副本2和3则还是读到旧值,此时不是线性一致的。因为后面的请求反而没读到新的值。(这是可以修复的,但是会损耗性能。例如读请求必须同步执行读修复后才能返回)

 

CAP定理

一致性(线性一致性),可用性(只要请求的客户端不崩溃,最终会得到响应),分区容忍性(网络分区,即网络故障)

网络故障没得选,所以只能选要一致性或者可用性。是个很显然的结论。

在网络正常工作的时候,系统可以提供一致性(线性一致性)和整体可用性。发生网络故障时,你必须在线性一致性和整体可用性之间做出选择。因此,CAP更好的表述成:在分区时要么选择一致,要么选择可用【39】。一个更可靠的网络需要减少这个选择,但是在某些时候选择是不可避免的。
CAP定理的正式定义仅限于很狭隘的范围【30】,它只考虑了一个一致性模型(即线性一致性)和一种故障(网络分区[^vi],或活跃但彼此断开的节点)。它没有讨论任何关于网络延迟,死亡节点或其他权衡的事。 因此,尽管CAP在历史上有一些影响力,但对于设计系统而言并没有实际价值【9,40】。
总结:CAP过时了,不必纠结

虽然按照CAP的意思,牺牲一致性是为了可用性。单实际上,不使用线性一致性主要是出于性能的考虑(太慢了,慢的难以忍受。如果性能还不如单机系统,为何还要用分布式),而不是容错。更快的线性一致算法不存在,但更弱的一致性模型可以快得多,所以对延迟敏感的系统而言,这类权衡非常重要。

 

顺序保证

事实证明,顺序,线性一致性和共识之间有着深刻的联系。

顺序反复出现有几个原因,其中一个原因是,它有助于保持因果关系(causality)(什么是因果关系,例如B建立在A的基础上,B可见A,但A不应该见到B)

如果一个系统服从因果关系所规定的顺序,我们说它是因果一致(causally consistent)

基于因果的顺序保证是偏序的,而线性一致性是全序的(如果系统表现的就好像只有一个数据副本,并且所有操作都是原子性的,这意味着对任何两个操作,我们总是能判定哪个操作先发生)。而这也意味着,线性一致的数据存储中不存在真正的并发操作。因为并发通常要求两个操作没有因果关系。

所以线性一致性是隐含保证了因果一致性。也就是说,可以实现因果一致性而并非需要线性一致性。性能也能得到一定保证。(研究人员正在探索新型的数据库,既能保证因果一致性,且性能与可用性与最终一致的系统类似【49,50,51】)

那么,怎么确定因果顺序呢,跟踪所有因果依赖很费时,一个好的办法就是给予自增序号或者时间戳(逻辑时钟)来排序事件。(缺点是这比因果关系更严格,是一个全序关系了)

如果是单主复制,那么序号可以都有主库赋予。

如果不是单主复制,怎么赋予一个包含因果关系一致的序号呢? 兰伯特时间戳,一个逻辑时钟算法。算法如下:

  1. 每个节点和每个客户端跟踪迄今为止所见到的最大计数器值,并在每个请求中包含这个最大计数器值。

  2. 当一个节点收到最大计数器值大于自身计数器值的请求或响应时,它立即将自己的计数器设置为这个最大值。并给予下一个发送出去的操作更大的计数器值。

每个节点都有一个唯一标识符,和一个保存自己执行操作数量的计数器。时间戳就是两者的简单组合:(计数器,节点ID),当计数器一样大的时候,就比较节点ID,这样每个时间戳就都是可比较的了。

只要每一个操作都携带着最大计数器值,这个方案确保兰伯特时间戳的排序与因果一致,因为每个因果依赖都会导致时间戳增长。缺点是,这个算法并不能分辨哪些操作可以并发或者因果依赖。

不足:如果某些唯一性业务有实时的要求,那么上面这个时间戳算法无法保证其他节点是不是在拿着更小的时间戳在做着同样的事情(你只能在事后发现这是冲突的并允许其中一者胜利)。

总之:为了实诸如如用户名上的唯一约束这种东西,仅有操作的全序是不够的,你还需要知道这个全序何时会尘埃落定。如果你有一个创建用户名的操作,并且确定在全序中,没有任何其他节点可以在你的操作之前插入对同一用户名的声称,那么你就可以安全地宣告操作执行成功。

全序广播

怎么确定全序的尘埃落定,或者是单主复制中主库如何故障切换。这就需要全序广播,也叫原子广播

全序广播通常被描述为在节点间交换消息的协议。 非正式地讲,它要满足两个安全属性:

可靠交付(reliable delivery)

没有消息丢失:如果消息被传递到一个节点,它将被传递到所有节点。

全序交付(totally ordered delivery)

消息以相同的顺序传递给每个节点。

有很多地方可以用到全序广播

  1. 主库到从库的复制,即状态机复制(state machine replication)

  2. 实现分布式的可序列化的事务,即每个节点都按一样的顺序执行事务(虽然这很粗糙,感觉没什么用)

  3. 顺序在消息送达时被固化:如果后续的消息已经送达,节点就不允许追溯地将(先前)消息插入顺序中的较早位置。这个事实使得全序广播比时间戳命令更强。(解决了上一小节的问题)

  4. 也可以用作防护令牌。每个获取锁的请求都作为一条消息追加到日志末尾,并且所有的消息都按它们在日志中出现的顺序依次编号。序列号可以当成防护令牌用,因为它是单调递增的。

另外,有以下结论,不再深入了解了

如果线性一致存储的原子操作包括CAS或”自增并返回“,那么它和全序广播都一样,等价于共识问题。

 

分布式事务与共识

共识,非正式地讲,目标只是让几个节点达成一致(get serveral nodes to agree on something)

应用场景有:领导选举,原子提交(必须让所有节点对事务的结果达成一致:要么全部中止/回滚(如果出现任何错误),要么它们全部提交(如果没有出错)。)

共识的不可能性:FLP定理,它证明,如果存在节点可能崩溃的风险,则不存在总是能够达成共识的算法。但是它只在异步网络模型下得到证明(意味着不能使用时钟或超时机制,这种情况下得不到确定解法)。也就是说,如果能够使用超时,甚至是使用随机数,都能绕开这个定理的限制。(FLP不可能原理 - 有梦就能实现 - 博客园 (cnblogs.com)

原子提交的形式化与共识稍有不同:原子事务只有在所有参与者投票提交的情况下才能提交,如果有任何参与者需要中止,则必须中止。 共识则允许就任意一个被参与者提出的候选值达成一致。 ( 然而,原子提交和共识可以相互简化为对方【70,71】。 非阻塞原子提交则要比共识更为困难)

所以接下来首先了解2PC,然后知道其不足,了解更好的一致性算法。比如ZooKeeper(Zab)和etcd(Raft)中使用的算法。

那么如何实现原子提交?首先要明白事务一旦提交了就没有撤销的操作,因为基于”读已提交“,提交的事务对其它事务就可见了。那么原子性提交的主要目的就是让节点尽量作出承诺不会撤销操作。当收集到所有节点这样的承诺之后,就可以让操作成功了。

2PC

最经典的算法就是2PC,两阶段提交的关键”不归“点有两个,当参与者投票“是”时,它承诺它稍后肯定能够提交(尽管协调者可能仍然选择放弃)。一旦协调者做出决定,这一决定是不可撤销的。这些承诺保证了2PC的原子性。 (单节点原子提交将这两个事件混为一谈:将提交记录写入事务日志。)

2PC的缺陷:如果协调者在第二阶段发生崩溃,参与者是应该放弃还是继续提交?唯一能做的就是等待协调者恢复,不然容易导致参与者们不一致。或者是参与者在第二阶段崩溃了呢?但是这时候没崩溃的参与者可能已经得到了正式提交的通知,提交后再撤销会影响很大。

3PC

两阶段提交被称为阻塞(blocking)原子提交协议,因为存在2PC可能卡住并等待协调者恢复的情况。理论上,可以使一个原子提交协议变为非阻塞(nonblocking)的,以便在节点失败时不会卡住。但是让这个协议能在实践中工作并没有那么简单。

3PC在2PC的基础上提出,三阶段提交就有 CanCommit(事务询问)、PreCommit(事务执行)、DoCommit(事务提交)三个阶段。但是仍旧不能解决2PC的根本问题(单点故障导致的不一致问题或者阻塞问题)

通常,非阻塞原子提交需要一个完美的故障检测器(perfect failure detector)【67,71】—— 即一个可靠的机制来判断一个节点是否已经崩溃。在具有无限延迟的网络中,超时并不是一种可靠的故障检测机制,因为即使没有节点崩溃,请求也可能由于网络问题而超时。出于这个原因,2PC仍然被使用,尽管大家都清楚可能存在协调者故障的问题。

实践中的分布式事务

分布式事务的某些实现会带来严重的性能损失。两阶段提交所固有的性能成本,大部分是由于崩溃恢复所需的额外强制刷盘(fsync)【88】以及额外的网络往返。

分布式事务的分类:

  1. 数据库内部的分布式事务。在这种情况下,所有参与事务的节点都运行相同的数据库软件。

  2. 异构分布式事务。在异构(heterogeneous)事务中,参与者是两种或以上不同技术:例如来自不同供应商的两个数据库,甚至是非数据库系统(如消息代理)。跨系统的分布式事务必须确保原子提交,尽管系统可能完全不同。

想要支持异构的分布式事务,所有受事务影响的系统都使用同样的原子提交协议(atomic commit protocl)。例如发送邮件服务器,它的执行事务就是成功发送了一个邮件,那在第二阶段的撤销就似乎没有什么用了。即需要保证中止会抛弃部分完成事务所导致的任何副作用。

 

X/Open XA扩展架构(eXtended Architecture)的缩写)是跨异构技术实现两阶段提交的标准【76,77】。它于1991年推出并得到了广泛的实现:许多传统关系数据库(包括PostgreSQL,MySQL,DB2,SQL Server和Oracle)和消息代理(包括ActiveMQ,HornetQ,MSMQ和IBM MQ) 都支持XA。

XA不是一个网络协议——它只是一个用来与事务协调者连接的API。这个API由协调者实现, XA假定你的应用使用网络驱动或客户端库来与参与者进行通信(数据库或消息服务)。应用调用XA API,连接到协调者(这个协调者往往是本地的),查明操作是不是分布式事务的一部分,是的话,则联系到其它参与者。而实际实现中,这个协调者往往是一个库,由应用自己加载到本地的进程中运行。所以如果应用崩溃,其它参与者也只能被动等待。数据库服务器(参与者)不能直接联系协调者,因为所有通信都必须通过客户端库。

如果涉及到锁,某些协调者崩溃会导致参与者被动地一直持有锁,阻碍了其它事务。理论上来说,协调者恢复后能够完美地检查出哪些事务受到了影响,并继续崩溃前的工作。但现实仍旧可能出错。许多XA的实现都有一个叫做启发式决策(heuristic decistions)*的紧急逃生舱口:允许参与者单方面决定放弃或提交一个存疑事务,而无需协调者做出最终决定【76,77,91】。*

总之,XA事务即使解决了保持多个参与者(数据系统)相互一致的现实的重要问题,但正如我们所看到的那样,它也引入了严重的运维问题。特别来讲,这里的核心认识是:事务协调者本身就是一种数据库(存储了事务的结果),因此需要像其他重要数据库一样小心地打交道:

  1. 协调者没有复制,单点失效问题

  2. 服务端应用许多都是无状态模式开发的,持久性数据存储在专门的数据服务器中,所以才能令应用服务器随意增加或删除。但是成为协调者就不能这样做了。

  3. 要兼容各种系统的机制,例如每个系统的锁机制可能不同,为了协调它们,仍旧需要一些其它努力。

  4. 于数据库内部的分布式事务(不是XA),限制没有这么大,例如,分布式版本的SSI 是可能的。然而仍然存在问题:2PC成功提交一个事务需要所有参与者的响应。因此,如果系统的任何部分损坏,事务也会失败。因此,分布式事务又有扩大失效(amplifying failures)的趋势,这又与我们构建容错系统的目标背道而驰。

 

容错共识

共识问题通常形式化如下:一个或多个节点可以提议(propose)*某些值,而共识算法*决定(decides)*采用其中的某个值。在座位预订的例子中,当几个顾客同时试图订购最后一个座位时,处理顾客请求的每个节点可以*提议正在服务的顾客的ID,而决定指明了哪个顾客获得了座位。

此时共识算法必须保证以下几个性质:

一致同意(Uniform agreement)

没有两个节点的决定不同。

完整性(Integrity)

没有节点决定两次。

有效性(Validity)

如果一个节点决定了值 v ,则 v是由某个节点所提议的。

终止(Termination) 由所有未崩溃的节点来最终决定值。

一致同意完整性属性定义了共识的核心思想:所有人都决定了相同的结果,一旦决定了,你就不能改变主意。

有效性属性主要是为了排除平凡的解决方案:例如,无论提议了什么值,你都可以有一个始终决定值为null的算法。但这显然就不符合有效性了。

终止属性正式形成了容错的思想。它实质上说的是,一个共识算法不能简单地永远闲坐着等死 —— 换句话说,它必须取得进展。即使部分节点出现故障,其他节点也必须达成一项决定。 (终止是一种活性属性,而另外三种是安全属性 ,前面章节有解释过)

终止属性则基于一个容错的上限,例如崩溃容错是二分之一,拜占庭容错是三分之一。

 

共识算法和全序广播

最著名的容错共识算法是视图戳复制(VSR, viewstamped replication)【94,95】,Paxos 【96,97,98,99】,Raft 【22,100,101】以及 Zab 【15,21,102】。

大多数这些算法实际上并不直接使用这里描述的形式化模型(提议与决定单个值,一致同意,完整性,有效性和终止属性)。取而代之的是,它们决定了值的顺序(sequence),这使它们成为全序广播算法

所以,全序广播相当于重复进行多轮共识(每次共识决定与一次消息传递相对应):

视图戳复制,Raft和Zab直接实现了全序广播,因为这样做比重复一次一值(one value a time)的共识更高效。在Paxos的情况下,这种优化被称为Multi-Paxos。

时代编号和法定人数

怎么确定一个领导者是独一无二的呢?一个普遍的做法是,协议定义了一个时代编号(epoch number)(在Paxos中称为投票编号(ballot number),视图戳复制中的视图编号(view number),以及Raft中的任期号码(term number)),算法保证在一个时代编号中领导者是独一无二的。

每次选举,会赋予这次选举递增的时代序号。如果有两个领导,那么有更高时代编号的领导者是真的。

领导者如何知道自己没有被另一个节点赶下台?在领导者想要整个分布式系统的节点做某种操作时,需要搜集法定人数的选票(通常为大多数),在这个搜集的过程中,就可以从其它节点那里意识到自己是不是最新的领导。只有在没有意识到任何带有更高时代编号的领导者的情况下,一个节点才会投票赞成提议。

因此,一般共识算法有两轮投票,选举,和对领导者的提议表决。关键点是,参与选举的节点和参与表决的节点之间必须有重叠(通过大多数投票,这一点可以保证),因为只有这样,领导者才能在和其它节点的沟通中意识到自己是拥有最大时代编号的。因此可以确定自己仍然在领导。然后它就可以安全地对提议值做出决定。

 

共识的局限性

  1. 对提议投票是一个同步复制的过程,但是很多数据库配置为异步复制,那么在故障切换的时候可能会损失部分数据,但是这样性能比较好。

  2. 节点门槛限制,如果发生网络分区,只有拥有大多数节点的分区才能正常工作,其它的则阻塞。

  3. 共识系统通常依靠超时来检测失效的节点。超时时间如何设置缺点的讨论,以前说过了。

  4. 有时共识算法对网络问题特别敏感。例如Raft已被证明存在让人不悦的极端情况【106】:如果整个网络工作正常,但只有一条特定的网络连接一直不可靠,Raft可能会进入领导者在两个节点间频繁切换的局面,或者当前领导者不断被迫辞职以致系统实质上毫无进展。其他一致性算法也存在类似的问题,而设计能健壮应对不可靠网络的算法仍然是一个开放的研究问题。

成员与协调服务

像ZooKeeper或etcd这样的项目通常被描述为“分布式键值存储”或“协调与配置服务”。

它们的API一般被用来提供分布式的全序广播容错服务。你可以存入键值数据,而这些数据会通过全序广播发送给各个副本节点。它还实现了其它一些有用的功能,一般被用到以下这些场合。

  1. 线性一致的原子操作,使用原子CAS操作可以实现锁:如果多个节点同时尝试执行相同的操作,只有一个节点会成功。共识协议保证了操作的原子性和线性一致性。即使节点发生故障或网络在任意时刻中断,分布式锁通常以租约(lease)的形式实现,租约有一个到期时间,以便在客户端失效的情况下最终能被释放。(虽然书中没有说明,但我来猜测下实现锁的做法吧,首先用CAS来确定这个对象没有被人持有,所以CAS可以设置这个对象被某个节点锁定了。然后下一个CAS原子操作就用来设置到期时间,也就是如果确实前面操作成功,就设置到期时间。”锁持有者“和”到期时间“,这两者的写入是线性一致的原子操作,那么分布式锁基本就没太大问题了)

  2. 操作的全序排序。当某个资源受到锁或租约的保护时,你需要一个防护令牌来防止客户端在进程暂停的情况下彼此冲突。防护令牌是每次锁被获取时单调增加的数字。这个单调增加,就是全序广播来提供啦。

  3. 失效检测。zookeeper的每个节点之间互相通过定时的心跳信号来确实是否活着。当会话超时(ZooKeeper调用这些临时节点)时,会话持有的任何锁都可以配置为自动释放(ZooKeeper称之为临时节点(ephemeral nodes))。

  4. 变更通知,客户端不仅可以读取其他客户端创建的锁和值,还可以监听它们的变更。因此,客户端可以知道另一个客户端何时加入集群(基于新客户端写入ZooKeeper的值),或发生故障(因其会话超时,而其临时节点消失)。

  5. 将工作分配给节点。因为节点可能动态地加入和故障。所以,精心使用原子操作,临时节点与通知有望解决这个问题。但是在太多节点上投票很低效,一般ZooKeeper在固定数量的节点(通常是三到五个)上运行,并在这些节点之间执行其多数票,同时支持潜在的大量客户端。因此,ZooKeeper提供了一种将协调节点(共识,操作排序和故障检测)的一些工作“外包”到外部服务的方式。

  6. 服务发现。因为服务地节点可能在动态变化。使用zookeeper可以维护多个只读功能副本节点。在这些节点上读到哪些节点可以提供服务。

  7. 成员服务。 成员资格服务确定哪些节点当前处于活动状态并且是群集的活动成员。正如我们在第8章中看到的那样,由于无限的网络延迟,无法可靠地检测到另一个节点是否发生故障。但是,如果你通过一致的方式进行故障检测,那么节点可以就哪些节点应该被认为是存在或不存在达成一致。

标签:事务,线性,全序,共识,一致性,节点,分布式
来源: https://www.cnblogs.com/dongjl/p/15914396.html