MIT6.824-lab2A-2022篇(万字推导思路及代码构建)


前言

趁着五一没啥事情,花两天把lab2A写一下…还是老样子,希望能读者能有自己的 思路构建后,再来看本篇会更有收获。


一、学习背景

lab2主要为分为A,B,C 分别是raft的领导选举,状态机日志复制,以及持久化实现,具体难度等我把全部做完再来比较吧,也希望自己能坚持下去…。做实验之前一定要把论文的第1、2节看完,对共识算法,以及raft的引入有个大概的认知。以及论文的第5节看完,这是实验的实现思路,当然只看论文估计也很难理解这里给出额外笔者在b站上找的理解视频或者辅助学习链接。

以及lab的Introduction一定要多看,上面给了很多的hint算是比较全了,以及代码测试时如何进行的,也都是在其中都有。

二、实验引入

因为做这个实验的时候总是会代入上一个实验MapReduce实验,因为目标都是实现一个框架,但是实际做的时候发现还是很多不同的。例如这个实验其实某个进入函数去make这个raft节点,不像lab1的worker之类,而是直接在测试代码里进行make
对于2A中调用的测试func分别为TestInitialElection2A、TestReElection2A、TestManyElections2A。而仔细看这三者方法的话会发现其中有一个共同点。那就都调用了make_config方法。而这个方法也就是我们构造出raft节点的方法。值得一提的是,测试代码里面会调用raft.go中的getState方法,判断你当前的任期和是否是领导人。具体方法应该具体根据自己的结构体实现。知道方法是怎么引入后,接下来就来实现我们的raft.go。

func (rf *Raft) GetState() (int, bool) {
    
    

	rf.mu.Lock()
	defer rf.mu.Unlock()
	var term int
	var isleader bool
	// Your code here (2A).
	term = rf.currentTerm
	//fmt.Println("the peer[", rf.me, "] state is:", rf.state)
	if rf.state == Leader {
    
    
		isleader = true
	} else {
    
    
		isleader = false
	}
	return term, isleader
}

三、结构体实现

3.1 State的定义

对于大致的结构体其实论文中的表格以及给的很清楚了,我们只需要补充部分细节。
对于server应有的状态:
在这里插入图片描述
由此我们可以在raft中定义如上几个变量(对于表格中的字段的个人理解,以注释方式写在代码中了)


type State int

// 枚举节点的类型:跟随者、竞选者、领导者
const (
	Follower State = iota
	Candidate
	Leader
)

// LogEntry log条目
type LogEntry struct {
    
    
	Term    int         // leader收到日志条目时的任期
	Command interface{
    
    } // 状态机的command
}

type Raft struct {
    
    
	mu        sync.Mutex          // Lock to protect shared access to this peer's state
	peers     []*labrpc.ClientEnd // RPC end points of all peers
	persister *Persister          // Object to hold this peer's persisted state
	me        int                 // this peer's index into peers[]
	dead      int32               // set by Kill()

	// Your data here (2A, 2B, 2C).
	// Look at the paper's Figure 2 for a description of what
	// state a Raft server must maintain.

	// 所有的servers拥有的持续状态量
	currentTerm int        // 记录当前的任期
	votedFor    int        // 记录当前的任期把票投给了谁
	logs        []LogEntry // 日志条目数组,包含了状态机要执行的指令集,以及收到领导时的任期号

	// 所有的servers需要立即可见的变量(volatile )
	// 正常情况下commitIndex与lastApplied应该是一样的,但是如果有一个新的提交,并且还未应用的话last应该要更小些
	commitIndex int // 状态机中已知的被提交的日志条目的索引值(初始化为0,持续递增)
	lastApplied int // 最后一个被追加到状态机日志的索引值

	// leader拥有的可见变量,用来管理他的follower
	// nextIndex与matchIndex应该长度要等于所有机器数量,Leader对于每个Follower都记录他的nextIndex和matchIndex
	// nextIndex指的是下一个的appendEntries要从哪里开始尝试
	// matchIndex指的是已知的某follower的log与leader的log最大匹配到第几个Index
	nextIndex  []int // 对于每一个server,需要发送给他下一个日志条目的索引值(初始化为leader日志索引+1)
	matchIndex []int // 对于每一个server,已经复制给该server的最后日志条目下标

	state     State         // 该节点是什么角色(状态)
	overtime  time.Duration // 设置超时时间,200-400ms
	timer     *time.Timer   // 每个节点中的计时器
	voteCount int           // 该节点在选举中获得的投票数
}

其中最后四个虽然没有提到,但是对于raft整个领导人选举来说这些字段应是较好理解的。而对于peers,me其实就是整个框架所拥有的rf节点总数,me代表的就是自身节点在raft中的下标。这部分在Introduction中也有具体提到。(所以Introduction也要认真的看

3.2 AppendEntries RPC的定义

从严格意义上说这个可能算是2b的内容,因为是日志增量同步,但是对于leader选举后的心跳建立来讲,这又属于2a,固在此一起定义了。
在这里插入图片描述

// AppendEntriesArgs leader复制log条目,也可以当做是心跳连接
type AppendEntriesArgs struct {
    
    
	Term         int        // leader的任期
	LeaderId     int        // leader自身的ID
	PrevLogIndex int        // 新的日志目前的索引值(预计要从哪里追加)
	PrevLogTerm  int        // 新的日志目前的任期号
	Entries      []LogEntry // 预计存储的日志(为空时就是心跳连接)
	LeaderCommit int        // leader的commit index指的是最后一个被大多数机器都复制的日志Index
}

type AppendEntriesReply struct {
    
    
	Term    int  // leader的term可能是过时的,此时收到的Term用于更新他自己
	Success bool //	如果follower与Args中的PreLogIndex/PreLogTerm都匹配才会接过去新的日志(追加),不匹配直接返回false
}

3.3 RequestVote RPC的定义

type RequestVoteArgs struct {
    
    
	// Your data here (2A, 2B).
	Term         int //	需要竞选的人的任期
	CandidateId  int // 需要竞选的人的Id
	LastLogIndex int // 竞选人日志条目最后索引
	LastLogTerm  int // 候选人最后日志条目的任期号
}

//
// example RequestVote RPC reply structure.
// field names must start with capital letters!
// 如果竞选者任期比自己的任期还短,那就不投票,返回false
// 如果当前节点的votedFor为空,且竞选者的日志条目跟收到者的一样新则把票投给该竞选者
//
type RequestVoteReply struct {
    
    
	// Your data here (2A).
	Term        int  // 投票方的term,如果竞选者比自己还低就改为这个
	VoteGranted bool // 是否投票给了该竞选人
}

四、领导选举

在经历完成代码的之后,我觉得对于实现领导选举还是要多看看几遍文章前方的可视化过程。很多细节,可能在实际代码调试输出记录的时候才会知晓。对于我来说,我认为领导选举要注意的细节应该主要是来自于定时器对状态机影响你需要考虑什么时候定时器需要重来,且重新来过之后对于每个节点的状态是个怎么样的改变

4.1初始化raft节点

这部分就是比较常规的初始化了,直接上代码:

// Raft节点
// 一个服务调用Make(peers,me,…)来创建一个 Raft peer。
//peers 参数是 Raft 对等点(包括这个)的网络标识符数组,用于 RPC。
// me参数是 peers 数组中此对等点的索引。
//
func Make(peers []*labrpc.ClientEnd, me int,
	persister *Persister, applyCh chan ApplyMsg) *Raft {
    
    
	rf := &Raft{
    
    }
	rf.peers = peers
	rf.persister = persister
	rf.me = me

	// Your initialization code here (2A, 2B, 2C).

	rf.currentTerm = 0
	rf.votedFor = -1
	rf.logs = make([]LogEntry, 0)

	rf.commitIndex = -1
	rf.lastApplied = -1 // 虽然论文中这两个数值都是0,但是应该是有值下的初始化为0,现在还未有实际数据

	rf.nextIndex = make([]int, len(peers))
	rf.matchIndex = make([]int, len(peers))

	rf.state = Follower
	rf.overtime = time.Duration(200+rand.Intn(200)) * time.Millisecond // 随机产生200-400ms
	rf.voteCount = 0

	// 初始化timer
	rf.timer = time.AfterFunc(rf.overtime, func() {
    
     rf.TimeoutElection() })
	// initialize from state persisted before a crash
	rf.readPersist(persister.ReadRaftState())

	// start ticker goroutine to start elections
	go rf.ticker()

	return rf
}

4.2超时选举

在定义超过定时器后的时间段后执行的超时选举:follower会变成candidater然后向其他peers节点发起拉票。

func (rf *Raft) TimeoutElection() {
    
    
	rf.mu.Lock()
	defer rf.mu.Unlock()

	// 自身变为candidate
	rf.state = Candidate
	// 投给自己一票,自身任期+1
	rf.votedFor = rf.me
	rf.currentTerm++
	rf.voteCount++

	// 初始化rpc投票请求,发给其他peer
	args := RequestVoteArgs{
    
    
		Term:         rf.currentTerm,
		CandidateId:  rf.me,
		LastLogIndex: len(rf.logs) - 1,
	}

	// paper实现细节中有提到,rpc的返回true条件之一是候选人日志和自己一样新,固传Term进去
	if len(rf.logs) > 0 {
    
    
		args.LastLogTerm = rf.logs[args.LastLogIndex].Term
	}

	// 通过for循环开启多个协程进行拉票
	for serverNum := 0; serverNum < len(rf.peers); serverNum++ {
    
    

		// 对自己已经拉票过了
		if serverNum != rf.me {
    
    
			// 并发调用匿名函数,传不传参看对参数的污染容忍情况
			go func(serverNum int, args RequestVoteArgs) {
    
    
				reply := RequestVoteReply{
    
    }
				ok := rf.sendRequestVote(serverNum, &args, &reply)
				if ok {
    
    
					rf.VoteResHandler(reply)
				}
			}(serverNum, args)
		}

	}
	rf.resetTimer()

}

这里有一个resetTimer是为了重新记时。

func (rf *Raft) resetTimer() {
    
    
	// 把之前的timer暂停,重新开始一个新的
	rf.timer.Stop()
	// 超过overtime选举出新的领导人
	rf.timer = time.AfterFunc(rf.overtime, func() {
    
     rf.TimeoutElection() })
}

因为多看几遍领导选主,其实是可以发现。虽然是小概率事件,但是你可能会有多个timer的过期时间是相等的。那么就代表最后会有多个竞选者,这就会导致了可能最后情况会是票数一致,无法选出领导人。那么在可视化的动图里他其实选择的是重新进行一次选举,因为是小概率事件。

4.3投票RPC实现

对于这一部分其实论文也有具体的提到。
在这里插入图片描述

  • 首先竞选者的任期必须,大于自己的任期。否则返回false。因为在出先网络分区时,可能两个分区分别产生了两个leader。那么我们认为应该是任期长的leader拥有的数据更完整。(在可视化中也有)。因此第二条就是,投票成功的前提的是,你自己的票要没投过给别人,且竞选者的日志状态要和你的一致。
  • 还有一个要注意的点是当前rf节点发送到别的rf的节点的RPC框架中已经给出,为sendRequestVote方法。
func (rf *Raft) RequestVote(args *RequestVoteArgs, reply *RequestVoteReply) {
    
    
	// Your code here (2A, 2B).
	rf.mu.Lock()
	defer rf.mu.Unlock()
	
	// 初始化reply
	reply.Term = rf.currentTerm
	reply.VoteGranted = false

	//fmt.Println("args info : term[", args.Term, "],id[", args.CandidateId, "].ask vote for:"+
	//	"term[", rf.currentTerm, "],id[", rf.me, "],voteFor[", rf.votedFor, "]")
	// 被请求的server的Term大于竞选者,驳回投票请求(例如网络分区导致两个leader,任期更长的leader拥有的数据更完整)
	if args.Term < rf.currentTerm {
    
    
		return
	} else if args.Term > rf.currentTerm {
    
     // 请求者的任期比当前节点大,考虑网络分区的相反情况
		// 此时不管什么状态都应充值自己的任期为最新,且变为follower
		rf.state = Follower
		rf.votedFor = -1
		rf.currentTerm = args.Term
		rf.voteCount = 0
	}

	// 此时voteFor已经全被还原为-1
	if rf.votedFor == -1 {
    
    
		currentLogIndex := len(rf.logs) - 1
		currentLogTerm := 0
		// 如果currentLogIndex下标不是-1就把term赋值过来
		if currentLogIndex >= 0 {
    
    
			currentLogTerm = rf.logs[currentLogIndex].Term
		}
		// 论文里的第二个匹配条件,当前peer要符合arg两个参数的预期
		if args.LastLogIndex < currentLogIndex || args.LastLogTerm < currentLogTerm {
    
    
			return
		}

		// 给票数,并且返回true
		rf.votedFor = args.CandidateId
		reply.Term = rf.currentTerm
		reply.VoteGranted = true

	}

}

这边实现中要注意的一个点时,就是在本节开头强调的,你要注意的是你的状态要在什么时候进行重置。个人认为这个点应该在解耦合的时候,也就是在投票rpc的时候进行。因为当进行rpc的时候,基本情况下是谁先拉到多数票谁就有机会更早的成为leader。 那么就可以在你拉票的时候进行状态重置。因为先成为竞选者,先把自己的term就加一那么在一开始任期相同情况下该竞选者就会把其他raft进行重置。这一部分可以自己多进行打印输出。

// 被请求的server的Term大于竞选者,驳回投票请求(例如网络分区导致两个leader,任期更长的leader拥有的数据更完整)
	if args.Term < rf.currentTerm {
    
    
		return
	} else if args.Term > rf.currentTerm {
    
     // 请求者的任期比当前节点大,考虑网络分区的相反情况
		// 此时不管什么状态都应充值自己的任期为最新,且变为follower
		rf.state = Follower
		rf.votedFor = -1
		rf.currentTerm = args.Term
		rf.voteCount = 0
	}

4.4处理选举结果

这一阶段就是处理RPC返回的结果判断,看返回的票数有没半数,判定能不能成为leader。

func (rf *Raft) VoteResHandler(reply RequestVoteReply) {
    
    
	rf.mu.Lock()
	defer rf.mu.Unlock()
	defer rf.resetTimer()
	// 收到的回复任期大于当前的任期
	if reply.Term > rf.currentTerm {
    
    
		rf.state = Follower
		rf.currentTerm = reply.Term
		rf.votedFor = -1
		return
	}

	// 收集票数判断能否当任
	if rf.state == Candidate && reply.VoteGranted {
    
    
		rf.voteCount++
		//fmt.Println("******rf[", rf.me, "]:voteCount[", rf.voteCount, "],len[", len(
		//	rf.peers), "]******")
		if rf.voteCount >= len(rf.peers)/2+1 {
    
    
			rf.state = Leader
			for i := 0; i < len(rf.peers); i++ {
    
    

				// 进行对所有的下标进行增量同步
				if i != rf.me {
    
    
					rf.nextIndex[i] = len(rf.logs)
					rf.matchIndex[i] = -1
				}
			}
			//rf.resetTimer()
			//go func() { rf.ReConnection() }()
		}
	}

}
  • 在这之中其实涉及了一个很重要的点。那就是各节点的定时器如何重置。而我想的是等到它如果没有超时,那么他就还可以被利用。等到超时了,那它就需要进行投票,且需要进行返回结果。所以我统一在handler处理结果的时候进行defer resetTimer。增加代码的可读性,和理解。

到这里我们先跑一下测试代码:
在这里插入图片描述
发现是all past但是报了个warning:
warning: term changed even though there were no failures
它直译就是即使没有故障,但是它的任期被改变了。回顾可视化的图里,其实leader被选出来后要不断的建立心跳,保持它的任期。而我们还没有实现这个,所以报了这个warning,接下来我们继续完善我们的代码。

五、建立简单心跳连接

5.1 心跳调用实现

用for一直循环,注意lock别搞成死锁就行,唯一要注意的点是sleep的时间应该为小于你单词计时器的最短时间

func (rf *Raft) sendAppendEntries(server int, args *AppendEntriesArgs, reply *AppendEntriesReply) bool {
    
    
	ok := rf.peers[server].Call("Raft.AppendEntries", args, reply)
	return ok
}

func (rf *Raft) ReConnection() {
    
    

	// 如果领导人状态没被改变
	for rf.state == Leader {
    
    
		rf.mu.Lock()

		args := AppendEntriesArgs{
    
    
			Term:         rf.currentTerm,
			LeaderId:     rf.me,
			PrevLogIndex: len(rf.logs) - 1,
		}

		if len(rf.logs) > 0 {
    
    
			args.PrevLogTerm = rf.logs[len(rf.logs)-1].Term
		}
		for serverNum := 0; serverNum < len(rf.peers); serverNum++ {
    
    

			// 对自己不必建立连接
			if serverNum != rf.me {
    
    
				// 并发调用匿名函数,传不传参看对参数的污染容忍情况
				go func(serverNum int, args AppendEntriesArgs) {
    
    
					reply := AppendEntriesReply{
    
    }
					ok := rf.sendAppendEntries(serverNum, &args, &reply)
					if ok {
    
    
						rf.AppendEntriesResHandler(reply)
					}
				}(serverNum, args)
			}

		}
		rf.mu.Unlock()
		time.Sleep(time.Duration(150) * time.Millisecond) //等待150ms

	}
}

5.2 日志增量\心跳确认RPC实现

这部分于投票RPC差不多,且只关注term方面,这里不再赘述。

func (rf *Raft) AppendEntriesResHandler(reply AppendEntriesReply) {
    
    
	rf.mu.Lock()
	defer rf.mu.Unlock()
	defer rf.resetTimer()

	// leader被刷
	if reply.Term > rf.currentTerm {
    
    
		rf.state = Follower
		rf.currentTerm = reply.Term
		rf.votedFor = -1
		rf.voteCount = 0
	}

}

// 增量同步日志/维持心跳
func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
    
    
	rf.mu.Lock()
	defer rf.mu.Unlock()

	// 初始化reply,2A只要保证term
	reply.Term = rf.currentTerm

	// 要注意的是可能会有网络分区,只有在把当前节点刷了的时候才可以重置
	if rf.currentTerm > args.Term {
    
    
		reply.Success = false
		return
	} else {
    
    
		rf.state = Follower
		rf.currentTerm = args.Term
		rf.votedFor = -1
		rf.voteCount = 0
		reply.Term = rf.currentTerm
		rf.resetTimer()
	}
}

最后把这些重构在VoteResHandler就行

func (rf *Raft) VoteResHandler(reply RequestVoteReply) {
    
    
	rf.mu.Lock()
	defer rf.mu.Unlock()
	defer rf.resetTimer()
	// 收到的回复任期大于当前的任期
	if reply.Term > rf.currentTerm {
    
    
		rf.state = Follower
		rf.currentTerm = reply.Term
		rf.votedFor = -1
		return
	}

	// 收集票数判断能否当任
	if rf.state == Candidate && reply.VoteGranted {
    
    
		rf.voteCount++
		//fmt.Println("******rf[", rf.me, "]:voteCount[", rf.voteCount, "],len[", len(
		//	rf.peers), "]******")
		if rf.voteCount >= len(rf.peers)/2+1 {
    
    
			rf.state = Leader
			for i := 0; i < len(rf.peers); i++ {
    
    

				// 进行对所有的下标进行增量同步
				if i != rf.me {
    
    
					rf.nextIndex[i] = len(rf.logs)
					rf.matchIndex[i] = -1
				}
			}
			rf.resetTimer()
			go func() {
    
     rf.ReConnection() }()
		}
	}

}

此时再来看看test打印:
在这里插入图片描述
已经发现消除了warning。

总结

在经历lab1的适应后对于lab2A还是较简单的,大概花了两天时间完成这个lab2A,但是日志增量2B官方说是hard应该难得多,也希望自己后面有时间去完成下。对于test中其实并不能test出你代码的漏洞,就像我之前没把票数清0。照样是通过了所有test,还是应该要多在关键的地方如RPC的时候多打印下自己节点情况,最重要的是完成自己预期的实现。如有不足,欢迎指正。

猜你喜欢

转载自blog.csdn.net/weixin_45938441/article/details/124552447