手把手教你写raft--选举(1)

174 阅读9分钟

前言

Raft,英译中是:n. 木筏;筏;大量;许多;木排;橡皮艇;充气船。如果你是一位游戏玩家,你或许会知道《raft》这个生存游戏。在该游戏中,你漂流在一望无际的海洋上,仅靠一块木筏求生,既要应对资源短缺的困境,又要抵御来自深海中不可见、不可预知的威胁。

当然,这篇文章的重心不在这个游戏,在程序员界我们有自己的Raft。同样名为木筏,它亦和游戏里的木筏一样,让我们虽然漂泊在分布式这片潜藏着种种威胁的海洋中,但亦有木筏的可靠。

本文的代码基于Mit6.824 2021给出的go代码骨架实现,理论上你把代码复制到对应位置,就可以跑通2A部分的测试案例。

选举规则

本文不会对Raft的基本概念进行介绍,在阅读本文前最好对分布式共识算法,或者raft有所了解。

raft论文(中文)这篇文章翻译了raft论文,如果英语能力尚可的同学可以看英文原文Raft Consensus Algorithm

在Raft的论文中有一张图,非常完美地表述了raft的机制,分为了4个部分

  • 节点状态
  • 日志追加 RPC规则
  • 投票 RPC规则
  • 节点规则

上图涵盖了大部分raft的运行机制,但本文仅对选举部分进行介绍。通过上图,你可以发现几个和选举有关的规则。

  • 在节点状态部分:
  1. votedFor记录着本轮选举投票给了哪个节点
  2. currentTerm,节点本身所处的选举周期
  3. log[],是一个数组,记录节点的raft日志信息,每个log又包含两个值:1. index日志的索引,2. term日志记录时的选举周期

  • 在RequestVote RPC部分:
  1. 请求参数

    • term 候选节点发起选举时的周期,是用来判断投票的关键参数
    • candidateId 发起投票请求的候选节点ID
    • lastLogIndex 发起投票请求候选节点所持有的最后一条日志的索引
    • lastLogTerm 发起投票请求候选节点所持有的最后一条日志的term

ps: 一般文章中只宣称term是用来判断是否投票的参数,但很多时候忽略了在选举周期相同时,raft会根据日志是否最新决定投票,即lastLogIndex和lastLogTerm

  1. 响应参数

    • term 响应节点所处的选举周期
    • voteGranted 一般是个bool值,为true表示投票

  1. 两条规则

    • candidate节点只投票给自己,以及term大于自己的节点(可以重复投给同一个节点)
    • 如果两个节点term相同,则会投票给日志较新的一个节点(日志更新的节点日志的index更大)
  • 在Rules For Server部分:

Candidate

  1. 如果candidate收到了半数以上投票,立马成为leader
  2. 如果接收到了合法leader的的AppendEntry请求,立马成为Follower

Follower

  1. 如果选举超期或接收到canidate的投票请求并同意投票,成为candidate
  2. follower会响应leader和candidate的请求,也就是说即使是follower也可以进行投票

Leader

  1. leader需要通过心跳AppendEntriesRPC来维持领导地位,如果follower在一定时间内没有接受到AppendEntriesRPC(包括心跳和正常日志请求)则转变为candidate发起选举。

对于任何节点

  1. 如果接受到Term大于自己的RPC响应,立马成为Follower,注意这里的RPC包含了AppendEntriesRPC和RequestVoteRPC

选举案例

上图是由7个节点组成的raft集群。假设在term8时刻,leader也就是最上面一行日志失去了leader身份,那么那些节点将有资格成为新的leader呢?

回顾上一小节中响应参数一栏,candidate节点是否投票的依据是:

  1. 本轮没有进行过投票,且投票请求方的term大于自己。
  2. 本轮没有进行过投票,且投票请求方的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()
		}
	}
}

选举

先来看选举超期相关代码逻辑。首先是改变节点状态,这块代码比较简单,核心逻辑是:

  1. 当变更角色为Follower时,需要重新启动选举超期任务,并停止心跳超期任务
  2. 当变更角色为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())
	}
}

发起选举

接下来,是选举发起方实现逻辑的核心逻辑,比较关键的几个点是:

  1. 我们为每个请求创建go rountine,避免阻塞ticker
  2. 在接受到请求后,我们需要判断该请求的响应是否还合法,具体通过判断当前节点状态和发起请求时状态是否一致,以及当前节点所处Term和发起请求时所处Term是否一致。
  3. 当接受到超过半数节点的投票时,立马成为leader,并发送心跳。
  4. 如果响应的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)
	}
}

响应选举

同样的响应选举也是按照论文中的几个规则实现

  1. candidate只能投票一次,或者投票给同一个candidate节点
  2. term小于当前节点,拒绝投票
  3. 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)
}

心跳

相较于选举部分,停跳的逻辑就简单多了。

发送心跳

  1. leader通过定时任务,周期性发送心跳给所有节点
  2. 当心跳响应返回了大于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
			}
		}
	}
}

响应心跳

接受到心跳的节点需要:

  1. 判断心跳请求所携带的Term是否小于自己,如果是,则否定这次心跳,传给对方自己的term周期
  2. 如果认可心跳,则维持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
}

遗留问题

这里遗留两个问题给大家思考:

  1. 三个节点的Raft集群,当某一个时刻发生网络分区,Leader被隔离,剩下两个Follower选出了新的Leader。此后某一个时刻,旧Leader重新加入集群,此时会发生什么?会重新发起一轮选举吗?
  2. 三个节点的Raft集群,当某一个时刻发生网络分区,某个Follower被隔离,在隔离期间,该Follower成为Candidate,并不断自增Term,发起选举。在某个时刻网络分区恢复,此时会发生什么?会重新发起一轮选举吗?注意,网络分区的节点的Term大于leader。