Raft算法分析与实现

阅读 200
收藏 18
2018-08-16
原文链接:www.jianshu.com

Raft是一个分布式系统的一致性算法,它不像Paxos那么难懂,实现比Paxos简单许多,性能与Paxos相当,在Etcd,Consul里面等都有广泛运用。之前在容器服务化的时候用到Consul,顺带看了Raft算法的论文,然后为了练手Go语言做了mit6.824分布式系统课程的lab2。由于实验里面随机选举时间和模拟的节点crash导致的异常可能在你运行上百次才会出现,实现后要测试多次以保证测试通过。我的Raft算法的实现代码在这里 6.824-2017-raft,多有参考其他代码,见README。6.824课程的lab1是完成一个简化版的MapReduce,实现比较简单,代码见 6.824-2017-mapreduce。如有错误,恳请指正。

1 概述

分布式系统的一致性算法就是指一组机器协同工作,即便其中有某些机器宕机了,系统还能正常对外提供服务。以前通常都喜欢用Paxos来讲解一致性算法,但是Paxos本身很复杂,代码实现也很难,于是催生了Raft这个更加简单易懂的一致性算法,难得的是,它的效果跟Paxos差不多。

为了易于理解,Raft采用了算法分解(分为leader选举,日志复制以及安全性)和减少状态的方式。与以往一致性算法不同的是,Raft有一些特别的地方:

  • 强Leader。Raft使用了一个强Leader特性,日志复制只能从Leader节点复制到其他节点。
  • Leader选举。Raft使用了一个随机超时来选举Leader,以确保选举不会失败。
  • 成员变化。Raft使用了联合一致性方法来实现成员配置变化时保证服务不受影响。

2 复制状态机

一致性算法是在复制状态机的背景下提出来的。在复制状态机中,集群中的服务器从相同的状态中生成同样的副本,即使其中有些服务器宕机了,客户端还是可以继续执行操作,复制状态机可以用来解决分布式系统中许多容错问题。大型分布式系统中通常拥有一个集群leader,比如GFS,HDFS等通常使用一个单独的复制状态机管理leader选举和配置信息以应对leader的崩溃。此外,使用复制状态机的还有Chubby以及ZooKeeper等。

复制状态机通过复制日志实现,如下图所示。每个服务器都会存储一份日志,日志存储的是一系列命令,而服务器的状态机会按顺序执行这些日志中的命令。每份日志中以同样的顺序存储了同样的命令,而状态机以同样的顺序执行这些相同的命令。每台服务器的状态机都是确定的,它们以同样的顺序执行同样的命令,最终的状态和输出也必然是一样的。

复制状态机图示

保持复制日志的一致性就是一致性算法的工作了。服务器上的一致性模块接收客户端命令并添加命令到它的日志中。它与其他服务器通信以保证每个日志最终都以相同的顺序包含相同的命令,即便过程中有服务器宕机了。一旦客户端命令正确的复制了,每个服务器的状态机按照日志中顺序处理这些命令,并将输出返回给客户端。最终,这些服务器看起来就像是一台高可用的状态机。

应用到实际系统中的一致性算法通常具备下面几个特性:

  • 保证安全。在所有的非拜占庭将军条件下保证安全,包括网络延迟,分区,丢包,乱序等。
  • 高可用。只要集群中的服务器有大多数(超过一半)可用,系统即是可用的。比如5台服务器的集群可用允许2台服务器宕机而不影响服务。
  • 不依赖时序保证日志的一致性。错误的时钟和极端的消息延迟在最坏情况下才会导致可用性问题。
  • 在通常情况下,只要集群中大部分服务器对过程调用做出响应,命令就可以完成,少数慢服务器不会影响整体系统性能。

3 Raft算法

Raft算法分为两部分,领导选举(Leader Election)和日志复制(Log Replication)。这里有个很形象的动画说明Raft算法的实现,关于成员变更和日志快照这里有篇解释很好的文章,见 深入浅出 Raft - Membership Change

3.1 领导选举

首先了解下Raft中节点的几个状态:Follower,Candidate,Leader。状态变迁如下图所示。

Raft节点状态变迁图
  • 处于Follower状态的节点在一个随机的超时时间(称之为Election timeout,注意每次都要随机选择一个超时时间,这个超时时间通常为150-300毫秒,我在实验中设置的是300+ms)内没有收到投票或者日志复制和心跳包的RPC,则会变成Candidate状态。

  • 处于Candidate状态的节点会马上开始选举投票。它先投自己一票,然后向其他节点发送投票,这个请求称之为Request Vote RPC。如果获得了过半的节点投票,则转成Leader状态。如果投票给了其他节点或者发现了更新任期(Term)的指令(如更新任期的选举投票和日志复制指令),则转为Follower状态。如果选举超时没有得到过半投票,则保持Candidate状态开始一个新一轮选举投票。

  • 处于Leader状态的节点会定期发送(这个时间为HeartbeatTimeout,通常要远小于选举超时,实验中我设置的位50ms)AppendEntries RPC请求给其他节点。如果发现了更新任期的指令,则转为Follower状态。

选举投票需要两个条件:

  • 条件一:请求投票的节点的任期必须大于等于本节点且本节点还没有投过票给其他节点(包括投票给自己)。
  • 条件二:请求投票的节点的日志必须是包含了最新提交日志的节点,这是为了保证日志安全增加的限制条件。如何保证请求投票节点包含了最新提交日志呢?可以比较两个节点最后一条日志的任期,如果任期不一样,则任期大的日志更新;如果任期一样,则日志更长的更新。

3.2 日志复制

Raft是强Leader机制,日志只能从Leader复制到其他节点。日志项LogEntry包括index,term,command三个元素。其中index为日志索引,term为任期,而command为具体的日志内容。日志格式如下图所示:

Raft日志格式示意图

通常的日志复制流程是这样的:

  • 客户端发送请求给Leader。
  • Leader接收客户端请求,先将请求命令作为一个日志项(LogEntry)append到自己的log中。
  • Leader然后在最近的一个Heartbeat timeout时发送 Append Entries RPC给Follower节点。
  • 一旦日志提交成功:
    • 此时日志处于Uncommitted状态,当过半节点添加log成功后,则Leader提交该日志给状态机,返回给客户端写入成功。
    • 并在接下来的Append Entries RPC中通知其他节点提交该日志。
    • Follower节点提交日志到自己的状态机中。
  • 如果Leader节点挂了,其他Follower节点会在超时后重新选举新的Leader。而如果有宕机或者慢的Follower节点,则Leader会不断重试直到成功。

即便出现网络分割,集群中同时存在多个Leader时,也不会有问题。假定5个节点的集群分割成了3节点和2节点两个大小集群,3节点大集群因为数目3过半,可成功提交日志,而节点数不够的小集群没法成功提交日志。当网络恢复时,因为另外分割的一个大集群已经成功提交了日志,最终新的Leader会在大集群中产生(基于选举投票的条件二保证)并同步到之前分割的小集群节点中。

关于日志复制的几个要点:

  • 不同的服务器上面的提交的相同的索引和任期的日志项的command一定相同,而且这个日志项之前的所有日志项都相同。
  • 如果一个日志项被提交,则它之前索引的所有日志项也肯定已经提交。
  • Leader从来都不覆盖自己的日志。其他状态节点如果出现与当前Leader日志不一致,则需要更新日志,包括写入新的日志和删除不一致的日志。
  • Leader提交过的日志一定会出现将来新的Leader中。
  • Leader要保证安全的提交日志,必须满足这两个提交规则(见4.3中不安全的情况和4.4安全的情况):
    • 日志条目已经复制到大多数Follower节点。
    • Leader当前任期的新日志条目至少有一个复制到了大多数Follower节点。

时序和可用性:

Raft的一个特点就是安全性不依赖时序,系统不会因为时序问题而导致错误发生,但是系统的可用性不可避免的会对时序有所依赖。如果服务器崩溃会导致Candidate节点选举不成功而不停的发起选举,而Raft必须有一个稳定的Leader,否则无法工作。领导选举是Raft中对时序要求最关键的地方,Raft能够选举出并保持一个稳定的Leader需要系统满足如下时序要求:

broadcastTime << electionTimeout << MTBF

其中broadcastTime是指一台服务器并行地向集群其他服务器发送RPC并接收到响应的平均时间,而electionTimeout是选举超时时间,MTBF则是指单个服务器发生故障的平均间隔时间。broadcastTime远小于electionTimeout可以保证Leader持续发送心跳包给Follower节点以防止Follower节点发起选举,electionTimeout远小于MTBF是为了保证系统的稳定运行。Leader崩溃后,理论上大约只有electionTimeout的时间内服务不可用。

根据存储方式的不同,broadcastTime一般设置为0.5ms到20ms(实验中设置心跳间隔略有不同,推荐是100ms左右,我设置的50ms),而electionTimeout一般是10-500ms(实验设置的是300+ms)。通常服务器的MTBF一般是几个月甚至几年,很容易符合这个要求。

4 Raft日志复制状态分析

4.1 前一条日志相同才能复制成功

复制状态图示1

4.2 Leader最新任期有日志已经复制到了大多数节点(安全)

复制状态图示2

如图所示,S1-S3在任期2已复制成功了第4条LogEntry,这个时候Leader必须包括第4个LogEntry,因此重新选举时S4和S5都不能选举为Leader,第4条日志可以安全提交。

4.3 Leader试图从一个较老的任期提交日志(不安全)

复制状态图示3

如上图所示,这时候如果提交第3条LogEntry是不安全的,因为后续如果S5选举为Leader的话会覆盖S1,S2,S3的第3条日志。

4.4 Leader安全的提交日志

复制状态图示4

如上图所示,此时Leader最新任期4的一个日志条目4已经复制到大多数节点S1-S3,此时S5不能选举成功,日志条目3和4都是安全的。这就印证了前面提到的Leader当前任期的新日志条目至少有一个复制到了大多数Follower节点才能提交。

4.5 Leader变化导致日志不一致

Leader变化导致日志不一致

如上图所示,Leader变化会导致各节点日志不一致,则需要做如下处理:

  • 新的Leader需要保证Follower的日志与其一致,Follower如果有不一致的多余日志要删除,少了日志则要添加。如下面处理流程图中的(a)是需要添加缺少的日志,(b)则是要删除不一致的多余的日志再添加新的日志。
  • Leader会给每个Follower维护一个nextIndex列表,记录要发送给对应Follower节点的下一个日志的索引。
  • 如果Follower复制日志失败,Leader需要减小nextIndex并重试。
日志不一致处理流程示意

5 Raft实现需注意的几个地方

Raft实现需要的数据结构在论文中已经很完整,如下图:

Raft实现数据结构和流程要点
  • Leader的心跳和日志复制都可以作为Append Entries RPC请求发送,可以简化代码。与日志复制不同的是,心跳RPC的Entries参数为空。
  • 注意两个超时。一个是选举超时,一个是日志复制(心跳)间隔时间。选举超时ElectionTimeout和日志复制间隔HeartbeatTimeout两个超时时间的选择,注意复制间隔必须远小于选举超时,即 HeartbeatTimeout << Electiontimeout。我的代码设置的选举超时随机为(300+Rand(100))ms(原论文要求的是150-300ms,但是实验里面的意思是要大于300ms比较好,不过设置为300+ms测试也能通过),注意选举超时每次都要随机,不然可能造成选举不成功。复制间隔固定为50ms(论文里面要求是20ms以内,实验里面是要求100ms左右,测试发现在选举超时为300+ms的时候心跳间隔为50ms可以测试通过)。
  • 注意加锁问题,多个协程共享的数据要加锁访问rf.mu.Lock(),记得释放锁,使用defer rf.mu.Unlock()是个不错的方案。测试的时候也要记得加上data race的检测, go test -race
  • 注意提交日志的时候applyLogs()函数里面的日志提交部分,commitIndex只要比lastApplied大的日志项都要提交,因为一次可能是提交多个日志的,否则会出错。
  • 日志数组rf.log的第一项没有使用,主要是为了和论文兼容,日志索引从1开始,注意,go语言的数组第一项如果是nil的话gob编码解码会有问题,所以要加个空的LogEntry进去填充。
  • 只要修改了本机要持久存储的变量,就要调用rf.persist()进行持久化。每个节点都要持久存储的变量有 currentTerm, voteFor, log
  • 对于优化Append Entries RPC次数的代码,请参照参考资料3的说明。

6 参考资料

评论