MIT6.824(2024春)Raft-lab3A代码分析

72 阅读18分钟

lab3A--Leader选举

我们需要在lab3中重现论文中的Raft算法,因此强烈建议在开启实验之前先通读一遍Raft论文,我做这个lab的时候是按照官方给出的时间表进行的,逐步完善各个模块。这一部分中我们先完成第一个模块:Leader选举

下面给出一些我在做lab时遇到困惑时的参考资料:Vanilla Beauty仓库地址,这个仓库的代码写的很好,代码设计思路讲的也十分通透。本文相对于这个仓库的代码讲解,更侧重于一些模块的详细设计思路和边界情况。

个人代码仓库

任务简介

在Raft论文中,给出了一张十分关键的图,阐释了Raft各个模块的组件:

image.png

这个实验要求我们完成Raft中的Leader选举心跳函数机制,根据上图和Raft论文可知:

Leader如果是正常运行的话,会不断发送AppendEntries RpcFollower,Follower收到之后对Leader进行相应回复,其中心跳在AppendEntries Rpc中的entires[]字段是空的。注意在收到心跳之后需要重置自己的选举超时时间,当前阶段我们还不需要进行日志相关的操作。

对于Leader选举,我们有如下要求:

  • 当指定的心跳间隔到期时, Follower转化为Candidate并开始进行投票选举, 会为自己投票并自增term
  • 每一个收到Candidate投票请求的服务器会根据上图判断RPC参数是否符合给它投票的要求,符合的话就投票。
  • 一个server在每一个任期只能投一次票,所以投票之后要记录是否已经投过票了,而且在每次任期更新时都重置投票权力。
  • 超过一半的Server的投票将选举出新的Leader, 新的Leader通过心跳AppendEntries RPC宣告自己的存在, 收到心跳的Server更新自己的状态(变为Follower,重置投票,更新任期)。
  • 若超时时间内无新的Leader产生, 再进行下一轮投票, 为了避免这种情况, 应当给不同Server的投票超时设定随机值。

官方提示

  • 我们需要将领导者选举的状态添加到Raft结构体中,这样每个服务器就具备了投票和参选的能力。
  • 我们还需要填写RequestVoteArgsRequestVoteReply 结构体,因为在func (*rf* *Raft) RequestVote(*args* *RequestVoteArgs, *reply* *RequestVoteReply)这个RPC函数中我们是通过args传递投票请求,通过reply传递对Candidate的回应。
  • 要实现心跳机制的话需要创建一个AppendEntries结构体,并且让Leader定期发送这个结构体给Follower
  • 需要一个单独的协程不断检查在投票间隔内是否收到AppendEntriesRPC,如果没有收到的话则变为Candidate准备进行选票。
  • 官方的框架代码中明确了应该使用ticker实现选举。

代码设计

首先我们需要添加LeaderFollowerCandidate字段和日志字段:

// 设置基本状态
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()协程进行选票,这里有两个问题需要我们注意:

  1. 检测选举超时的间隔和随机选举超时时间都必须要大于心跳,而且前者必须小于后者?这是因为心跳时间设计较短为了及时发现Leader故障,防止Follower因为长时间没收到心跳而发起不必要的选举(因为可能出现网络丢包等特殊情况)。检查间隔设计比心跳时间长是为了避免频繁的检查,比随机选举超时时间短是为了确保能够及时发现选举,否则time.Sleep(ElectTimeOutCheckInterval)就会‘睡过头’。随机选举超时时间最长是为了确保在正常情况下不会触发选举,只有在较长时间(相对于心跳)没接收到心跳时才会进行选举。
  2. 选举为什么单开一个协程而不是调用函数?因为选举需要向所有的节点发送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
}

下面解释一下这段代码的相关逻辑:

  1. 如果args.Term < rf.currentTerm,那么当前服务器不应该接收任期更低的服务器的投票请求,应该拒绝投票,并且在回复中告知自己有更高的任期,提醒Candidate同步使用更高的任期。

  2. 如果args.Term > rf.currentTerm,标识请求的term比当前节点的term高,说明发起请求的节点已经进入了新的term。由于在 Raft中每个节点在一个任期内只能进行一次投票,那么当收到节点的更新时,就需要更新自己的term并转变为Follower,以确保系统的一致性。

  3. 下面这个if语句有两次判断逻辑,第一层是if rf.votedFor == -1 || rf.votedFor == args.CandidateId,前面这个判断条件好理解,在我们的代码中voteFor == -1表示之前没有投过票,理所应该地可以进行当前投票,但是rf.votedFor == args.CandidateId这个判断条件怎么理解呢,不是已经投票给Candidate 了吗?这其实是一种特殊情况:如果发生了网络问题,当前节点已经给Candidate投过票了,但是因为网络问题,导致回应没传给Candidate,之后Candidate超时选举时间又到了,于是重新开始选举,由于该节点之前给Candidate投过票了,那么该节点还是会同意给Candidate投票。

  4. 接下来这个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的日志比当前节点更有权威。

  5. 注意我们只能在这个投票逻辑中更新选举超时时间戳的值,因为该节点已经有了一个可以信任的Candidate,没必要自己再去进行选举,浪费资源,因为但凡可以给该节点投票的服务器都会给该节点信任的Candidate投票,由上述判断条件可知。

  6. 如果不满足进行投票的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选举功能中,我们不需要携带任何日志项,所以PrevLogIndexPrevLogTerm都是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过程图来的:

  1. 先判断当前服务器的任期是否比args的任期大,如果更大的话,说明此时的Leader不权威,需要重新进行选举,所以给Leader回应心跳失败,之后Leader就会退化为Follower,然后等待有服务器超时进行选举。
  2. 在经历上一个判断条件之后,当前服务器的任期肯定比args的任期小,那么说明Leader具有权威,是个合法的心跳,那么我们此时就可以更新我们的时间戳。
  3. if args.Term > rf.currentTerm这段代码表示当前服务器第一次收到来自 Leader的心跳,原因如下:本来Leader在之前的选举过程中,就会发送选票请求给其他服务器,在这个过程中会同步其他服务器的任期与Leader相同(如果Leader任期更大的话),但是Leader在收到半数服务器的选票之后就可以直接当选,可能有些服务器并没有收到来自Leader的选票消息导致自己任期落后(网络分区),所以这个条件就是对于那些在选举阶段没有收到Leader消息的服务器去同步任期的。同步任期之后还需要重置选票,因为来到了新的任期,每个任期都有一次投票机会。
  4. 接下来是添加日志的判断条件,在此时Leader选举部分没有涉及,在后续log复制实验中会实现。
  5. reply.Term = rf.currentTerm是同步任期的字段,在每个服务器的通信过程中都有体现,是为了维护任期的一致性。
  6. 最后是检查日志的commitIndex,同样是log复制实验的内容。

在完成实验之后进行测试:

image-20250530091840266