lab3A--Leader选举
我们需要在lab3中重现论文中的Raft算法,因此强烈建议在开启实验之前先通读一遍Raft论文,我做这个lab的时候是按照官方给出的时间表进行的,逐步完善各个模块。这一部分中我们先完成第一个模块:Leader选举。
下面给出一些我在做lab时遇到困惑时的参考资料:Vanilla Beauty仓库地址,这个仓库的代码写的很好,代码设计思路讲的也十分通透。本文相对于这个仓库的代码讲解,更侧重于一些模块的详细设计思路和边界情况。
任务简介
在Raft论文中,给出了一张十分关键的图,阐释了Raft各个模块的组件:
这个实验要求我们完成Raft中的Leader选举和心跳函数机制,根据上图和Raft论文可知:
Leader如果是正常运行的话,会不断发送AppendEntries Rpc给Follower,Follower收到之后对Leader进行相应回复,其中心跳在AppendEntries Rpc中的entires[]字段是空的。注意在收到心跳之后需要重置自己的选举超时时间,当前阶段我们还不需要进行日志相关的操作。
对于Leader选举,我们有如下要求:
- 当指定的心跳间隔到期时,
Follower转化为Candidate并开始进行投票选举, 会为自己投票并自增term。 - 每一个收到
Candidate投票请求的服务器会根据上图判断RPC参数是否符合给它投票的要求,符合的话就投票。 - 一个server在每一个任期只能投一次票,所以投票之后要记录是否已经投过票了,而且在每次任期更新时都重置投票权力。
- 超过一半的
Server的投票将选举出新的Leader, 新的Leader通过心跳AppendEntries RPC宣告自己的存在, 收到心跳的Server更新自己的状态(变为Follower,重置投票,更新任期)。 - 若超时时间内无新的
Leader产生, 再进行下一轮投票, 为了避免这种情况, 应当给不同Server的投票超时设定随机值。
官方提示
- 我们需要将领导者选举的状态添加到Raft结构体中,这样每个服务器就具备了投票和参选的能力。
- 我们还需要填写
RequestVoteArgs和RequestVoteReply结构体,因为在func (*rf* *Raft) RequestVote(*args* *RequestVoteArgs, *reply* *RequestVoteReply)这个RPC函数中我们是通过args传递投票请求,通过reply传递对Candidate的回应。 - 要实现心跳机制的话需要创建一个
AppendEntries结构体,并且让Leader定期发送这个结构体给Follower。 - 需要一个单独的协程不断检查在投票间隔内是否收到
AppendEntriesRPC,如果没有收到的话则变为Candidate准备进行选票。 - 官方的框架代码中明确了应该使用
ticker实现选举。
代码设计
首先我们需要添加Leader,Follower和Candidate字段和日志字段:
// 设置基本状态
const (
Follower = iota
Candidate
Leader
)
type Entry struct {
Term int
Cmd interface{}
}
并且我们需要在Raft结构体中添加一些字段,如下:
state int // 当前raft节点的状态(follow,candidate,leader)
currentTerm int // 当前raft节点的任期
votedFor int // 该节点把票投给了谁
voteCount int // 当前term收到的票数
log []Entry // 存储的日志
commitIndex int // 提交日志的索引
lastApplied int // 给上层应用日志的索引
nextIndex []int // 发给 follower[i] 的下一条日志索引
matchIndex []int // follower[i] 已复制的最大日志索引
muVote sync.Mutex // 保护投票数据,用于细化锁粒度
timeStamp time.Time // 记录收到心跳的时间
官方提示我们对于有关心跳的计时操作,我们最好使用time.Time而不是time.Timer,因为使用Timer,需要管理定时器的启动、停止和重置,而使用time.Time只需要更新一个时间戳,更简单。
我们还需要设计RequestVote RPC结构体和AppendEntries RPC结构体,这部分仿照上面那个RPC图设计就好,没什么难点:
// example RequestVote RPC arguments structure.
// field names must start with capital letters!
type RequestVoteArgs struct {
// Your data here (3A, 3B).
Term int // 当前raft节点的任期
CandidateId int // 发送请求的候选人id
LastLogIndex int // 候选人最后的日志索引,供选举用
LastLogTerm int // 候选人最后的日志任期,同样供选举使用
}
// example RequestVote RPC reply structure.
// field names must start with capital letters!
type RequestVoteReply struct {
// Your data here (3A).
Term int // 其他raft节点回传的任期
VoteGranted bool // 表示是否得到选票
}
// 心跳/日志同步RPC参数
// field names must start with capital letters!
type AppendEntriesArgs struct {
Term int //当前Leader的任期
LeaderId int //Leader在peers数组中的id
PrevLogIndex int //新日志条目之前的那个日志索引
PrevLogTerm int //PrevLogIndex 对应的任期号
Entries []Entry //要复制的日志
LeaderCommit int //leader的已提交日志索引
}
// 心跳/日志同步RPC回复
// field names must start with capital letters!
type AppendEntriesReply struct {
Term int //回复raft节点的任期
Success bool //是否成功收到raft节点的确认
}
到此,我们所需的结构体已经设计完毕,下面是函数逻辑的设计。官方提示我们,我们需要使用ticker函数检测心跳超时,并且在超时的时候进行选举。因此,我们还需要设计合适的心跳发送时间间隔和选举超时时间间隔:
const (
HeartBeatTimeOut = 150 // 心跳重传时间
ElectTimeOutBase = 500 // 选举超时时间
ElectTimeOutCheckInterval = time.Duration(300) * time.Millisecond // 检查是否超时的间隔
)
func (rf *Raft) ticker() {
rd := rand.New(rand.NewSource(int64(rf.me)))
for !rf.killed() {
rdTimeOut := GetRandomElectTimeOut(rd)
rf.mu.Lock()
if rf.state != Leader && time.Since(rf.timeStamp) > time.Duration(rdTimeOut)*time.Millisecond {
// 超时
go rf.Elect()
}
rf.mu.Unlock()
time.Sleep(ElectTimeOutCheckInterval)
}
}
每个Raft节点在启动时都会同时启动一个ticker协程,检测是否在规定时间内没收到心跳。这个函数通过检测每个Raft节点随机的超时时间(500ms-1000ms),如果超时了就启动一个Elect()协程进行选票,这里有两个问题需要我们注意:
- 检测选举超时的间隔和随机选举超时时间都必须要大于心跳,而且前者必须小于后者?这是因为心跳时间设计较短为了及时发现Leader故障,防止Follower因为长时间没收到心跳而发起不必要的选举(因为可能出现网络丢包等特殊情况)。检查间隔设计比心跳时间长是为了避免频繁的检查,比随机选举超时时间短是为了确保能够及时发现选举,否则
time.Sleep(ElectTimeOutCheckInterval)就会‘睡过头’。随机选举超时时间最长是为了确保在正常情况下不会触发选举,只有在较长时间(相对于心跳)没接收到心跳时才会进行选举。 - 选举为什么单开一个协程而不是调用函数?因为选举需要向所有的节点发送RPC并且等待回应,是一个十分耗时的过程,如果调用函数的话就会阻塞
ticker,导致无法继续检查超时,可能错过新的超时。在代码设计中还有许多类似的地方,我们后续会讲到。
接下来的Elect函数负责处理当前节点具体的投票逻辑:
func (rf *Raft) Elect() {
rf.mu.Lock()
rf.currentTerm += 1 // 自增term
rf.state = Candidate // 成为候选人
rf.votedFor = rf.me // 给自己投票
rf.voteCount = 1 // 自己有一票
rf.timeStamp = time.Now() // 重置时间戳
args := &RequestVoteArgs{
Term: rf.currentTerm,
CandidateId: rf.me,
LastLogIndex: len(rf.log) - 1,
LastLogTerm: rf.log[len(rf.log)-1].Term,
}
rf.mu.Unlock()
for i := 0; i < len(rf.peers); i++ {
if i == rf.me {
continue
}
go rf.collectVote(i, args)
}
}
在选举函数中,任务是需要改变当前节点的状态,并且向其他节点发送选票信息,处理他们的回应。所以我们在一开始就根据论文的要求进行状态变更:
- 自增Term
- 成为候选人
- 给自己投票并且记录
- 更新时间戳,避免下一轮选举超时马上到来
状态变更之后我们就构造RequestVoteArgs并且对每个Raft节点启动一个协程收集票数。注意,这里的args需按照上图的RPC请求进行构造。这里对每个节点启动一个线程收集投票的原因是:每个节点的投票请求是独立的,通过为每个节点启动单独的协程,可以同时向多个节点发送投票请求,并行等待所有节点的响应,而且不会因为某个节点响应慢而阻塞其他节点的处理。如果之间调用collectVote:需要等待当前节点响应后才能处理下一个节点,如果某个节点响应慢,会延迟整个选举过程,可能错过其他节点的快速响应。
来到collectVote函数:
func (rf *Raft) collectVote(serverTo int, args *RequestVoteArgs) {
voteAnswer := rf.GetVoteAnswer(serverTo, args)
if !voteAnswer {
return
}
rf.muVote.Lock()
if rf.voteCount > len(rf.peers)/2 {
rf.muVote.Unlock()
return
}
rf.voteCount += 1
if rf.voteCount > len(rf.peers)/2 {
rf.mu.Lock()
if rf.role == Follower {
// 有另外一个投票的协程收到了更新的term而更改了自身状态为Follower
rf.mu.Unlock()
rf.muVote.Unlock()
return
}
rf.role = Leader
rf.mu.Unlock()
go rf.SendHeartBeats()
}
rf.muVote.Unlock()
}
在这个函数中我们的主要逻辑是收集每个节点的选票回应,然后在达到半数以上节点同意时就将当前节点的状态改为Leader并且向其他节点发送心跳,通知自己是Leader。所以我们先调用了rf.GetVoteAnswer(serverTo, args),收集对应服务器的投票结果,如果对应服务器不愿意投票的话就之间返回,如果愿意投票的话就继续下面的逻辑:将票数++,然后判断当前票数是否多于半数节点,如果多的话就当选为Leader,并且向其他节点发送心跳。
注意这两段代码:
if rf.voteCount > len(rf.peers)/2 {
rf.muVote.Unlock()
return
}
if rf.role == Follower {
// 有另外一个投票的协程收到了更新的term而更改了自身状态为Follower
rf.mu.Unlock()
rf.muVote.Unlock()
return
}
上一段代码是为了在当前服务器已经获得半数选票之后,直接返回,不进行后续的逻辑处理,这是因为已经没必要进行处理了,Raft规定只要获得半数以上选票就可以当选Leader,所以可以直接返回,不需要接下来的投票了。
下一段代码是防止在选票过程中发生特殊情况:
时间线:
1. 节点A开始选举,term = 5
2. 节点A启动多个协程收集投票
3. 其中一个协程收到节点B的投票请求,term = 6
4. 节点A更新term并变为Follower
5. 其他收集投票的协程发现状态已变为Follower
6. 这些协程会直接返回,不再继续收集投票
添加这个检查可以避免无效的选举继续,因为节点A的任期是5,节点B的任期是6,其他节点在收到B的选举消息时会将自己的任期转为6,从而收到节点A的选举消息后会拒绝任期更低的请求,所以节点A的选举不会成功,可以直接返回了。
GetVoteAnswer函数,这个函数的作用是向其他节点发送RPC请求,获取RPC回复并且进行相应操作:
func (rf *Raft) GetVoteAnswer(server int, args *RequestVoteArgs) bool {
sendArgs := *args
reply := RequestVoteReply{}
ok := rf.sendRequestVote(server, &sendArgs, &reply)
if !ok {
return false
}
rf.mu.Lock()
defer rf.mu.Unlock()
if sendArgs.Term != rf.currentTerm {
// 函数调用的间隙被修改了
return false
}
if reply.Term > rf.currentTerm {
// 已经是过时的term了
rf.currentTerm = reply.Term
rf.votedFor = -1
rf.role = Follower
}
return reply.VoteGranted
}
由于我们之前在Elect函数中构造出来了args参数,之后的函数调用链将这个参数一路传递下去,所以我们现在直接拿过来用就好了。但是这里要注意,我们只能传递一个拷贝给RPC接口,这是因为在并发环境中,args可能被多个协程同时访问和修改。如果不创建拷贝,当多个协程同时处理同一个args时,会导致数据竞争和不确定的行为。通过创建拷贝,每个协程都使用自己的参数副本,避免了并发访问的问题,确保了数据的一致性和安全性。
我们在收到回复之后,如果对应服务器给Candidate回应的任期大于Candidate的任期,那么说明当前节点还没有资历成为Leader,所以退化为Follower并且更新自己的任期,重置选票。
注意其中的这段代码:
if sendArgs.Term != rf.currentTerm {
// 函数调用的间隙被修改了
return false
}
这个sendArgs不是对于args 的拷贝吗,而args的Term就是使用rf.currentTerm初始化的,二者理应相等,为什么会有这个判断条件呢?
其实类似于之前的collectVote函数中的这段逻辑:if rf.voteCount > len(rf.peers)/2,这段逻辑在上面有详细解释,当前这个if sendArgs.Term != rf.currentTerm不等的原因可能是在下面的判断中收到了任期更大的节点的回复,导致自己任期和更大的节点同步,所以在之后其他协程的判断中这里就可以直接返回,不必进行多余处理。
这个判断条件的作用是确保在发送请求和接收响应之间,节点的term没有发生变化,从而保证选举过程的一致性和正确性。如果term发生变化,说明在请求发送期间,节点可能已经转变为Follower或收到了更高term的请求,此时应该放弃当前的选举请求。
投票接收节点调用RequestVote函数处理接收投票逻辑,代码如下:
func (rf *Raft) RequestVote(args *RequestVoteArgs, reply *RequestVoteReply) {
// Your code here (2A, 2B).
rf.mu.Lock()
if args.Term < rf.currentTerm {
// 旧的term
// 1. Reply false if term < currentTerm (§5.1)
reply.Term = rf.currentTerm
rf.mu.Unlock()
reply.VoteGranted = false
DPrintf("server %v 拒绝向 server %v投票: 旧的term: %v,\n\targs= %+v\n", rf.me, args.CandidateId, args.Term, args)
return
}
// 代码到这里时, args.Term >= rf.currentTerm
if args.Term > rf.currentTerm {
// 已经是新一轮的term, 之前的投票记录作废
rf.votedFor = -1
rf.currentTerm = args.Term
rf.state = Follower
}
if rf.votedFor == -1 || rf.votedFor == args.CandidateId {
if args.Term > rf.log[len(rf.log)-1].Term || (args.LastLogIndex >= len(rf.log)-1 && args.LastLogTerm >= rf.log[len(rf.log)-1].Term) {
rf.currentTerm = args.Term
rf.votedFor = args.CandidateId
rf.state = Follower
rf.timeStamp = time.Now()
reply.Term = rf.currentTerm
rf.mu.Unlock()
reply.VoteGranted = true
DPrintf("server %v 同意向 server %v投票\n\targs= %+v\n", rf.me, args.CandidateId, args)
return
}
} else {
DPrintf("server %v 拒绝向 server %v投票: 已投票\n\targs= %+v\n", rf.me, args.CandidateId, args)
}
reply.Term = rf.currentTerm
rf.mu.Unlock()
reply.VoteGranted = false
}
下面解释一下这段代码的相关逻辑:
-
如果
args.Term < rf.currentTerm,那么当前服务器不应该接收任期更低的服务器的投票请求,应该拒绝投票,并且在回复中告知自己有更高的任期,提醒Candidate同步使用更高的任期。 -
如果
args.Term > rf.currentTerm,标识请求的term比当前节点的term高,说明发起请求的节点已经进入了新的term。由于在Raft中每个节点在一个任期内只能进行一次投票,那么当收到节点的更新时,就需要更新自己的term并转变为Follower,以确保系统的一致性。 -
下面这个if语句有两次判断逻辑,第一层是
if rf.votedFor == -1 || rf.votedFor == args.CandidateId,前面这个判断条件好理解,在我们的代码中voteFor == -1表示之前没有投过票,理所应该地可以进行当前投票,但是rf.votedFor == args.CandidateId这个判断条件怎么理解呢,不是已经投票给Candidate了吗?这其实是一种特殊情况:如果发生了网络问题,当前节点已经给Candidate投过票了,但是因为网络问题,导致回应没传给Candidate,之后Candidate超时选举时间又到了,于是重新开始选举,由于该节点之前给Candidate投过票了,那么该节点还是会同意给Candidate投票。 -
接下来这个if语句:
if args.Term > rf.log[len(rf.log)-1].Term || (args.LastLogIndex >= len(rf.log)-1 && args.LastLogTerm >= rf.log[len(rf.log)-1].Term)是按照上图RPC设计图来设计的,当前节点最后一条日志的任期比
args的任期小时;或者二者任期相等,但是当前节点的最后一条日志索引小于args中的LastLogIndex参数,此时都可以对Candidate投票,因为这表面了Candidate的日志比当前节点更有权威。 -
注意我们只能在这个投票逻辑中更新选举超时时间戳的值,因为该节点已经有了一个可以信任的
Candidate,没必要自己再去进行选举,浪费资源,因为但凡可以给该节点投票的服务器都会给该节点信任的Candidate投票,由上述判断条件可知。 -
如果不满足进行投票的if分支的话就不投票,但是还是要向
Candidate汇报自己的Term信息,因为我们需要维护Term是单调递增的,有时候该节点会形成一个网络分区(通信受限制),那么该节点只能与有限几个服务器通信,而且可能收不到Leader的心跳,就会触发选举超时机制,但是由于该节点只能与有限几个服务器通信,那么它不可能获得半数服务器以上的支持,所以会选举失败,然后选举又超时,一直重试,导致自己的任期不断变大,所以当网络恢复之后,就会导致自己的任期格外大。此外还有一些其他的特殊情况,所以我们在心跳或者选举这些通信过程中需要时刻交换Term信息,以维持系统一致性。
接下来是心跳函数的设计,我们在collectVote函数中的这段代码go rf.sendHeartbeats(),就是心跳的入口函数,启动一个协程不断向其他服务器发送心跳:
func (rf *Raft) SendHeartBeats() {
DPrintf("server %v 开始发送心跳\n", rf.me)
for !rf.killed() {
rf.mu.Lock()
if rf.role != Leader {
rf.mu.Unlock()
return
}
args := &AppendEntriesArgs{
Term: rf.currentTerm,
LeaderId: rf.me,
PrevLogIndex: 0,
PrevLogTerm: 0,
Entries: nil,
LeaderCommit: rf.commitIndex,
}
rf.mu.Unlock()
for i := 0; i < len(rf.peers); i++ {
if i == rf.me {
continue
}
go rf.handleHeartBeat(i, args)
}
time.Sleep(time.Duration(HeartBeatTimeOut) * time.Millisecond)
}
}
在当前Leader选举功能中,我们不需要携带任何日志项,所以PrevLogIndex和PrevLogTerm都是0,表示每个服务器的前一个日志都在0号索引位置,而0号索引位置的日志项是我们手动添加的空日志。在循环中我们需要检测当前服务器是否还是Leader,如果不是的话就应该从心跳协程中退出。这可能是由于当前Leader发生了网络分区,然后其他服务器选出新的Leader,该服务器从网络分区恢复之后,收到新Leader的心跳发现其任期更大,所以转化为Follower。
然后是go rf.handleHeartBeat(i, args)代码中Leader给每个服务器发送心跳的处理函数:
func (rf *Raft) handleHeartBeat(serverTo int, args *AppendEntriesArgs) {
reply := &AppendEntriesReply{}
sendArgs := *args
ok := rf.sendAppendEntries(serverTo, &sendArgs, reply)
if !ok {
return
}
rf.mu.Lock()
defer rf.mu.Unlock()
if sendArgs.Term != rf.currentTerm {
return
}
if reply.Term > rf.currentTerm {
DPrintf("server %v 旧的leader收到了心跳函数中更新的term: %v, 转化为Follower\n", rf.me, reply.Term)
rf.currentTerm = reply.Term
rf.votedFor = -1
rf.role = Follower
}
}
这个函数就是调用了对应服务器的RPC,然后处理返回值,其中if sendArgs.Term != rf.currentTerm这个判断与之前GetVoteAnswer函数的相同,在此不进行过多赘述。
最后我们看到心跳接收方的函数AppendEntires():
func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
// Your code here (2A, 2B).
rf.mu.Lock()
if args.Term < rf.currentTerm {
reply.Term = rf.currentTerm
rf.mu.Unlock()
reply.Success = false
return
}
rf.timeStamp = time.Now()
if args.Term > rf.currentTerm {
rf.currentTerm = args.Term
rf.votedFor = -1
rf.role = Follower
}
if args.Entries == nil {
DPrintf("server %v 接收到 leader &%v 的心跳\n", rf.me, args.LeaderId)
}
if args.Entries != nil &&
(args.PrevLogIndex >= len(rf.log) || rf.log[args.PrevLogIndex].Term != args.PrevLogTerm) {
reply.Term = rf.currentTerm
rf.mu.Unlock()
reply.Success = false
return
}
reply.Success = true
reply.Term = rf.currentTerm
if args.LeaderCommit > rf.commitIndex {
rf.commitIndex = int(math.Min(float64(args.LeaderCommit), float64(len(rf.log)-1)))
}
rf.mu.Unlock()
}
我们来看一下这段代码的相关逻辑,其实是完全按照上面的RPC过程图来的:
- 先判断当前服务器的任期是否比
args的任期大,如果更大的话,说明此时的Leader不权威,需要重新进行选举,所以给Leader回应心跳失败,之后Leader就会退化为Follower,然后等待有服务器超时进行选举。 - 在经历上一个判断条件之后,当前服务器的任期肯定比
args的任期小,那么说明Leader具有权威,是个合法的心跳,那么我们此时就可以更新我们的时间戳。 if args.Term > rf.currentTerm这段代码表示当前服务器第一次收到来自Leader的心跳,原因如下:本来Leader在之前的选举过程中,就会发送选票请求给其他服务器,在这个过程中会同步其他服务器的任期与Leader相同(如果Leader任期更大的话),但是Leader在收到半数服务器的选票之后就可以直接当选,可能有些服务器并没有收到来自Leader的选票消息导致自己任期落后(网络分区),所以这个条件就是对于那些在选举阶段没有收到Leader消息的服务器去同步任期的。同步任期之后还需要重置选票,因为来到了新的任期,每个任期都有一次投票机会。- 接下来是添加日志的判断条件,在此时
Leader选举部分没有涉及,在后续log复制实验中会实现。 reply.Term = rf.currentTerm是同步任期的字段,在每个服务器的通信过程中都有体现,是为了维护任期的一致性。- 最后是检查日志的
commitIndex,同样是log复制实验的内容。
在完成实验之后进行测试: