Raft - 分布式一致算法浅析

289 阅读14分钟

解决的问题

现有的(Multi-)Paxos算法一方面是理解起来很困难,另一方面是很难在工程中实现,所以斯坦福大学的一个团队提出了Raft算法。Raft算法在保留Paxos优点的同时,提供一个更加直观、易于理解和实现的一致性算法。Raft通过简化的设计和明确的机制,帮助开发人员更好地构建和维护分布式系统,确保系统的一致性和可靠性。

概念

状态

Raft中,称leader,follower,candidate为state(状态)。

  • Leader leader服务器负责处理来自客户端的所有请求,集群中仅有一个leader
  • Follower follower服务器不处理客户端的请求,收到来自客户端的请求也只会转发给leader,但是会处理来自candidate服务器和leader服务器的请求,集群中有多个follower
  • Candidate candidate是一种特殊的过渡状态,当其一段时间内(election timeout)内未收到来自leader的心跳请求后,就会由follower变成candidate,去发起选举。

Term(任期)

在Raft一致性算法中,term(任期)是一个重要的概念,其实际上是一组连续的整数,用于管理leader选举和日志复制过程。Term是指leader任职的周期。每当发起一个新选举时,candidate都需要将当前leader的term加上1,然后发送给别的follower,当选举成功后,这个新的term就将是新leader的任期。

选举

对于每一个follower,如果在election timeout时间内都没有收到来自leader的心跳请求,就会发起选举流程,为了避免多个follower同时发起选举导致所有candidate都没法得到大多数票,Raft对每一个follower的election timeout时间都做了随机化处理,这样可以最大限度的避免了上述所说的情况。

此外,每一个leader的term内,每一个server只能投票给一个candidate(first-come-first-served,先收到哪个candidate的投票请求先给谁投票),这样就能保证只能有一个candidate获取大多数投票并成为leader。因此并不存在一个candidate给另一个candidate投票的情况,因为每个server在一个leader的任期内只能投票给一个candidate,而每个candidate在发起投票请求之前已经把票投给了自己。

下面这些选举流程以经典的5个服务器的集群作为示例。

初次选举

只有一个follower发起选举

这种情况相对简单很多,当集群中sever启动后,由于没有任何一个leader发起心跳请求,所以第一个到达election timeout的follower会转变成candidate(下图黄色的服务器表示进入了candidate状态,绿色表示已经进入了leader状态),先给自己投票,接着会给其他每一个服务器发送一个RequestVote RPC请求,正式发起选举流程。

Raft - 初次选举.drawio.png

当candidate获得2个及以上的投票时(加上自投的那一票),就会转变成leader。

当发起选举时突然收到一个来自leader的心跳请求

这种情况可能发生于以下两种原因:

  1. candidate与leader的通讯中断,但实际上leader还是正常运转的
  2. 同时有两个candidate发起选举(虽然引入了随机的election timeout,但是还有种情况出现,只是概率较小),此时一个candidate已经获取到大多数follower的投票成为了leader,开始向其他follower发起心跳请求

此时如果心跳请求中携带的term比此candidate的currentTerm要小,则直接无视,继续进行选举过程,否则会停止选举,并转变回follower。

日志复制

日志的组织方式

截屏2024-11-22 17.09.40.png (图片来源于Raft论文-In Search of an Understandable Consensus Algorithm (Extended Version))

Log entry按上图方式组织,最上面的数字是log的索引,每个方格表示一个log entry,不同颜色表示不同的term,方格中上方的数字表示term,下方的表示client发送过来的请求。

日志的处理

当leader选举完毕后,并开始处理client发送过来的请求,请求中包含着最终需要server本地的复制状态机(Replicated State Machines执行的命令, leader会把每个命令添加到其日志的尾部,然后将这个日志项通过AppendEntries RPCs 发送到follower服务器,当成功复制到大多数服务器上时,leader便会将这个日志项commit(这里包含一些细节,决定何时才能真正地commit,而不仅仅是看有没有被复制到大多数服务器上,下面会进行自己讲解),此时本地的复制状态机便会执行该日志项包含的命令(如图中某个方格中的x<-3)。同时返回给client处理结果,要注意的是,随后leader还是会继续给剩下的followed发送复制日志项的请求,直到所有的follower都在本地拥有这个日志项为止。

log replication - .drawio.png (图片示例参考自Raft论文-In Search of an Understandable Consensus Algorithm (Extended Version))

假设当前的状态如上图1-1所示,此时term为2,作为leader的server1 刚刚把index为2的日志项复制到server2中就宕机了,此时开始发起选举,最终server5以term=3获得了来自它自己以及s3,s4的选票(由于这两个server的日志并没有比server5更新,所以可能成功获取它们的选票,而日志更新的server2可能此时还没有超过election timeout,还没来得及发起选票)。

如图1-2,新的leader server5在开始同步日志给其他的server前,收到了一个client请求,并将其添加到了自己的日志中,随后就又宕机了。

如图1-3,此时,宕机许久的server1重启了,这个时候它以term=4发起了选票,理所当然地获取了大多数follower的投票,重新变成了leader,此时开始向其他follower复制日志项,此时server1的state如下所示:

属性
currentTerm4
votedFor1
nextIndex[][{server2: 3(lastLogIndex)+1=4},{server3: 4},{server4: 4},{server5: 4}]
currentTerm4

所以,对于server3来说,当作为leader的server1向其发送AppendEntries RPCs请求时,会以nextIndex(server3):4-1=3作为prevLogIndex,同时prevLogTerm则为4,server3上显然没有对应的日志项,所以这个RPC请求会失败,此时leader会不断减少prevLogIndex以在server3上找到一个匹配的日志项,最终当prevLogIndex为1时找到了对应的匹配项(如图所示,即为图1-2中server3的第一个日志项),此时leader会把自己prevLogIndex后面的日志项发送给server3,如图中1-3的leader的index为2,3的日志项。

假设1-3中,leader刚把index为2的日志项复制给sever3后就宕机了,此时server5通过选举成为了leader(它的日志比server2-4都要新,因为其最后一个日志项的term为3,且index为2,不比server2-4的要小),这个时候通过跟上述server1发送AppendEntries RPCs请求给server3复制日志项类似的过程,server5会使用其index为2的日志项将其他server的索引为1以后的日志项都给覆盖掉(因为此时只能匹配到server2-4 index为1的日志项,prevLogIndex为1)。

这个时候就引出了一个问题,如果在1-3中,由于server1索引为2的日志项已经复制到了大多数follower中(server1-3)都含有此项,这个时候server1将此日志项commit了,那么在1-4中无疑会被直接覆盖掉,那么就违背了一致性算法的设计原则-已经commit的条目丢失了。

针对此种情况,raft要求每个leader只当自己当前term创建的日志项被复制到大多数follower中时才会去commit这些日志项,同时也会commit来自之前term创建的日志项(这里则是指图1-3中索引为2的日志项,它们都是term为2创建的)。基于此种原则,图1-3时,此时作为leader的server1便不会commitindex为2的日志项,而是必须等到index为3的日志项复制到大多数follower时才会去commit。如图1-5,此时server1的index为3的日志项已经复制到大多数follower了,这个时候再去commit,commit完之后server1宕机了,server5无法通过选举成为leader了,因为它的最后一个日志项显然没有别的follower的日志项新(此时server1 index为3的日志项已经复制到大多数follower上去了,而选举需要获得大多数follower的投票,其中必然有一个follower包含server1的index为3的日志项,而其term为4,server5的最后一个日志项的term为3,显然无法获取这个follower的投票,这跟server5能获得大多数follower的投票相矛盾,因为server5无法再成为leader,从而复制已经被commit的server1中index为2的日志项了)。

关于日志复制的几个原则:

  1. 日志只会从leader复制到follower,即流向是单向的;
  2. leader从不会在自己的任期内修改或者删除自己的日志,只会在末尾添加。

集群成员变更

上述讨论的场景中都是基于集群成员未发生变更的情况下,然后在真实的环境下,集群的服务器增加、减少、替换都是比较常见的情形,那么Raft算法中又是如何保证在集群成员变更时保证集群的一致性和高可用性呢?

Raft算法中,集群配置指出哪些服务器参与了一致性算法,因此集群成员变更也是指的集群配置发生了变化,旧的集群配置我们用Cold来表示,新的则用Cnew来表示,如果直接进行Cold -> Cnew的切换,以集群中原本有三台服务器,现在要新增两台服务器为例,可能会出现以下问题:

截屏2024-11-25 22.44.55.png (图片来源于Raft论文-In Search of an Understandable Consensus Algorithm (Extended Version))

由于无法在同一时刻完成新旧配置的更换,所以会出现上图中的情况,在红色箭头指向的时刻,server1和server2还是使用的旧配置(即在它们看来,整个集群中还是只有三台服务器,因此两台服务器就是大多数,接下来所有的决策都还是会以这个旧的配置为准),server3-5则已经切换到了新配置,在它们看来,集群中已经有了5台服务器,三台服务器是大多数。在这种情况下,很有可能会选举出两个leader出来(比如server1拿到了自身以及server2的投票,server3拿到了自身以及server4和server5的投票,这样就出现了两个leader),这样的话显然是没法保证集群一致性的,raft算法的一致的参考标准就是leader,出现了两个标准,自然也就不存在所谓一致性了。

Raft算法的解决方案是使用所谓joint consensus来保证新旧配置的平滑过渡,并且可以保证高可用和一致性。joint consensus指的是在集群所有服务器都切换到新配置前会使用一个联合配置Cold,new,联合配置的意思是所有的决策必须同时要Cold和Cnew中的大多数服务器达成一致方可。如下图所示,引入联合配置Cold,new可以保证在整个切换过程中,不存在哪个时刻Cold或者Cnew可以单独做决策,这样也就避免了上述中出现两个leader的情况。

截屏2024-11-26 09.50.42.png (图片来源于Raft论文 - In Search of an Understandable Consensus Algorithm (Extended Version))

Raft算法把集群配置作为一种特殊的日志项来存储和处理,因此集群配置也会存在复制和commit的情况,逻辑也跟之前讲述的一样,这样的好处是不用引入额外的机制去更换集群配置,并且也可以和一般的日志项一样,确保其在整个集群中的一致性。有一点需要说明的是,一旦某个服务器将最新的配置加入到日志中时,就会去使用这个配置,无论其是否已经commit。上图中,虚线表示集群配置的日志项已经创建,但是没有commit,实线表示已经commit,下面对这个图进行稍微深入一点的分析。

leader接收到替换集群配置的请求时,它会去创建一个Cold,new的日志项并添加到其日志中去,此时leader会使用这个配置去做决策,并且将其复制到Cold,new所描述的服务器上去(即Cold和Cnew中指定的服务器)。Cold,new提交的前提是已经复制到了Cold以及Cnew中的大多数服务器上去(这也是联合配置的含义和要求),在这之前 Cold是可以单独做决策的(从图中也可以看到),因为Cold中大多数服务器尚未接收到这个新的配置,这些服务器完全可以基于Cold去做决策,但是一旦当Cold,new提交了,意味着Cold中大多数服务器都已经接收到了这个新的配置Cold,new,由于选举和提交都需要经过大多数服务器,因此Cold此时已经无法单独做决策了。

当Cold,new已经提交的时候,也意味着此时可以安全地去创建Cnew这个配置日志项了, 可以思考一下当Cold,new日志项提交之前就去创建Cnew日志项会出现什么情况?Cnew日志项可能先于Cold,new就已经传播到了Cnew中指示的大多数服务器上,这个时候就会出现新旧配置同时发挥作用的情况了。当Cnew日志项被提交后,Cnew配置所指示的大多数服务器都已经使用上了新配置了,此时Cold中的服务器已经无法干预到Cnew中的服务器的决策了。

新旧配置切换可能带来这样一个问题,当新的配置要求移除掉现有的服务器时,这些新的服务器在新配置切换完之后将无法收到leader的心跳请求消息,此时它们会以更大的term发送RequestVote请求,而leader在收到更大的term之后会转变为follower,如下图所示。

截屏2024-11-26 11.07.23.png (图片来源于Raft论文 - In Search of an Understandable Consensus Algorithm (Extended Version))

由于原来的leader变成了follower,新集群中将没有心跳请求信号,会导致新一轮的选举,然后重复上述的情况,大大地影响了集群的性能和吞吐量。因此Raft针对这种情况,规定服务器在接收到当前领导者的心跳信号后,在一个时间间隔内收到RequestVote请求时,会直接忽略这个请求,而leader服务器自然也会直接忽略掉这个请求,进而也不会转变为follower。

实际应用

ElasticSearch在7.0版本中在改进了之前的分布式一致性算法,采用了类似Raft的预选举机制,避免无意义的选举,同时也引入了term,整体在向Raft算法靠近。Kafka消息中间件在其3.3版本中首次在生产环境中使用KRaft(Kafka Raft)共识协议的版本,替代了之前版本中一直使用的zookeeper。这两者是目前非常流行的分布式系统,Raft算法能在其中扮演重要角色也正说明了它在现实世界的分布式系统中是可靠的。

参考

In Search of an Understandable Consensus Algorithm (Extended Version) By Diego Ongaro and John Ousterhout