分布式系统一致性问题与Raft算法

什么是分布式一致性问题

首先,什么是分布式系统一致性问题?分布式系统这个词应该不用多解释,简单地说就是由多个节点(一个节点即一台物理机器或一个进程),异步得通过网络通讯而组成的系统。

而一致性(Consistency),这个词在不同的场景下有不同的含义,比方说大家比较熟的应该是在关系型数据库中事务的一致性。但在分布式系统中,一致性的基本含义是对进行进行一个或多个操作指令之后,系统内的其他成员对这些操作指令的结果能够达成共识,也就是对结果的看法是一致的。

举个例子,比方说有多个客户端,这些客户端出于某种原因,都要对分布式系统中的变量v赋值,客户端A发起一个赋值请求修改v=a,客户端B也发起一个请求修改v=b,等等等。但最终在某一个时刻布式系统内都能够对v的值产生一个共识,比如最终大家都知道v=a。而不会说系统内不同节点对这个值有不同的看法。

要做到这一点很难吗?是的,很难。

注:一致性这个词其实包含挺广的,但这里特质共识这个意思,所以有时候也会用共识的说法,知道是一个意思就行了。

为什么分布式一致性问题很难

明白了什么是分布式一致性问题之后,我们再来讨论为什么分布式一致性会很难。

如果是在单机环境下,实现一致性是很容易做到的,基本上我们开发的程序都能够做到。但在分布式环境下,则难度放大了无数倍。因为在分布式环境下节点间的状态是通过网络通信进行传输的,而网络通信是不可靠的,无序的,注意这里的不可靠不是说tcp或udp的那个可靠,而实无法通过网络通信准确判断其他节点的状态。

比如A向B发送了一个请求,B没有回应。这个时候你没办法判断B是忙于处理其他任务而无法向A回复,还是因为B就真的挂掉了。

顺便说一点,分布式一致的问题往往还具有一定的欺骗性。

它具有一定欺骗性的原因在于分布式一致性的问题直观感受上往往比较简单,比如上面的A向B发送请求的问题,我们无论选择直接认为B挂掉,或者选择让A不断进行重试,看上去似乎都能解决这个问题。但随着而来的又会有新问题,比如A选择认为B挂掉而进行失败处理,那么系统继续无碍运行。但如果B只是因为系统任务繁忙,过一会恢复作业,A就因为自身的选择破坏了数据的一致性,因为在B断线期间系统就不一致了。这就又出现了新的问题。

总结起来,就是看似简单的问题,引入简单的解,往往又会出现新的问题。而后又继续在此基础上“打补丁”,而后又会出现新的问题,不断循环往复,一个简单的问题不断叠加,就变成了超级复杂棘手的问题。就像筑堤堵水,水不断涨,堤坝不断堆砌,最终到了一个谁也没法解决的境地。

说回刚刚的话题,按照刚刚的例子,其实可以引出另一个问题,那就是活性(liveness)和安全性(satefy)的取舍。

活性(liveness)与安全性(satefy)

活性与安全性,这个要怎么理解呢?

刚刚说到,当A向B发送请求,B没有及时回应。但这个时候,A是无法准确知道B真正的状态的(忙于其他任务还是真的挂掉了),也就是说我们是无法做到完全正确的错误检测。

这种时候按照上面的说法,有两种选择,

  1. 任务B依旧或者,无限重试,不断等待。
  2. 直接认为B挂掉了,进行错误处理。

选择1,破坏了系统的活性,因为在重试的时间内,系统是无法对外提供服务的,也就是短暂得失活了。

选2呢又破坏了安全性,因为如果B其实没有挂掉,而这时候重新启动一个节点负责原本B的工作,那么此时系统中就会有旧的B节点,和新的B节点。此时旧的节点就称之为僵尸节点(Zombie)。而如果在主从分布的系统,也就是一个leader多个follower的系统中,如果B刚好是leader,那么这种情况也被称之为脑裂。

可以发现,liveness和响应速度有关,而satefy又和系统的可用性相关,并且这两者是不可兼得的。

关于这个问题,上世纪Lynch发表的一篇论文《Impossibility of Distributed Consensus with One Faulty Process》,基本上已经阐述了这个定理,也就是FLP impossibility。

FLP impossibility

FLP impossibility说明了这样一件事,在分布式或者说异步环境下,即便只有一个进程(节点)失败,剩余的非失败的进程不可能达成一致性。

这个是分布式领域中的定理,简称就是FLP impossibility。

当然所有的定理似乎都不是那么好理解,FLP impossibility也是一样,光是结论听起来就非常拗口。证明起来那就更加抽象,甚至在论文中也没有通过例子来论证。因为如果要通过实例来论证,那么首先就得要先设计N多的分布式算法,然后再逐一证明这些算法是FLP impossibility。

其实通俗些的理解,那就是说分布式(异步)环境下,liveness和satefy是鱼与熊掌不可兼得。不可能做到100%的liveness同时又兼顾到satefy。想要100%的satefy,那么liveness又保证不了,这里面又好像有CAP的影子,不得不说道路都是相通的。

话说回来,既然FLP impossibility已经说死了,异步环境下,即便只有一个进程(节点)失败,剩余的非失败的进程不可能达成一致性,那么paxos和raft这些算法又是如何做到分布式异步环境下一致的呢?

柳暗花明

其实FLP impossibility已经为我们指明方向了。既然无法完全兼得,那自然要放松某方面的限制,satefy是不能放松的,那自然只能从liveness上下手了。

具体做法上,那就是给分布式系统加上一个时间限制,其实在前面介绍liveness和satefy的时候,应该就有人能想到了。既然不能一直等待也不能直接任务远端节点挂掉,那么折衷一下,在某个时间内不断重连,超过这个时间,则认为远端节点是挂掉就可以了。

而事实上也正是如此,如果你对zookeeper熟悉,那应该知道zookeeper在选举leader的时候是不提供服务的,这就是它丧失部分liveness的一个体现。另一个体现是,性能,因为要通过一个时间段来对远端节点状态进行确认,那自然性能会有所下降,这又是不可避免的。

而具体的raft算法,那就等到下一节再说吧。

总结:

  1. 分布式一致性指的其实就是分布式异步环境下,要让多个节点对系统状态的改变能够达成共识。
  2. 分布式系统一致性难,难在异步通信不可靠。由此衍生出了liveness和satefy取舍的问题以及僵尸节点问题,有了FLP impossibility定理。
  3. paxos/raft等算法通过一个安全时间段,可以在某种程度上实现分布式系统的一致性。

raft算法的起源与paxos算法

要说raft算法,那就不得不先说paxos算法。在raft算法问世之前,paxos算法可以说一直就是分布式一致性的代名词。但paxos有两个主要问题:

  1. paxos算法复杂且难以理解
  2. paxos算法难以直接应用工程化

针对第一点,尽管有很多人试图降低它的复杂程度,但依旧改变不了它难以理解的事实。而第二点就致命了,首先paxos算法能解决分布式系统的共识问题,这个是已经被证明了的。但要将paxos算法实际应用起来,往往需要修改其算法结构,但很可能修改后算法就变了模样,也就是说修改后的paxos算法与原先的paxos算法相比有存在缺陷的可能,并且难以证明。

raft算法就是为了解决paxos的缺陷而产生的。它更加易于理解,并且能够达到paxos算法的相同功能,也就是能够解决分布式系统的一致性问题。

那么下面介绍下raft的具体实现吧。

raft算法

raft的整体架构先从这张图看起,这是raft论文里面的图。
在这里插入图片描述
这张图揭示了客户端向服务器发送请求的流程,我们先简单介绍下图的内容,再分析下整个流程。左边的是客户端,右边的是服务端(分布式系统,即有N个节点)。客户端负责发送请求更改分布式系统中的状态(变量),服务端负责接收信息,并让系统中所有节点就某个状态达成共识。

服务端也就是分布式系统环境,由一个leader组成,多个follower构成。leader负责接收消息并同步给其他节点,以及负责解决节点信息不一致的问题,具体怎么解决后面会说。而follwer负责接收leader的消息以及当接收不到消息的时候,试图让自己成为leader。

那么整个流程大概是这样:

  1. 客户端发送请求给到服务端的leader(如果是追随者,重定向到leader)
  2. leader接收到信息,改变自身的状态,并将这一改变信息发送给全部的follower
  3. 当大多数follower节点将成功这个改变应用到自身的时候,leader确认了这次改变
  4. 将改变成功的消息返回给客户端

这就是大概的流程,当然其中省略了很多细节。那么现在,我们来看看具体的内部流程。

简单得说,raft通过一个选定一个系统唯一的领导者(其他节点为追随者),由它来负责管理各个节点(包括自己)的日志信息,来实现一致性。那么这样就有了两个问题,第一个是唯一的领导者选举,第二个是保证每个节点日志的一致性,我们逐个来说。

注意这里只会讲解大概的思路,尽量不去太深入到细节中去,还是那句话,对具体实现细节感兴趣的童鞋推荐看原论文。

领导者选举

先大概介绍下领导者选举的流程。首先,leader会周期性得发送心跳(非固定周期,就是说可能间隔10ms发心跳给a,15毫秒发心跳给b,再间隔8ms发给a),维系自己的地位。当某个追随者在时间内没接收到leader的心跳的时候,它就会知道leader挂了,这个时候追随者就会尝试推举自己成为领导者(成为候选人),并发送请求让其他追随者投票给自己。其他追随者通过某种规则判断该候选人有没有“资质”,有则投票给他,否则不投票。最终,当超过半数的追随者认同该候选人,那么它就成为了新的leader。

在这个过程中,主要面临的也是两个问题,

  • 如何让系统中只有唯一一个领导者,也就是如何处理僵尸节点(Zombie),或者说脑裂的问题
  • 当集群中没有领导者的时候,如果选出最合适的领导者,也就是追随者投票的时候如何进行资质认定

处理僵尸节点问题(Zombie)

处理这个问题,在raft算法中,引入了一个任期的概念。任期从0开始,随着leader的更迭逐渐递增,通过这个任期的概念,解决了多数分布式系统内部不一致的问题。

比如说一个leader挂掉或不可达的时候,会有一个追随者试图成为新leader,这时候它的任期会自动加1。还记得吗,竞选leader的节点需要发送请求给每一个追随者投票,这个请求信息中就包含新的任期,追随者接收信息对比发现请求里面的任期比自己的大,便会更新自己的任期。

这样一来,上面说到的僵尸节点问题就解决了,当旧的leader(僵尸leader)重新返回后,发送心跳给追随者,追随者发现心跳请求里面的任期比自己还低,便不再鸟它。旧的leader发现没人鸟自己,也就明白了自己已经不再是leader,所以就变成了追随者。

如果你足够细心,你会发现这个逻辑里面还隐藏着一个问题。要是某个任期在竞选leader的时候,没有获取到足够的选票,也就是系统内大多数节点不认可它该怎么办呀?很简单,这个任期就会变成一个空任期,会直接开始下一个任期的领导者选举。至于投票不投票的逻辑,这是我们接下来要说的。

选出最合适的领导者

我们在上面说了,每个节点会维持一个存放日志的list。其实这个list不止存放日志,它还存放了每条日志对应的任期。类似

[('状态5','任期0'),('状态10','任期0'),('状态14','任期1')]

这样的列表。

我们知道当追随者试图成为leader(也就是候选人)的时候,会广播投票请求,请求里面就包含了竞选者日志list中最后一条日志的索引,以及对应的任期。每个进行投票的追随者会与自身的日志list比较,如果索引比自身list的最后索引小,那么说明候选人的日志没自己的新,这时候追随者会拒绝投票。

这样的特性能够保证系统尽可能得选出日志最新的节点,为什么说尽可能?因为依旧存在可能丢失状态。比如leader接收到请求,还没发给其他节点,或只发给少数节点。那么没来得及完成处理的这些请求就可能丢失,但即便这样,系统在最终依旧会能维持一致,只是并非最新更改的状态值,或者说有部分数据任然会丢失。

维持日志一致性

首先,所有的状态改变(可以理解为变量值改变)都是由leader执行,然后将这一改变辐射到所有的追随者。每个节点都维持一个存日志的list,用以存储每一条改变状态的信息。

leader接收客户端的操作指令后,先追加这一指令到自己的日志list,然后会将指令发送给追随者,追随者主要目的就是接收这些指令,并追加到自己日志list中。leader负责管理和检测追随者的日志list是否和自己的一致,由此实现整个分布式系统的一致性。

在这个过程,主要也是有几个问题需要解决:

  • 如何在无序的网络中控制追随者日志的一致
  • 当追随者出现僵尸节点,在僵尸节点恢复正常的时候,如何确保日志和leader变得一致

日志一致的保证
在介绍日志一致性前,需要明白这样两个事实:

如果在不同的节点的日志list中,两个条目拥有相同的索引和任期号,那么他们存储了相同的指令。
如果在不同的节点的日志list中,两个条目拥有相同的索引和任期号,那么他们之前的所有日志条目也全部相同。

也就是说,两个节点各自有一个list保存日志,如果两个节点各自的list中的某个索引相同点,任期也相同,那么说明它前面的内容都是一致的。

解决日志一致性这个问题其实不难,就是当leader将客户端的指令辐射出去的时候,要等到所有追随者状态确认后再执行下一条客户端指令,典型的用效率换取准确性。这里的状态确认只有三种,成功,失败和连接不上客户端。注意失败和连接不上是两种情况,失败有可能是因为某些日志数据不一致,这时就需要找到追随者日志list中与一致的那个点,然后覆盖掉后面不一致的数据。最极端的情况就是发现追随者全部都和leader的日志list不一致,那么就会将leader的list全部覆盖追随者的日志list,这种做法也叫做强制复制

而连接不上则可能是因为追随者正忙,或者它真的挂掉了。上一篇讲过,要在异步的网络通信中真正识别这两种情况几乎是不可能的。这时候,我们可以直接认为它挂掉了,如果发现其实没挂,那么直接按照僵尸节点的逻辑来处理。

处理僵尸节点

当发现追随者节点不可达的时候,会将它标记为僵尸节点(论文里是说无限重试,但实际操作发现无限重试存在一些问题),等待该追随者重启。这样一来,当僵尸节点重新恢复时最大的问题其实就是它的任期和日志list远落后于leader。

解决这些问题的方法其实上面都讲到了,每次leader请求,追随者会比较任期,发现自己任期低会自动更新。如果是日志list,那么会找到与leader日志list一致的那个索引,然后覆盖后面的全部list。

最后再来结合具体的例子看看吧。
在这里插入图片描述
灰色方框之上的是一个刚担任leader的节点。a,b,c,到f是不同的几个follower节点,每个节点后面的那行代表日志list,里面的数字表示任期。

当一个leader当选的时候,a,b,…到f基本涵盖了可能的情况。a和b表示追随者缺少部分日志,那么这时候追加就行。c和d表示r日志过多,那么要删除索引11以及之后的日志,让日志和leader保持一致。e和f表明r日志list有不一致的内容,即某些地方任期不一致,这时候e会找到索引相同的最后一个节点(这里是5),覆盖后面的内容,f也是同样的道理。

OK,以上就是raft算法维持分布式系统一致性的基本思路,当然还有一些额外的内容,比如防止日志list过长而可以采用的checkpoint技术,以及客户端实现exctly once语义的方法,这些比较细节的内容就不多说了。这篇文章的目的还是希望起到抛砖引玉的作用而已。

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 编程工作室 设计师:CSDN官方博客 返回首页