RAFT协议是一种共识算法(consensus algorithm)。什么是共识算法,说白了也就是大多数成员达成一致的算法。那对于大多数有定量吗?有,大于等于N/2+1就是大多数,也就是多余半数的成员达成一致。
共识算法的典型代表是Paxos,而由于其不仅难以理解,更难以实现,所以衍生出了很多基于Paxos的算法,Raft就是其中之一,提供了一种更易懂、且便于工程实践的算法。
为了提高系统的可用性,系统设计时会引入备份(防止单点故障导致不可用)。比如系统存储,会有一个主存储和N个备存储(多数据副本),随之产生了一个新的问题,系统怎么保证主存储了备存储的数据一致(多副本之间数据一致性)?
一致性:一致性就是数据保持一致,在分布式系统中,可以理解为多个节点中数据的值是一致的。可以将一个具有强一致性的分布式系统当成一个整体,应用层可以忽略底层多副本之间数据同步的问题。
Raft就是保证一致性的一种共识算法。
Raft算法基于状态复制机(Replicated State Machine),状态机将客户端的操作命令(command) 转换成日志(log),经各状态机按照顺序处理后,apply到状态机中(state )。
根据状态机的运行逻辑,要保证个节点之间的state最终一致,也就是保证个节点的日志副本一致,其他节点按照顺序处理日志后,使得集群内状态一致。
Raft就是用来管理日志副本(replicated log)的算法。Raft首先会选举出一个Leader,用于管理replicated log,Leader从客户端接收请求,处理成日志(log),并把日志同步给其他节点,而且会告诉其他节点什么时候可以安全的apply 日志到状态机中。
Raft算法要保证了如下特性
选举安全:在一个term(下面有介绍),最多一个Leader可以被选举(或者没有选举出来Leader)。
Leader 只允许日志条目增加:Leader节点永远不会覆盖或者删除自己的日志条目。只会新增新的日志条目。
日志匹配:两条日志条目的term和index属性相同,那么之前的日志条目信息也相同。
Leader完整性:如果在一个term中,一个日志条目被commited,在高版本term中的Leader一定会用用这条被commited的日志条目。也就是说commited后的日志条目在集群中不会丢失。
状态机安全:一个节点的某index位置applied日志条目到状态机,不会存在其他节点将对应节点不同条目的日志apply到状态机。
由此Raft算法拆分出了三个相对独立的子问题
选主 leader election,Leader不存在或者Leader宕机的情况下需要选择出一个Leader。
日志复制 log replication,Leader将自己的日志同步给集群中其他节点。
安全性 Safety:主要保证状态机安全特性,后面章节会详细介绍。
Raft集群中节点角色有以下三种:
Leader: 所有请求处理节点。请求写入本地日志后同步集群其他节点。
Follower:同步Leader节点日志,转发客户端请求给Leader节点。
Candidate:在timeout实践内没有接收到Leader节点的心跳请求,认为Leader节点宕机,转换角色状态为Candidate,开始leader election,直到选主结束。
任期 term
每当candidate触发leader election时都会增加term。term编号单调递增。每个节点都会保存一个当前任期term,通信时会带上这个term。
接下来我们看下各角色之间的转换图
如何成为Follower
启动时节点默认为Follower。
Candidate收到新Leader的RPC。
所有节点,收的的请求(request)或者响应(response)中的term>currentTerm,变为Follower。
如何成为Candidate
Follower节点在timeout时间内没有收到Leader节点的心跳请求且没有投票给Candidate。
Candidate节点在timeout周期内,没有获得大多数选票,会维持Candidate角色。
如何成为Leader
Candidate节点在选举周期内获得集群内大多数选票,变为Leader。
下面我们来演示下常见的Leader election场景:选举成功和选举失败。
Candidate节点获取其他节点的投票,通过RequestVote RPC,接口详情见下图。
投票规则:
每个term,各节点可以投一票,通过votedFor属性标识是否在本term内投票过。
Candidate首先会给自己投一票。
Follower没有投票给其他节点过,投票请求中的term>=节点当前term,且投票请求中的日志index要>=当前节点最新的日志条目index,满足以上条件的投票请求 采取先到先得的方式响应对应的Candidate。
选举成功:集群初始启动
模拟场景,集群共有5个节点S1~S5,刚开始启动时节点角色都为Follower,圈内数字标识term,刚启动时term为1,外圈灰色部分表示剩余超时时间。
S3节点超时,节点变为Candidate,开启一轮leader election。
S3的term+1变为2,S3首先投自己一票,并行的向集群中其他节点发送投票请求。
集群中的节点收到投票请求后,响应,
图片中带十字图标表示投票给请求的Candidate节点。
收到投票结果后的S3成为了Leader,选举成功
选举失败场景:多节点同时开启leader election
S3、S2节点宕机,S5和S4节点同时超时开启新一轮的leader election。
S4、S5都投给自己一票,S1的投票根据先到先得原则投给了S4,S4得到了两票< (5/2+1=3),不满足大多数原则,S4不能成功变更为Leader。
等待S4选举超时,开启下一轮的leader election,
由于S5的term 3 小于S4的term 4,所以S3变更为Follower,并且投票给S4
最终S4成功获得3票当选为Leader
为了避免多个节点同时开启leader election 导致选举失败的情况,Raft采用随机超时时间的方式,尽量避免多节点同时开启leader election。
更多Raft集群选主场景,可以在https://raft.github.io/页面模拟各种情况进行测试。
日志结构如下图
每个日志条目(方框标识)包含状态机命令(x<--3)和对应的term编号(框内上方数字),每个条目还有一个index标识在日志中的位置(上图头部数字)。
当日志条目被集群中的大多数节点接收后,对应的日志条目就被commit。
日志同步问题主要保证Raft的日志匹配特性,日志匹配特性可以拆解为以下两点
1.不同节点下,两个日志条目,拥有相同的term和index,那么对应的条目command一定相同。
2.不同节点下,两个日志条目,拥有相同的term和index,那么该日志条目之前的条目也一定相同。
首先第一点,由于是Leader节点负责处理客户端请求,按照顺序写入日志条目,那么term创建一个拥有相同term和index的日志条目,且日志条目不会改变在日志的的位置,也就是index属性。
第二点是如何来保证的呢,日志同步通过AppendEntries RPC请求进行,具体参数信息请看下图
可以看到请求中包含了prevLogTerm和prevLogIndex,这两个参数用来做一致性检查的,收到AppendEntries RPC请求后Follower会检查前一个日志条目的term和index和请求中prevLogTerm和prevLogIndex参数是否一致,如果一致,则一致性检查通过,如果不一致,则一致性检查失败,拒绝此次日志同步请求。
正常情况下Leader和Follower日志保持一致,异常状态下如网络延迟或者节点宕机等情况,会出现Follower和Leader日志不一致的情况。下图展示了一些Leader和Follower不一致的场景。
那么Raft是怎么来处理这种Leader和Follower之间日志条目不一致的情况呢?答案是Follower会从Leader中同步数据。
Leader会为每个Follower维护一个nextIndex属性,用于标识下一次日志复制发送那一条日志条目给Follower。
当Leader刚当选的时候 nextIndex的初始值为Leader最后一条日志条目对应的index。
如果Follower和Leader的日志不一致,当日志条目同步AppendEntries RPC请求时,一致性检查会失败,Leader会递减nextIndex,并重试AppendEntries RPC请求,最终回到到一个nextIndex,Leader日志和Follower日志一样。
Follower会删除和Leader不一致的日志条目,并且追加Leader同步过来的日志条目。最终和Leader的日志条目保持一致。
选举约束( Election restriction),Follower会拒绝投票给自己最后一条日志条目新于投票请求中的最后最后一条日志条目。
commit之前term的日志条目约束:当前term的日志条目commit时,才会将之前term的日志条目一起commit。
Follower会拒绝投票给自己最后一条日志条目新于投票请求中的最后最后一条日志条目。关于日志条目更新的比较规则时 先比较term,term大的更新,term一样的情况下,比较index,index大的更新。
Leader的节点一定包含所有被Commited的日志条目信息,Candidate节点要当选为Leader,必须要获得大多数节点的投票,意味着至少有一个节点拥有commited的日志条目,Candidate最后最后一条日志条目要新于大多数节点,也就意味着这个当选的节点拥有commited的日志条目。
我们先来看一下以下场景
a) term 2 ,S1时leader 将index 2 的日志条目 复制给 S2。
b)term 3, S1宕机,S5收到S3、S4和自己的投票后当选为Leader,然后接收一个不同的日志条目到index 2。
c)term4 ,S5宕机了,S1重启后重新当选为Leader,继续复制term2、index2的日志,S3接收到term2,index2日志,term2,index2日志此时还不能commit(日志term和当前term不一致时,不能在日志同步给大多数节点后commit),原因情况接下来的场景。
d)term 5,S1宕机,S5 获得 S2、S2、S4、S5的投票后当选为Leader,同步term3 index3的日志条目给其他节点。加入当时term2 index2被提交了,那么这条提交的记录就会被覆盖掉。
e)term 4,当S1接收到新的日志条目term4 index4,并将其同步到集群中大多数节点后,term4 index4被commit,之前的term2 index2条目也可以一起被commit。
Raft不会通过计算大多数节点同步方式 commit之前term的日志条目。只有当Leader的当前term的日志条目才会通过计算大多数节点同步方式,commit日志条目。当前版本的日志条目被commit,之前term的日志条目才会被commit。
关于Raft的内容就先介绍到这,受自身经验限制,有些描述可能会出现疏漏,完整内容请查看官网和Raft论文。
参考:
https://raft.github.io/raft.pdf