前言
Raft,英译中是:n. 木筏;筏;大量;许多;木排;橡皮艇;充气船。如果你是一位游戏玩家,你或许会知道《raft》这个生存游戏。在该游戏中,你漂流在一望无际的海洋上,仅靠一块木筏求生,既要应对资源短缺的困境,又要抵御来自深海中不可见、不可预知的威胁。
当然,这篇文章的重心不在这个游戏,在程序员界我们有自己的Raft。同样名为木筏,它亦和游戏里的木筏一样,让我们虽然漂泊在分布式这片潜藏着种种威胁的海洋中,但亦有木筏的可靠。
本文的代码基于Mit6.824 2021给出的go代码骨架实现,理论上你把代码复制到对应位置,就可以跑通2A部分的测试案例。
选举规则
本文不会对Raft的基本概念进行介绍,在阅读本文前最好对分布式共识算法,或者raft有所了解。
raft论文(中文)这篇文章翻译了raft论文,如果英语能力尚可的同学可以看英文原文Raft Consensus Algorithm
在Raft的论文中有一张图,非常完美地表述了raft的机制,分为了4个部分
- 节点状态
- 日志追加 RPC规则
- 投票 RPC规则
- 节点规则
上图涵盖了大部分raft的运行机制,但本文仅对选举部分进行介绍。通过上图,你可以发现几个和选举有关的规则。
- 在节点状态部分:
- votedFor记录着本轮选举投票给了哪个节点
- currentTerm,节点本身所处的选举周期
- log[],是一个数组,记录节点的raft日志信息,每个log又包含两个值:1. index日志的索引,2. term日志记录时的选举周期
- 在RequestVote RPC部分:
-
请求参数
- term 候选节点发起选举时的周期,是用来判断投票的关键参数
- candidateId 发起投票请求的候选节点ID
- lastLogIndex 发起投票请求候选节点所持有的最后一条日志的索引
- lastLogTerm 发起投票请求候选节点所持有的最后一条日志的term
ps: 一般文章中只宣称term是用来判断是否投票的参数,但很多时候忽略了在选举周期相同时,raft会根据日志是否最新决定投票,即lastLogIndex和lastLogTerm
-
响应参数
- term 响应节点所处的选举周期
- voteGranted 一般是个bool值,为true表示投票
-
两条规则
- candidate节点只投票给自己,以及term大于自己的节点(可以重复投给同一个节点)
- 如果两个节点term相同,则会投票给日志较新的一个节点(日志更新的节点日志的index更大)
- 在Rules For Server部分:
Candidate
- 如果candidate收到了半数以上投票,立马成为leader
- 如果接收到了合法leader的的AppendEntry请求,立马成为Follower
Follower
- 如果选举超期或接收到canidate的投票请求并同意投票,成为candidate
- follower会响应leader和candidate的请求,也就是说即使是follower也可以进行投票
Leader
- leader需要通过心跳AppendEntriesRPC来维持领导地位,如果follower在一定时间内没有接受到AppendEntriesRPC(包括心跳和正常日志请求)则转变为candidate发起选举。
对于任何节点
- 如果接受到Term大于自己的RPC响应,立马成为Follower,注意这里的RPC包含了AppendEntriesRPC和RequestVoteRPC
选举案例
上图是由7个节点组成的raft集群。假设在term8时刻,leader也就是最上面一行日志失去了leader身份,那么那些节点将有资格成为新的leader呢?
回顾上一小节中响应参数一栏,candidate节点是否投票的依据是:
- 本轮没有进行过投票,且投票请求方的term大于自己。
- 本轮没有进行过投票,且投票请求方的term大于等于自己时,对方的日志至少与自己一样或者比自己新
以(a)节点为例,根据上述的两个要求(a)节点能够赢得b、e、f+自己4个节点的投票有机会成为leader。那么再看假设(a)节点成为leader会丢失数据吗?
对于下图中相较(a)节点多出来的日志并没有被集群提交(不被超过半数的节点持有),因此(a)节点成为leader是合法的。
在看一个反例(f)节点,对于(f)节点日志出现的可能性是,在term2时期当选了leader,但是在term3时期集群又发起了选举,(f)节点再次担任leader,在写入term3的日志后宕机或者网络隔离。对于(f)节点而言,由于日志不是最新的也无法获得大部分节点的投票。
代码实现
在raft结构体,按照论文中实现几个关键选举参数即可。这边使用了两个Timer来触发任务超期执行。
type Raft struct {
// raft节点全局锁
mu sync.Mutex // Lock to protect shared access to this peer's state
peers []*labrpc.ClientEnd // RPC end points of all peers
me int // this peer's Index into peers[]
dead int32 // set by Kill()
currentTerm int // 记录当前节点任期
votedFor int // 当前选举周期投票给谁
roleState NodeState // 当前节点状态 (follower、leader、candidate)
logs []Entry // 日志
electionTimeout *time.Timer // 触发选举超期
heartbeatTimeout *time.Timer // 触发心跳
}
func Make(peers []*labrpc.ClientEnd,
me int,
applyCh chan ApplyMsg) *Raft {
rf := &Raft{
peers: peers,
me: me,
dead: 0,
currentTerm: 0,
votedFor: -1,
roleState: StateCandidate,
logs: make([]Entry, 1),
heartbeatTimeout: time.NewTimer(StableHeartbeatTimeout()),
electionTimeout: time.NewTimer(RandomizedElectionTimeout()),
}
go rf.ticker() //关键逻辑,实现选举与心跳
return rf
}
在Raft的构造方法中,我们创建了一个go routine,用于响应选举超期,以及心跳超期。
func (rf *Raft) ticker() {
for rf.killed() == false {
select {
case <-rf.electionTimeout.C:
rf.mu.Lock()
Debug(dTimer, "S%d election timeout in term %d", rf.me, rf.currentTerm)
// 1. 改变状态
rf.ChangeState(StateCandidate)
// 2. 自增term
rf.currentTerm += 1
// 3. 发起选举
rf.startElection()
if rf.roleState != StateLeader {
rf.electionTimeout.Reset(RandomizedElectionTimeout())
}
rf.mu.Unlock()
case <-rf.heartbeatTimeout.C:
rf.mu.Lock()
// 只有leader才需要发起心跳
if rf.roleState == StateLeader {
rf.BroadcastHeartbeat(true)
rf.heartbeatTimeout.Reset(StableHeartbeatTimeout())
}
rf.mu.Unlock()
}
}
}
选举
先来看选举超期相关代码逻辑。首先是改变节点状态,这块代码比较简单,核心逻辑是:
- 当变更角色为Follower时,需要重新启动选举超期任务,并停止心跳超期任务
- 当变更角色为Leader时,需要重新启动心跳超期任务,并停止选举超期任务
func (rf *Raft) ChangeState(state NodeState) {
if state == rf.roleState {
return
}
Debug(dVote, "S%d change role from %s to %s in term %d", rf.me, rf.roleState, state, rf.currentTerm)
rf.roleState = state
switch state {
case StateFollower:
// follower停止心跳
rf.heartbeatTimeout.Stop()
rf.electionTimeout.Reset(RandomizedElectionTimeout())
case StateCandidate:
case StateLeader:
// leader停止选举
rf.electionTimeout.Stop()
rf.heartbeatTimeout.Reset(StableHeartbeatTimeout())
}
}
发起选举
接下来,是选举发起方实现逻辑的核心逻辑,比较关键的几个点是:
- 我们为每个请求创建go rountine,避免阻塞ticker
- 在接受到请求后,我们需要判断该请求的响应是否还合法,具体通过判断当前节点状态和发起请求时状态是否一致,以及当前节点所处Term和发起请求时所处Term是否一致。
- 当接受到超过半数节点的投票时,立马成为leader,并发送心跳。
- 如果响应的Term大于自己,则立马成为follower(成为follower的好处是会重新投递选举超期Timer,如果本轮选举失败了,还会发起下一轮选举)
ps:这里遗留个问题给你,如果投票响应的term与自己一致,但是被拒绝投票了,即response.VoteGranted==false,会发生什么呢?
还有一点需要特别注意,这里上锁不要把rpc请求过程上锁,具体理由为什么可以思考一下。
func (rf *Raft) startElection() {
request := rf.genRequestVoteRequest()
grantedVotes := 1
rf.votedFor = rf.me
for peer := range rf.peers {
if peer == rf.me {
continue
}
go func(peer int) {
response := new(RequestVoteReply)
if rf.sendRequestVote(peer, request, response) {
rf.mu.Lock()
defer rf.mu.Unlock()
if rf.currentTerm == request.Term && rf.roleState == StateCandidate {
if response.VoteGranted {
// 获得选票
Debug(dVote, "S%d receive vote from S%d in term %d", rf.me, peer, rf.currentTerm)
grantedVotes += 1
if grantedVotes > len(rf.peers)/2 {
Debug(dVote, "S%d receive more than half votes and become leader in term %d", rf.me, rf.currentTerm)
rf.ChangeState(StateLeader)
rf.BroadcastHeartbeat(true)
}
} else if response.Term > rf.currentTerm {
// 降级为follower
// 如果在超时事件内没有选出leader,会重新触发选举
Debug(dVote, "S%d receive vote term higher then self, and become follower in term %d", rf.me, rf.currentTerm)
rf.ChangeState(StateFollower)
rf.currentTerm, rf.votedFor = response.Term, -1
}
}
}
}(peer)
}
}
响应选举
同样的响应选举也是按照论文中的几个规则实现
- candidate只能投票一次,或者投票给同一个candidate节点
- term小于当前节点,拒绝投票
- term相同,但日志没有当前节点新,拒绝投票
func (rf *Raft) RequestVote(request *RequestVoteArgs, response *RequestVoteReply) {
rf.mu.Lock()
defer rf.mu.Unlock()
// term小于我,或者当前周期已经投过票了,且不是请求方,则拒绝投票
if (request.Term < rf.currentTerm) ||
(request.Term == rf.currentTerm && rf.votedFor != -1 && rf.votedFor != request.CandidateId) {
response.Term, response.VoteGranted = rf.currentTerm, false
return
}
// 只要term比我大,我就得变成follower
if request.Term > rf.currentTerm {
rf.ChangeState(StateFollower)
rf.currentTerm, rf.votedFor = request.Term, -1
}
// 日志没我新,拒绝投票
if !rf.isLogUpToDate(request.LastLogTerm, request.LastLogIndex) {
response.Term, response.VoteGranted = rf.currentTerm, false
return
}
Debug(dVote, "S%d vote to S%d with term %d", rf.me, request.CandidateId, request.Term)
// term不比我小,日志比我新,投票给对方
rf.votedFor = request.CandidateId
rf.electionTimeout.Reset(RandomizedElectionTimeout())
response.Term, response.VoteGranted = rf.currentTerm, true
}
func (rf *Raft) isLogUpToDate(term int, index int) bool {
lastLog := rf.getLastLog()
// 一个节点如果term大于另一个节点,则该节点上的日志必新于另一个节点
// 回想一下,follower上的日志必定最终与leader一致,当出现不一致时leader会覆盖写,而term的设计就保证了,大于必最新
// 当term一样时,日志的index被用来比较
return term > lastLog.Term || (term == lastLog.Term && index >= lastLog.Index)
}
心跳
相较于选举部分,停跳的逻辑就简单多了。
发送心跳
- leader通过定时任务,周期性发送心跳给所有节点
- 当心跳响应返回了大于leader的Term时,leader降级为Follower(说明该leader是个老leader,集群中已经存在新的合法的leader)
func (rf *Raft) BroadcastHeartbeat(isHeartBeat bool) {
Debug(dTimer, "S%d as leader send heartbeat in term %d", rf.me, rf.currentTerm)
for peer := range rf.peers {
if peer == rf.me {
continue
}
if isHeartBeat {
// need sending at once to maintain leadership
go rf.replicateOneRound(peer)
} else {
// just signal replicator goroutine to send entries in batch
}
}
}
func (rf *Raft) replicateOneRound(peer int) {
if rf.roleState != StateLeader {
return
}
request := rf.genHeartbeatAppendEntryRequest()
response := new(AppendEntryResponse)
if rf.sendHeartbeatAppendEntry(peer, request, response) {
// just do nothing
rf.mu.Lock()
rf.handleAppendEntriesResponse(peer, request, response)
rf.mu.Unlock()
}
}
func (rf *Raft) handleAppendEntriesResponse(peer int, request *AppendEntryRequest, response *AppendEntryResponse) {
if rf.roleState == StateLeader && rf.currentTerm == request.Term {
if response.Success {
// do nothing
} else {
// 只要收到心跳请求的节点的term大于自己,就降级成follower
if response.Term > rf.currentTerm {
Debug(dTimer, "S%d know self is old leader, and back to follower", rf.me)
rf.ChangeState(StateFollower)
rf.currentTerm, rf.votedFor = response.Term, -1
}
}
}
}
响应心跳
接受到心跳的节点需要:
- 判断心跳请求所携带的Term是否小于自己,如果是,则否定这次心跳,传给对方自己的term周期
- 如果认可心跳,则维持Follower状态,并重置选举超期任务。
func (rf *Raft) AppendHeartbeatEntry(request *AppendEntryRequest, response *AppendEntryResponse) {
rf.mu.Lock()
defer rf.mu.Unlock()
if request.Term < rf.currentTerm {
response.Term, response.Success = rf.currentTerm, false
return
}
if request.Term > rf.currentTerm {
rf.currentTerm, rf.votedFor = request.Term, -1
}
Debug(dTimer, "S%d receive heartbeat from S%d in term %d", rf.me, request.LeaderId, rf.currentTerm)
rf.ChangeState(StateFollower)
rf.electionTimeout.Reset(RandomizedElectionTimeout())
response.Term, response.Success = rf.currentTerm, true
}
遗留问题
这里遗留两个问题给大家思考:
- 三个节点的Raft集群,当某一个时刻发生网络分区,Leader被隔离,剩下两个Follower选出了新的Leader。此后某一个时刻,旧Leader重新加入集群,此时会发生什么?会重新发起一轮选举吗?
- 三个节点的Raft集群,当某一个时刻发生网络分区,某个Follower被隔离,在隔离期间,该Follower成为Candidate,并不断自增Term,发起选举。在某个时刻网络分区恢复,此时会发生什么?会重新发起一轮选举吗?注意,网络分区的节点的Term大于leader。