MIT6.824 Lab2A - Raft 选举

本文是 MIT6.824 Lab2A 的实现思路,将不会讲 Raft 算法的具体细节,大家可以先看看论文、课程、实验文档,也可以看看我对 Raft 算法选举部分的总结:《Raft 算法总结------关于选举和心跳》

MIT6.824 Lab2A 要我们做什么?

  • Lab2A 中,在 /mit6.824-lab/src/raft 目录下运行 go test -run 2A 便会开始 Lab2A 的测试。其中,TestInitialElection2A() TestReElection2A() TestManyElections2A() 会分别测试
    • TestInitialElection2A():能否正确选举出 Leader 并且维持 Leader 的领导地位
    • TestReElection2A() :能否在领导人失效或网络故障情况下能够正确进行重新选举
    • TestReElection2A() :在多次网络故障和恢复的情况下,能够正确进行领导人选举
  • 实际上,我们只需要把《Raft 算法总结------关于选举和心跳》中提到的东西实现就好了。
    • Lab2A 的几乎所有代码实现都在raft.go

节点初始化

在测试开始时,会调用 func Make(peers []*labrpc.ClientEnd, me int,persister *Persister, applyCh chan ApplyMsg) *Raft 方法进行初始化, 这里给出每一个节点的初始数据结构(参考论文第5节)

  1. 所有服务器上的持久性状态(在响应 RPC 请求之前,已经更新到了稳定的存储设备)
参数 解释
currentTerm 服务器已知最新的任期(在服务器首次启动时初始化为0,单调递增)
votedFor 当前任期内收到选票的 candidateId,如果没有投给任何候选人 则为空
log[] 日志条目;每个条目包含了用于状态机的命令,以及领导人接收到该条目时的任期(初始索引为1)
  1. 所有服务器上的易失性状态
参数 解释
commitIndex 已知已提交的最高的日志条目的索引(初始值为0,单调递增)
lastApplied 已经被应用到状态机的最高的日志条目的索引(初始值为0,单调递增)
  1. 领导人(服务器)上的易失性状态(选举后已经重新初始化)
参数 解释
nextIndex[] 对于每一台服务器,发送到该服务器的下一个日志条目的索引(初始值为领导人最后的日志条目的索引+1)
matchIndex[] 对于每一台服务器,已知的已经复制到该服务器的最高日志条目的索引(初始值为0,单调递增)

也即每个 Raft 节点需要这样一个数据结构

除了上面提到的,我们还额外增加了下面的属性(具体含义已经写在注释里了)

go 复制代码
// Others
status          Status        // 当前节点状态
electionTimeout time.Duration // 追随者在成为候选人之前等待的时间
timer           *time.Ticker  // 计时器
  • make() 函数中,我们除了对上面的属性进行初始化之外,另外一个很重要的工作就是设置定时器,并 "start ticker goroutine to start elections".

    go 复制代码
    rf.electionTimeout = time.Duration(150+rand.Intn(150)) * time.Millisecond
    rf.timer = time.NewTicker(rf.electionTimeout)
    // start ticker goroutine to start elections
    go rf.ticker()

    关于 Election Timeout 的含义请参考论文.

发送投票请求

投票请求结构体

go 复制代码
// example RequestVote RPC arguments structure.
// field names must start with capital letters!
// 投票请求的结构体
type RequestVoteArgs struct {
	// Your data here (2A, 2B).
	Term         int // 候选人所在的任期
	CandidateId  int // 请求选票的候选人的 ID
	LastLogIndex int // 候选人的最后日志条目的索引值
	LastLogTerm  int // 候选人最后日志条目的任期号
}

发送投票请求

func (rf *Raft) ticker()函数中,当定时器被触发,也就是 electionTimeout 时间到了,此时 Follower 应该切换为 Candidate,并向其他节点发送投票请求,如下:

go 复制代码
func (rf *Raft) ticker() {
	for !rf.killed() {
		// Your code here (2A)
		// Check if a leader election should be started.
		<-rf.timer.C
		// 定时器被触发
		if rf.killed() {
			return
		}
		rf.mu.Lock()
		switch rf.status {
		// 当前为跟随者,触发定时器意味着已经等待了electionTimeout,状态变化为竞选者
		case Follower:
			rf.status = Candidate
			fallthrough // 继续执行下一个分支
		// 当前为候选人状态,把选票投给自己,并行地向集群中的其他服务器节点发送请求投票的 RPCs 来给自己投票
		case Candidate:
			// 任期 +1
			rf.currentTerm++
			// 投票给自己
			rf.votedFor = rf.me
			// 收到的选票数
			votedNums := 1
			// 开启新的选举任期
			rf.electionTimeout = time.Duration(150+rand.Intn(150)) * time.Millisecond
			rf.timer.Reset(rf.electionTimeout)
			// 并行地向集群中的其他服务器节点发送请求投票的 RPCs 来给自己投票
			for i := 0; i < len(rf.peers); i++ {
				if i == rf.me {
					continue
				}
				voteArgs := RequestVoteArgs{
					Term:         rf.currentTerm,
					CandidateId:  rf.me,
					LastLogIndex: len(rf.logs) - 1,
					LastLogTerm:  0,
				}
				voteReply := RequestVoteReply{}
				// fmt.Printf("[ticker(%v)] send a voting request to %v\n", rf.me, i)
				go rf.sendRequestVote(i, &voteArgs, &voteReply, &votedNums)
			}
		// 当前为领导者,进行心跳/日志同步
		case Leader:
			// TODO
		}
		rf.mu.Unlock()
	}
}

处理投票请求

当其他节点接收到来自 Candidate 的投票请求时,需要判断能否将自己的那一票投给当前 Candidate

  1. 如果当前节点所处的任期大于 Candidate 的任期,也即是 args.Term > rf.currentTerm,说明 Candidate 已经过时了,不投票.并且把当前的任期返回给 Candidate
  2. 如果 Candidate 的最后一条日志的索引小当前节点的索引,也即 args.LastLogIndex < currentLogIndex,说明 Candidate 的日志落后但前节点,不能成为 Leader
  3. 如果 Candidate 最后一条日志的任期小于当前节点的任期,也即 args.LastLogTerm < currentLogTerm,说明Candidate不包含了最新操作的日志,拒绝投票 具体实现如下:
go 复制代码
func (rf *Raft) RequestVote(args *RequestVoteArgs, reply *RequestVoteReply) {
	// Your code here (2A, 2B).
	rf.mu.Lock()
	defer rf.mu.Unlock()
	// 当前节点crash
	if rf.killed() {
		reply.Term = -1
		reply.VoteGranted = false
		reply.Replystatus = Clash
		return
	}
	// 该竞选者已经过时
	if args.Term < rf.currentTerm {
		// 告诉该竞选者当前的 Term
		reply.Term = rf.currentTerm
		reply.VoteGranted = false
		reply.Replystatus = Outdated
		return
	}

	if args.Term > rf.currentTerm {
		// 重置自身的状态
		rf.status = Follower
		rf.currentTerm = args.Term
		// 单有 args.Term > rf.currentTerm 还不能直接投票
		rf.votedFor = -1
	}
	// 如果 args.Term > rf.currentTerm
	if rf.votedFor == -1 {
		currentLogIndex := len(rf.logs) - 1
		currentLogTerm := 0
		if currentLogIndex >= 0 {
			currentLogTerm = rf.logs[currentLogIndex].Term
		}
		// 如果不能满足 args.LastLogIndex < currentLogIndex  args.LastLogTerm < currentLogTerm 任一条件,都不能投票
		// 请求投票(RequestVote) RPC 实现了这样的限制:RPC 中包含了候选人的日志信息,然后投票人会拒绝掉那些日志没有自己新的投票请求。
		// Raft 通过比较两份日志中最后一条日志条目的索引值和任期号定义谁的日志比较新。如果两份日志最后的条目的任期号不同,那么任期号大的日志更加新。\
		// 如果两份日志最后的条目任期号相同,那么日志比较长的那个就更加新。
		if args.LastLogIndex < currentLogIndex || args.LastLogTerm < currentLogTerm {
			// 拒绝投票
			reply.VoteGranted = false
			reply.Term = rf.currentTerm
			reply.Replystatus = Outdated
			return
		}
		// 满足所有条件,投票
		rf.votedFor = args.CandidateId
		reply.Term = rf.currentTerm
		reply.VoteGranted = true
		reply.Replystatus = Normal
		// 重置 electionTimeout
		rf.timer.Reset(rf.electionTimeout)
		// fmt.Printf("[func-RequestVote-rf(%v)] voted rf[%v]\n", rf.me, rf.votedFor)
	} else {
		// 如果 args.Term = rf.currentTerm
		reply.VoteGranted = false
		reply.Replystatus = Voted
		// 票已经给了同一轮选举的另外的竞争者
		if rf.votedFor != args.CandidateId {
			return
		} else {
			// 票已经给过当前发送请求的节点了
			rf.status = Follower
		}
		rf.timer.Reset(rf.electionTimeout)
	}
}

处理投票结果

在Lab2A的实现中,我把处理结果的逻辑一同放在了func (rf *Raft) sendRequestVote(server int, args *RequestVoteArgs, reply *RequestVoteReply, votedNums *int) bool 中,具体如下

  1. 当前竞选者过期了,回到 Follower 状态.重置 election timeout
  2. 选票到达多数派,本身不是 Leader,改变状态,初始化 nextIndex[],上任 Leader
  3. 选票没有到达多数派,不做处理
go 复制代码
switch reply.Replystatus {
    // 接收请求的节点 clash
    case Clash:
    {
        ok = false
    }
    // 当前竞选者过期了
    case Outdated:
    {
        rf.status = Follower
        rf.timer.Reset(rf.electionTimeout)
        if rf.currentTerm < reply.Term {
            rf.currentTerm = reply.Term
        }
    }
    // 正常的选举(获得选票/请求的节点已经把票给出去了)
    case Voted, Normal:
    {
        if reply.VoteGranted && *votedNums <= (len(rf.peers)/2) {
            *votedNums++
        }
        // 如果选票达到多数派
        if *votedNums >= (len(rf.peers)/2)+1 {
            *votedNums = 0
            // 本身不是leader,改变状态,初始化 nextIndex[]
            if rf.status != Leader {
                rf.status = Leader
                rf.nextIndex = make([]int, len(rf.peers))
                for i, _ := range rf.nextIndex {
                    rf.nextIndex[i] = len(rf.logs) + 1
                }
                rf.timer.Reset(HeartBeatTimeout)
                // fmt.Printf("[sendRequestVote-func-rf(%v)] Reaching the majority and becoming the leader\n", rf.me)
            }
        }
    }
}

发送心跳

Leader成功当选之后,就要想其他节点发送心跳以维持自己的领导地位.

心跳请求的结构

go 复制代码
type AppendEntriesArgs struct {
	Term         int        // leader 任期
	LeaderId     int        // 领导人id
	PrevLogIndex int        // 紧邻新日志条目之前的那个日志条目的索引
	PrevLogTerm  int        // 紧邻新日志条目之前的那个日志条目的任期
	Entries      []LogEntry // 需要被保存的日志条目(被当做心跳使用时,则日志条目内容为空;为了提高效率可能一次性发送多个)
	LeaderCommit int        // 领导人的已知已提交的最高的日志条目的索引
}

实际上,同步日志也通过 AppendEntriesArgs 实现,当发送单纯的心跳是时, Entries 为空.关于日志同步,我们等 Lab2B的时候再展开讨论

发送心跳请求

  • 当 Leader 上任之后,会把定时器重置为心跳时间,当定时器被触发,就发送心跳请求,如下
go 复制代码
case Leader:
    // 重置心跳
    rf.timer.Reset(HeartBeatTimeout)
    // 构造心跳请求
    for i := 0; i < len(rf.peers); i++ {
    if i == rf.me {
    continue
    }
    appendEntriesArgs := AppendEntriesArgs{
    Term:         rf.currentTerm,
    LeaderId:     rf.me,
    PrevLogIndex: 0, // 单纯的心跳不会产生日志
    PrevLogTerm:  0,
    Entries:      nil,
    LeaderCommit: rf.commitIndex,
    }
    appendEntriesReply := AppendEntriesReply{}
    // fmt.Printf("[ticker(%v)] send a append entries to %v\n", rf.me, i)
    go rf.sendAppendEntries(i, &appendEntriesArgs, &appendEntriesReply)
}

处理心跳请求

  1. 心跳发起者的任期已经过时了 args.PrevLogTerm < rf.currentTerm,返回最新的任期
  2. 否则重置 electionTimeout, 防止在 leader 没有出现异常的情况下开启新一轮的选举
go 复制代码
// 处理心跳请求、同步日志RPC
func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
	// 节点crash
	if rf.killed() {
		reply.AppendStatus = Killed
		reply.Term = -1
		reply.Success = false
		return
	}
	// 心跳发起者的任期已经过时了
	if args.PrevLogTerm < rf.currentTerm {
		reply.AppendStatus = Expire
		reply.Term = rf.currentTerm
		reply.Success = false
	}
	// 重置 electionTimeout, 防止在 leader 没有出现异常的情况下开启新一轮的选举
	rf.currentTerm = args.Term
	rf.votedFor = args.LeaderId
	rf.status = Follower
	rf.timer.Reset(rf.electionTimeout)
	reply.AppendStatus = AppendEntriesNormal
	reply.Term = rf.currentTerm
	reply.Success = true
}

处理心跳响应

  • 这里的逻辑就很简单了(单纯处理心跳的话),不再赘述
go 复制代码
switch reply.AppendStatus {
    case Killed:
    {
        return false
    }
    case Expire:
    {
        rf.currentTerm = reply.Term
        rf.status = Follower
        rf.votedFor = -1
        rf.timer.Reset(rf.electionTimeout)
    }
    case AppendEntriesNormal:
    return true
}

写在最后

  • 至此,lab2A 的内容就全部做完啦!运行测试,希望你能看到下面的输出!
相关推荐
A_Tai2333333几秒前
贪心算法解决用最少数量的箭引爆气球问题
算法·贪心算法
唐叔在学习12 分钟前
【唐叔学算法】第19天:交换排序-冒泡排序与快速排序的深度解析及Java实现
java·算法·排序算法
_nirvana_w_12 分钟前
C语言实现常用排序算法
c语言·算法·排序算法
唐叔在学习20 分钟前
【唐叔学算法】第18天:解密选择排序的双重魅力-直接选择排序与堆排序的Java实现及性能剖析
数据结构·算法·排序算法
Kenneth風车1 小时前
【机器学习(九)】分类和回归任务-多层感知机(Multilayer Perceptron,MLP)算法-Sentosa_DSML社区版 (1)11
算法·机器学习·分类
最后一个bug1 小时前
rt-linux中使用mlockall与free的差异
linux·c语言·arm开发·单片机·嵌入式硬件·算法
Kobebryant-Manba1 小时前
kafka基本概念
分布式·学习·kafka
蹉跎x2 小时前
力扣1358. 包含所有三种字符的子字符串数目
数据结构·算法·leetcode·职场和发展
rainoway2 小时前
CRDT宝典 - yata算法
前端·分布式·算法
hanbarger2 小时前
分布式通信,微服务协调组件,zookeeper
分布式·zookeeper·中间件