MIT 6.824 | Lab2 Raft实现

612 阅读6分钟

1 前言

MIT6.824 实现起来可真的繁琐,分布式的bug调起来过于麻烦,看了无数个别人得实现,我还是太菜了,QAQ,所幸通过了Lab2。

lab2 的内容是要实现一个除了节点变更功能外的 raft 算法。论文中的图二(如下图)是实现raft的关键。如果想要形象化得看Raft算法是如何运转得,可以看这个(Raft Consensus Algorithm。之前把代码开源出来收到邮件提醒叫我别这么做,我就不分享我的代码,只分享一些片段了。

image.png

2 实现

2.1 结构体

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.
	currentTerm		int           // Server当前的term
	voteFor			int           // Server在选举阶段的投票目标
	logs            []LogEntry
	nextIndexs      []int         // Leader在发送LogEntry时,对应每个其他Server,开始发送的index
	matchIndexs     []int
	commitIndex     int           // Server已经commit了的Log index
	lastApplied     int           // Server已经apply了的log index
	myStatus        Status        // Server的状态

	timer           *time.Ticker  // timer
	voteTimeout     time.Duration // 选举超时时间,选举超时时间是会变动的,所以定义在Raft结构体中
	applyChan       chan ApplyMsg // 消息channel

	// 2D
	lastIncludeIndex  int         // snapshot保存的最后log的index
	lastIncludeTerm   int         // snapshot保存的最后log的term
	snapshotCmd       []byte
}

2.2 选主

如果 follower 在 election timeout 内没有收到来自 leader 的心跳,(也许此时还没有选出 leader,大家都在等;也许 leader 挂了;也许只是 leader 与该 follower 之间网络故障),则会主动发起选举。

选举过程:

    1. 首先切换自己的状态folower -> canadiate,增加自己的current term,给自己投一票
    1. 之后给剩余节点发送RequestVote,如果其他节点发现canadiateterm比自己的term大的话(在lab2A中是这样的,之后的实现还需要考虑log replicationsafety),就会投票给那个节点
  • 等待回复

可能的结果:

  1. 收到的结果为大多数同意,则成为leader
  2. 被告知其他的更新的leader存在,切换状态为follower
  3. 一段时间没有收到大多数投票也没有被告知更新leader,重新发出选举

按照论文中得实现即可,我这里面把选举超时时间和心跳时间实现在同一个timer,我认为当一个peer成为leader之后就不再需要持续的重置选举超时时间了。在Lab2A中,可以不实现AppendEntries,只需要发个空包就行。

func (rf *Raft) ticker() {
	for rf.killed() == false {

		// Your code here to check if a leader election should
		// be started and to randomize sleeping time using
		// time.Sleep().
		select {
		case <-rf.timer.C:
			if rf.killed() {
				return
			}
			rf.mu.Lock()
			currStatus := rf.myStatus
			switch currStatus {
			case Follower:
				rf.myStatus = Candidate
				fallthrough
			case Candidate:
				// 进行选举
				rf.currentTerm+=1
				rf.voteFor = rf.me
				// 每轮选举开始时,重新设置选举超时
				rf.voteTimeout = time.Duration(rand.Intn(150)+200)*time.Millisecond
				voteNum := 1
				rf.persist()
				rf.timer.Reset(rf.voteTimeout)
				// 构造msg
				for i,_ := range rf.peers {
					if i == rf.me {
						continue
					}
					voteArgs := &RequestVoteArgs{
						Term:         rf.currentTerm,
						Candidate:    rf.me,
						LastLogIndex: len(rf.logs)+rf.lastIncludeIndex,
						LastLogTerm:  rf.lastIncludeTerm,
					}
					if len(rf.logs) > 0 {
						voteArgs.LastLogTerm = rf.logs[len(rf.logs)-1].Term
					}
					voteReply := new(RequestVoteReply)
					//DPrintf("发起选举",rf.me,i,voteArgs,rf.currentTerm, rf.lastIncludeIndex, rf.lastIncludeTerm)
					go rf.sendRequestVote(i, voteArgs, voteReply, &voteNum)
				}
			case Leader:
				// 进行心跳
				appendNum := 1
				rf.timer.Reset(HeartBeatTimeout)
				// 构造msg
				for i,_ := range rf.peers {
					if i == rf.me {
						continue
					}
					appendEntriesArgs := &AppendEntriesArgs{
						Term:         rf.currentTerm,
						LeaderId:     rf.me,
						PrevLogIndex: 0,
						PrevLogTerm:  0,
						Logs:         nil,
						LeaderCommit: rf.commitIndex,
						LogIndex:     len(rf.logs)+rf.lastIncludeIndex,
					}
					//installSnapshot,如果rf.nextIndex[i]小于等lastCludeIndex,则发送snapShot
					if rf.nextIndexs[i] <= rf.lastIncludeIndex {
						installSnapshotReq := &InstallSnapshotRequest{
							Term:             rf.currentTerm,
							LeaderId:         rf.me,
							LastIncludeIndex: rf.lastIncludeIndex,
							LastIncludeTerm:  rf.lastIncludeTerm,
							Data:             rf.snapshotCmd,
						}
						installSnapshotReply := &InstallSnapshotResponse{}
						//DPrintf("installsnapshot", rf.me, i, rf.lastIncludeIndex, rf.lastIncludeTerm, rf.currentTerm, installSnapshotReq)
						go rf.sendInstallSnapshot(i, installSnapshotReq, installSnapshotReply)
						continue
					}
					for rf.nextIndexs[i] > rf.lastIncludeIndex {
						appendEntriesArgs.PrevLogIndex = rf.nextIndexs[i]-1
						if appendEntriesArgs.PrevLogIndex >= len(rf.logs)+rf.lastIncludeIndex+1 {
							rf.nextIndexs[i]--
							continue
						}
						if appendEntriesArgs.PrevLogIndex == rf.lastIncludeIndex {
							appendEntriesArgs.PrevLogTerm = rf.lastIncludeTerm
						} else {
							appendEntriesArgs.PrevLogTerm = rf.logs[appendEntriesArgs.PrevLogIndex-rf.lastIncludeIndex-1].Term
						}
						break
					}
					if rf.nextIndexs[i] < len(rf.logs)+rf.lastIncludeIndex+1 {
						appendEntriesArgs.Logs = make([]LogEntry,appendEntriesArgs.LogIndex+1-rf.nextIndexs[i])
						copy(appendEntriesArgs.Logs, rf.logs[rf.nextIndexs[i]-rf.lastIncludeIndex-1:appendEntriesArgs.LogIndex-rf.lastIncludeIndex])
					}

					appendEntriesReply := new(AppendEntriesReply)
					go rf.sendAppendEntries(i, appendEntriesArgs, appendEntriesReply, &appendNum)
				}
			}
			rf.mu.Unlock()
		}
	}
}

注意点:

  • guiandance中有提到,对于过期请求的回复,可以直接抛弃,虽然test文件中没有相关的case来判断代码的对错,但是我还是实现了一下如何回复。
  • 对于投票统计可以在raft结构体中直接记录,也可以在ticker()函数中使用局部函数变量统计。
  • 多注意细节,如选举时间/心跳包时间的重置问题等;

2.3 日志复制

每个节点存储自己的日志副本(log[]),每条日志记录包含,有的实现也包含index,我把index放到节点本身去存储了:

  • 索引:该记录在日志中的位置
  • 命令

2.3.1 日志同步

解决问题:

  • Leader发送心跳宣示自己的主权,Follower不会发起选举。
  • Leader将自己的日志数据同步到Follower,达到数据备份的效果。

主要过程: 日志同步要解决如下两个问题:

  • Leader发送心跳宣示自己的主权,Follower不会发起选举。
  • Leader将自己的日志数据同步到Follower,达到数据备份的效果。

运行流程

  1. 客户端向 Leader 发送命令,希望该命令被所有状态机执行;
  2. Leader 先将该命令追加到自己的日志中;
  3. Leader 并行地向其它节点发送 AppendEntries RPC,等待响应;收到超过半数节点的响应,则认为新的日志记录是被提交的:
  4. Leader 将命令传给自己的状态机,然后向客户端返回响应;此外,一旦 Leader 知道一条记录被提交了,将在后续的 AppendEntries RPC 中通知已经提交记录的 Followers;Follower 将已提交的命令传给自己的状态机
  5. 如果 Follower 宕机/超时:Leader 将反复尝试发送 RPC

以下是代码实现的主要逻辑,可以不必等待所有节点的请求,超过一半即可执行。

switch reply.AppendErr {
	case AppendErr_Nil:
		if reply.Success && reply.Term == rf.currentTerm && *appendNum <= len(rf.peers)/2 {
			*appendNum++
		}
		if rf.nextIndexs[server] > args.LogIndex+1 {
			rf.mu.Unlock()
			return ok
		}
		rf.nextIndexs[server] = args.LogIndex+1
		if *appendNum > len(rf.peers)/2 {
			*appendNum = 0
			if (args.LogIndex>rf.lastIncludeIndex && rf.logs[args.LogIndex-rf.lastIncludeIndex-1].Term != rf.currentTerm) ||
				(args.LogIndex == rf.lastIncludeIndex && rf.lastIncludeTerm != rf.currentTerm){
				rf.mu.Unlock()
				return false
			}
			for rf.lastApplied < args.LogIndex {
				rf.lastApplied++
				applyMsg := ApplyMsg{
					CommandValid:  true,
					Command:       rf.logs[rf.lastApplied-rf.lastIncludeIndex-1].Cmd,
					CommandIndex:  rf.lastApplied,
				}
				rf.applyChan <- applyMsg
				rf.commitIndex = rf.lastApplied
			}
		}
	case AppendErr_ReqOutofDate:
		rf.myStatus = Follower
		rf.timer.Reset(rf.voteTimeout)
		if reply.Term > rf.currentTerm {
			rf.currentTerm = reply.Term
			rf.voteFor = -1
			rf.persist()
		}
	case AppendErr_LogsNotMatch:
		if args.Term != rf.currentTerm {
			rf.mu.Unlock()
			return false
		}
		rf.nextIndexs[server] = reply.NotMatchIndex
	case AppendErr_ReqRepeat:
		if reply.Term > rf.currentTerm {
			rf.myStatus = Follower
			rf.currentTerm = reply.Term
			rf.voteFor = -1
			rf.timer.Reset(rf.voteTimeout)
			rf.persist()
		}
	case AppendErr_Commited:
		if args.Term != rf.currentTerm {
			rf.mu.Unlock()
			return false
		}
		rf.nextIndexs[server] = reply.NotMatchIndex
	case AppendErr_RaftKilled:
		rf.mu.Unlock()
		return false
	}

2.3.2 日志应用

一旦发现commitIndex大于lastApplied,应该立马将可应用的日志应用到状态机中。Raft节点本身是没有状态机实现的,状态机应该由Raft的上层应用来实现,因此只需将日志发送给applyCh这个通道即可。据说有更加优雅的实现(TiKV 功能介绍 - Raft 的优化 | PingCAP),我就不贴我的代码了。 存在一个冲突优化,助教有提到:

  • If a follower does not have prevLogIndex in its log, it should return with conflictIndex = len(log) and conflictTerm = None.
  • If a follower does have prevLogIndex in its log, but the term does not match, it should return conflictTerm = log[prevLogIndex].Term, and then search its log for the first index whose entry has term equal to conflictTerm.
  • Upon receiving a conflict response, the leader should first search its log for conflictTerm. If it finds an entry in its log with that term, it should set nextIndex to be the one beyond the index of the last entry in that term in its log.
  • If it does not find an entry with that term, it should set nextIndex = conflictIndex. 翻译一下就是添加ConflictIndexConflictTerm字段:
  1. follower 没有 prevLogIndex 处的日志,则直接置 conflictIndex = len(log),conflictTerm = None
  • leader 收到返回体后,肯定找不到对应的 term,则设置nextIndex = conflictIndex
  • 其实就是 leader 对应的 nextIndex 直接回退到该 follower 的日志条目末尾处,因为 prevLogIndex 超前了
  1. followerprevLogIndex 处的日志,但是 term 不匹配;则设置 conlictTermprevLogIndex 处的 term,且肯定可以找到日志中该 term出现的第一个日志条目的下标,并置conflictIndex = firstIndexWithTerm

leader 收到返回体后,有可能找不到对应的 term,即 leaderfollowerconflictIndex处以及之后的日志都有冲突,都不能要了,直接置nextIndex = conflictIndex 若找到了对应的term,则找到对应term出现的最后一个日志条目的下一个日志条目,即置nextIndex = lastIndexWithTerm + 1;这里其实是默认了若 leaderfollower 同时拥有该 term 的日志,则不会有冲突,直接取下一个 term 作为日志发起就好,是源自于 safety 的安全性保证 如果还有冲突,leaderfollower 会一直根据以上规则回溯 nextIndex

2.4 持久化

持久化是最简单的部分,在Raft论文中需要持久化的字段只有3个 currentTermvoteForlog[],我是有相关变动的时候直接持久化一次。

2.5 日志压缩和快照

随着时间推移,存储的日志会越来越多,不但占据很多磁盘空间,服务器重启做日志重放也需要更多的时间。如果没有办法来压缩日志,将会导致可用性问题:要么磁盘空间被耗尽,要么花费太长时间启动。所以日志压缩是必要的。 日志快照就是把当前状态记录下来,把该状态前的所有操作信息都丢弃。 对于go语言来说,这篇bolg很好的讲了如何让内存不泄露go 避免切片内存泄露 - hubb - 博客园 (cnblogs.com) 快照时间:

    1. 服务端触发日志压缩
    1. leader发来的InstallSnapshot请求:当收到其他节点的压缩请求后,先上报上层应用,然后再决定是否安装快照。
    • 对于 leader 发过来的 InstallSnapshot,只需要判断 term 是否正确,如果无误则 follower 只能无条件接受。
    • 对于服务上层触发的 CondInstallSnapshot,与上面类似,如果 snapshot 没有更新的话就没有必要去换,否则就接受对应的 snapshot 并处理对应状态的变更。

3 结语

做Lab2的时候真的痛苦,感觉人都要麻了,到处报错。不过写完之后感觉自己对分布式了解了不少,感谢大佬们留下的文档,没有前人的文章和实现的片段代码我是做不完这个lab2的。感谢!