本文是 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
中
- Lab2A 的几乎所有代码实现都在
节点初始化
在测试开始时,会调用 func Make(peers []*labrpc.ClientEnd, me int,persister *Persister, applyCh chan ApplyMsg) *Raft
方法进行初始化, 这里给出每一个节点的初始数据结构(参考论文第5节)
- 所有服务器上的持久性状态(在响应 RPC 请求之前,已经更新到了稳定的存储设备)
参数 | 解释 |
---|---|
currentTerm | 服务器已知最新的任期(在服务器首次启动时初始化为0,单调递增) |
votedFor | 当前任期内收到选票的 candidateId,如果没有投给任何候选人 则为空 |
log[] | 日志条目;每个条目包含了用于状态机的命令,以及领导人接收到该条目时的任期(初始索引为1) |
- 所有服务器上的易失性状态
参数 | 解释 |
---|---|
commitIndex | 已知已提交的最高的日志条目的索引(初始值为0,单调递增) |
lastApplied | 已经被应用到状态机的最高的日志条目的索引(初始值为0,单调递增) |
- 领导人(服务器)上的易失性状态(选举后已经重新初始化)
参数 | 解释 |
---|---|
nextIndex[] | 对于每一台服务器,发送到该服务器的下一个日志条目的索引(初始值为领导人最后的日志条目的索引+1) |
matchIndex[] | 对于每一台服务器,已知的已经复制到该服务器的最高日志条目的索引(初始值为0,单调递增) |
也即每个 Raft 节点需要这样一个数据结构
除了上面提到的,我们还额外增加了下面的属性(具体含义已经写在注释里了)
go
// Others
status Status // 当前节点状态
electionTimeout time.Duration // 追随者在成为候选人之前等待的时间
timer *time.Ticker // 计时器
-
在
make()
函数中,我们除了对上面的属性进行初始化之外,另外一个很重要的工作就是设置定时器,并 "start ticker goroutine to start elections".gorf.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
- 如果当前节点所处的任期大于 Candidate 的任期,也即是
args.Term > rf.currentTerm
,说明 Candidate 已经过时了,不投票.并且把当前的任期返回给Candidate
- 如果
Candidate
的最后一条日志的索引小当前节点的索引,也即args.LastLogIndex < currentLogIndex
,说明Candidate
的日志落后但前节点,不能成为Leader
- 如果 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
中,具体如下
- 当前竞选者过期了,回到
Follower
状态.重置election timeout
- 选票到达多数派,本身不是
Leader
,改变状态,初始化nextIndex[]
,上任Leader
- 选票没有到达多数派,不做处理
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)
}
处理心跳请求
- 心跳发起者的任期已经过时了
args.PrevLogTerm < rf.currentTerm
,返回最新的任期 - 否则重置 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 的内容就全部做完啦!运行测试,希望你能看到下面的输出!