一、背景
随着计算机发展至今天,单机模式随着纵向拓展已经遇到了瓶颈,在如今常见的设计架构中,我们通过横向拓展,来满足我们对性能、算力、存储的需求。例如Redis 是通过一致性Hash算法,将原来单机存储的数据,分布到多个节点,通过主从复制模式避免单节点故障的问题。Raft 算法也是一种用来管理日志复制的一致性算法,相较于Paxos 算法,Raft更容易理解和落地。注:Raft一致性算法译文 , Raft 动画
二、算法原理
Raft 算法是一种管理日志复制的一致性算法,我们可以将主从节点看成一个整体,对于外部的读写请求,如何才能是这个整体都能提供正确的响应?Raft 算法从4个方向进行解决处理,分别是 领导选举、日志复制、安全、成员变化。然后定义了5大原则,在这5个原则下,保证了Raft算法稳定安全的运行。分别是:选举安全原则、领导人(日志)只增加原则、日志匹配原则、领导人完全原则、状态机安全原则
2.1 领导选举
Raft 协议中,针对主从节点不同阶段,定义了3中状态,不同状态的节点,可以有不同的动作和行为,分别是 leader、follower 和candidate。为了解决一个任期内,选举无果(选票被瓜分的情况),采用随机选举超时,来避免复杂的中间状态和处理逻辑。三者之间的转换关系如下图
leader:接受外部请求,发送心跳给follower节点,复制日志给follower节点(跟随心跳信息一起发送),即 AppendEntries RPC。
follower:接受leader的心跳信息(AppendEntries RPC),以及接受候选人的选举投票请求(RequestVote RPC)。一个任期内,只有一票,且只能投给一个候选人
candidate:候选人,发起选举投票(RequestVote RPC),当获得过半的选票,则成为新任期的leader,然后发送日志同步心跳消息。如果反对票过半,则成为follower,如果收到当前任期或者新任期的leader心跳信 息,则变为follower。
候选人发起投票请求的报文主要结构体大致如下 RequestVote RPC
字段 | 注释 |
---|---|
lastLogIndex | 候选人最新的日志Index |
lastLongTerm | 候选人最新的日志所在的任期 |
Term | 选举所在的任期 |
follower 在下面的情况下才投赞成票
- Term >= currentTerm
- 当前节点没有投票、或者重复投票的时候,才赞成。即一个任期内,follower只能投给一个候选者。
2.1.1 遵循的规则
所有节点
如果收到的RPC 请求 Term(T) > currentTerm , currentTerm = T 。(选举相关)
如果CommitIndex > lastApplied ,将LastApplied 自增,并将log[LastApplied]应用到复制状态机。
一个Term 任期内,只能选举出一个leader(选举安全原则)
如果一条日志被提交到状态机,那么所有的节点,在这个日志所在的索引处,不会存在不同的日志条目。(状态机安全原则)
如果两个日志相同索引位置的任期号相同,那么这两个日志从起始位置到该索引位置之间的日志条目都是一致的(日志匹配原则)
Leader节点
向其他节点发送 AppendEntries RPC(heartbeat),避免超时引发重新选举。(日志复制、选举)
如果某个从节点的日志信息不匹配,找到从节点的最新的leader节点日志匹配的日志索引,然后将之后的日志删除,用leader的日志进行覆盖
leader节点的日志只新增,不删除。(领导人只增加原则)
如果一个日志在指定的任期内被提交,那么这条日志一定会出现在所有任期号最大的领导人中。(领导人完全原则:即leader一定拥有最多最全的日志)
follower追随者
接受候选人 和 leader的RPC请求
如果超过某个设定时间没有收到leader 或者 候选人的请求,则自己变为候选人。
如果在心跳间隔时间内,收到候选人的选举投票请求,拒绝。(论文中好像没这条,但是可以在实现中添加这条的处理机制,避免候选人只与leader网络不通的场景,导致频繁发生选举,但是会增加实现的复杂性(选票有3中状态,支持,不支持,反对),如果没有这个场景,可以忽略)
Candidate 候选人
\1. 变为候选人后:A、currentTerm+1 B、给自己投票 C、重置选举计时器 D、发送投票请求 RequestRote RPC
支持票过半,成为leader。
反对票过半,成为follower。(论文中好像没这条,但是可以在实现中添加这条的处理机制,避免候选人只与leader网络不通的场景,导致频繁发生选举,但是会增加实现的复杂性(选票有3中状态,支持,不支持,反对),如果没有这个场景,可以忽略)
如果收到新leader的心跳信息,转为follower。
没有成功,也没有失败,在随机选举超时失败后,开启新一轮选举。
2.1.2 触发选举的几种场景
2.1.2.1 正常场景
follower 节点A在与leader X 超时后,变为候选人,Term + 1 ,给自己投了一票。然后发送选举投票给 BCDE节点
BC节点完成了收到投票请求,并完成了投票给A。
A收到过半的支持(ABC 选票),变为leader 广播心跳信息。
2.1.2.2 多个候选人
A和E节点 都从Follower 变为 候选人状态,A currentTerm+1 = 2 ,投票给自己,E currentTerm+1 = 2 ,投票给自己。然后两者分别发起RequestVote RPC。
D 优先收到了候选人 E 的请求,currentTerm = term = 2,VoteFor E,然后接到候选人A的RPC 请求,由于A的Term 也等于 currentTerm = 2,且D的选票已经投给了E,根据选举安全选择,D不能给A投票。同理E 也不给A投票。
BC投票给A,然后给获得过半的支持,A成为leader,广播心跳(宣誓自己的主权),E这里等待选举随机时间超时,如果超时之前收到了A的心跳信息,则变为follower,否则开启新一轮的选举。
2.1.2.3 网络隔离
上层的网络区域内可以正常运行,且A成功当选了leader。
下层DE 无法正常运行,且E也无法成为leader。如果经过几个随机选举超时间隔后,E的currentTerm > A.currentTerm ,当网络隔离解除后,由于E的最新日志index、term 均小于A,E选举失败,但是E.CurrentTerm > A.CurrentTerm ,出导致集群的重新选举(收到Term的干扰),因此目前的实现上(添加了预候选人状态阶段,避免Term进行无谓的自增)。
2.2 日志复制
当一个节点当选为leader 节点后,就开始了日志复制 AppendEntries RPC,日志复制与心跳信息是同一个请求(如果没有待复制的的日志,只是不发日志条目而已),请求的结构体如下
字段 | 注释 |
---|---|
Term | 当前的任期 |
leaderId | 领导人ID,便于follower重定向 |
PreLogIndex | 上一条日志条目所在的索引 |
PreLogTerm | 上一条日志条目所在的任期 |
entries[] | 日志条目,当heartbeat时为空,可以一次发送多条 |
leaderCommit | leader 节点当前最新的已提交的日志条目 |
follower 会有如下响应
- Term 小于currentTerm,拒绝(你这过气 的leader,还想提交日志条目?)
- preLogIndex 所在的索引没有日志(日志缺失),返回false。
- preLogIndex 所在的索引日志的任期与PreLogTerm 不匹配,删除该索引,以及之后的日志。
- 添加entries 中,在本节点不存在的日志(幂等处理,如果同一个位置多次添加,幂等处理)
- 更新CommitIndex。
leader 会有如下响应
- 如果follower拒绝,且返回的Term > currentTerm ,则leader 变为follower。
- 如果Term 相同,则递减preLogIndex (即日志条目前移,leader会维护者与所有follower的当前复制所在的日志索引位置 nextIndex),直到与follower指定的索引位置(preLogIndex)与leader 保持一致,再进行复制。
2.3 安全
2.3.1 选举限制
候选人如果被选举为leader,那么该候选人一定拥有之前所有已提交的日志。
2.3.2 leader 只能提交当前任期的日志条目
节点当选为leader后,该leader只能提交当前任期的日志条目,而之前任期尚未提交的日志条目,只能通过CommitIndex 间接提交
2.3.3 论证
我们通过下图进行论证
(a) 任期2 S1 竞选leader成功,并接受来自客户端的消息,记录到本地,并复制到s2
(b) 任期3 S1挂掉了,s5通过s3、s4 和自身的投票,是可以竞选为leader的,然后接收到客户端对的信息,但尚未来得及复制,也挂掉了。
(c) 任期4 S1 通过S1、S2、S3投票竞选为leader,并开始了复制,并接收到了客户端的消息,但该消息尚未复制到其他节点。此时,如果如果违反了2.3.2 leader只能提交当前任期日志条目的限制,可能出现了任期2,logIndex=2 的日志被提交了,且应用至状态机,但(d) 阶段,S1又挂了,S5 开启了任期5的投票
(d) 任期5 ,S5 发起投票请求,Term = 5 > 其余节点任期,符合之前的规则;lastLogIndex=2的 lastLogTerm > S2,S3,S4的lastLogTerm(up-to-date),因此当选为leader,开始同步其它节点复制,然后将lastLogIndex=2 的日志条目进行了覆盖,导致已提交的数据发生丢失,违反了状态机安全原则。究其原因,在(c) 阶段,任期4 的leader s1 提交了任期2 的日志条目,导致(d) 阶段,S5 又违反了2.3.1 选举限制,包含全部已提交的日志条目的原则。导致提交的日志丢失。
因此,如果(c) 阶段,S1 不可以提交任期2 的日志,那么,会出现两种情况,
如图(d)阶段,S5 日志条目,覆盖了其它节点未提交的日志条目(这种情况是允许的,毕竟未提交)。
如图(e)阶段,S1复制完任期4,logIndex=3 的日志条目,并提交了该条目,CommitIndex=3,根据日志匹配原则,term=2且logIndex=2 的日志也会被间接提交,并应用之状态机中。此时,如果S1挂掉,S5 不符合up-to-date ,是不能当选为leader的。
下面是etcd 对raft协议实现中,up-to-date的代码
func (l *raftLog) isUpToDate(lasti, term uint64) bool { return term > l.lastTerm() || (term == l.lastTerm() && lasti >= l.lastIndex()) }
入参:
lasti:候选人lastLogIndex
term:候选人lastLogTerm
这段逻辑的意思是:如果候选人的最新的日志所在的任期 大于 follower 最新日志的任期(日志比我的新),返回true(投支持票);如果任期一样新,则判断lastLogIndex 是不是比自己的大,如果大,还是你最新,我还是支持你。
2.4 成员变化
上面都是基于集群数量没有变化的场景,但实际场景中,我们会调整配置,例如替换某些机器、更改复制级别等。我们可以通过关闭整个集群,防止在升级集群配置的时候,出现问题,但是这样影响到了可用性。如果不关闭集群,可能会出现如下图的场景
老的集群有3个节点,S1,S2,S3;在老的集群选举的时刻,添加2个新的节点S4、S5;S1 可以通过自身投票+S2 投票,完成老配置的过半选票,获得leader,S5 可以通过S3 和S4 S5的选票,完成新配置的选票过半,竞选为leader,这个时候,进群就会有2个leader同时存在的,这就违反了领导人安全选举原则。
假设我们原来的集群有N 个节点,新加入 X 个节点,有一下几种情况
老配置机器为奇数
那么老配置过半是 (N+1)/2,则剩余选票的数量是 (N-1)/2,新配置最大可以拥有(N-1)/2 + X 个赞成票,只要赞成票不过半就不会同时出现两个leader。
- 添加新节点后,总节点数是奇数
- 如果添加X 个节点后,总节点数(N+X) 为奇数,即X 为偶数。即(N-1)/2 + X < (N+X+1)/2 → X <2 ,由于X 是偶数,等式不成立,即当老配置是奇数时,添加后的节点数是不可能为奇数的,因为X<2 ,只能选择X=1;
- 添加新节点后,总节点数是偶数
- 如果添加X 个节点后,总节点数(N+X) 为偶数,即X 为奇数,即(N-1)/2 + X < (N+X)/2 +1; → X < 3,由于X为奇数,那么 X=1;
老配置机器是偶数
那么老配置节点过半是 N/2 +1,剩余选票数量是 N/2 -1,新配置最大可以拥有 N/2 - 1 + X 个赞成票,只要赞成票不过半就不会同时出现两个leader。
- 添加新配置节点后,总节点数是奇数
- 如果添加X 个节点后,总节点数(N+X) 为奇数,即X 为奇数。N/2 - 1 + X < (N+X+1)/2 → X<3,由于X为奇数,那么 X=1;
- 添加后新配置节点后,总节点数是偶数
- 如果添加X 个节点后,总节点数(N+X) 为偶数,即X 为偶数,N/2 - 1 + X < (N+X)/2 +1; → X < 4,由于X为偶数,那么 X=2;
总的来说,对于新增机器的情况下,
- 如果原集群的数量是偶数,那么最多只能添加2节点,
- 例如原来是4个节点,老配置需要3票,当选leader,新增2个节点,新配置最多只有3票,不过半(总机器数变为了6),也可以里理解为,原来是4个节点,由于建议节点数为奇数,那么补了一个节点,变成了5个节点,原来4个节点过3票当选leader的还成立,那么最多还只能再添加1个节点。
- 如果原集群是奇数,那么最多只能添加1个节点
同理,节点数减少也可以用上面的方法进行分析,这里就不多做介绍了。