Raft (MIT 6.824 Lab 2A) | 🏆 技术专题第五期征文 ......

2,116 阅读10分钟

引言

MIT 6.824的系列实验是构建一个容错Key/Value存储系统,实验2是这个系列实验的第一个。在实验2(Lab 2)中我们将实现Raft这个基于复制状态机(replicated state machine)的共识协议。本文将详细讲解Lab 2A。

复制服务通过在多个副本服务器上存储状态的完整拷贝来达到容错的目的。通过这种复制机制,即使某些服务器出现故障(如崩溃,网络故障或不稳定),服务也能够照常运行。但随之带来的挑战是故障可能导致副本数据不一致。

Raft将客户端请求组织成日志(log)的序列,并确保所有的副本服务器看到相同的日志。每个副本都以日志顺序执行客户端请求,并将这些请求应用到服务状态的本地拷贝上。由于所有活着的副本都看到相同的日志内容,并且都以相同的顺序执行相同的请求,所以继而有相同的服务状态。如果一个服务器出现故障但是之后恢复了,Raft会确保其日志为最新状态。只要至少大多数服务器(多数派)都处于活动状态并且可以相互通信,Raft就能继续运行。当不足多数派时,Raft将无法提交或应用新的日志,直到多数派能够再次交流。

在Lab 2中,我们将用GO语言实现Raft。我们将其封装为一个具有相关方法的Go对象类型,并作为一个模块以便被后续实验使用。一组Raft实例通过RPC通信来维护复制的日志,接收不定编号的命令,即日志条目(log entries)。这些日志条目将被索引号编号,具有给定索引的日志条目最终将被提交,并将其发送给上层应用,以便其执行。

正文

Lab 2A的任务是实现Raft的选主(leader election)和心跳(不带log entries的AppendEntries RPC)。我们的代码要能够选举单一一个leader,如果没有发生故障,该server保持leader状态;如果老leader发生故障或者心跳数据包丢失至超时,则由新leader接任。

数据结构

首先我们需要定义一些Raft中要用到的数据结构。

type State string
const (
	Follower  State = "follower"
	Candidate State = "candidate"
	Leader    State = "leader"
)

一个server的状态分为三种:follower、candidate和leader。通常情况下,一组Raft有且只有一个leader,其它的都是follower。follower是被动的,它们自己不发送RPC请求,只是简单的响应来自leader或者candidate的请求。leader负责所有来自客户端的请求。candidate用于选举新的leader。

const (
	electionInterval  int64 = 150
	timeoutSection    int64 = 150
	heartBeatInterval int64 = 75
)

此处定义了选主的选举超时时间及心跳间隔。随机时间能够减少split vote的情况,这里选举超时时间落在[electionInterval, electionInterval + timeoutSection)。 值的选取主要有以下几个考量:

  • timeoutSection越大则越不容易发生split vote,选主成功率越高,但会使随机到的超时时间更大,因此follower要经过更长时间才会变为candidate选主,进而不可用的时间加大。
  • 根据论文的时间约束,选举超时时间要远大于leader并行发送RPC到整个集群的往返时间,同时远小于机器的平均宕机时间。
  • heartBeatInterval越小则越能更快响应客户端的请求,但是会使集群负载升高,吞吐下降。我们希望能够容忍一次心跳的丢失,因此heartBeatInterval设置在timeoutSection的一半以下。 论文建议将选举的超时时间可以设置在150ms-300ms之间,但是实验建议将electionInterval、timeoutSection和heartBeatInterval适当调大。
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.

	state       State
	lastReceive int64
	currentTerm int
	votedFor    int
	...
}

此处定义了Raft结构体,在Lab 2A中我们只需要根据论文的Figure 2定义以下几个变量:

  • state: 当前server的状态,leader、follower还是candidate
  • currentTerm: 当前server所见过的最新term,初始为0
  • votedFor: 当前term被该server投票的candidate的id,没投票则为-1
  • lastReceive: 记录收到有效RPC的时间,用于超时计时,初始为-1
type RequestVoteArgs struct {
	// Your data here (2A, 2B).
	Term         int
	CandidateId  int
	...
}

type RequestVoteReply struct {
	// Your data here (2A).
	Term        int
	VoteGranted bool
}

follower变为candidate后通过RequestVote RPC来请求其它follower为其投票。此处定义了RequestVote RPC的结构体,在Lab 2A中RequestVoteArgs包含了:

  • term: candidate的term
  • candidateId: 请求投票的candidate id RequestVoteReply包含了:
  • term: 被请求者的currentTerm,用于candidate更新自己的term
  • voteGranted: true表示candidate获得投票
type AppendEntryArgs struct {
	// Your data here (2A, 2B).
	Term         int
	LeaderId     int
	...
}

type AppendEntryReply struct {
	// Your data here (2A).
	Term    int
	Success bool
	...
}

当candidate变为leader后会立即发送Append Entry RPC通知集群所有server,之后周期性发送该RPC作为心跳,防止其它server选举超时。日志复制也是通过Append Entry RPC实现的,不过在Lab 2B中才需要。在Lab 2A中AppendEntryArgs包含了:

  • Term: leader的term
  • LeaderId: leader的id,用于其它server将来自客户端的请求重定向给leader AppendEntryReply包含了:
  • Term: 被请求者的currentTerm,用于leader更新自己的term并及时降级成follower
  • Success: true表示follower包含匹配的prevLogIndex及prevLogTerm,在Lab 2A中暂时不需要匹配log

Server状态

server状态的转换如图所示。server启动后初始为follower,当超时后开始选举变为candidate,如果收到多数派的投票则变为leader。如果leader发现有更高的term,自己则降级为follower。如果candidate在选举过程中发现了当前leader或者新term,则降级为follower。如果candidate在选举过程中直到超时既没有得到多数派选票也没有降级,则开始新一轮选举。 为了方便处理server的状态转换,我们定义了几个函数:

func (rf *Raft) convertToLeader() {
	DPrintf("[%d]: convert from [%s] to [%s], term [%d]", rf.me, rf.state, Leader, rf.currentTerm)
	rf.state = Leader
	rf.lastReceive = time.Now().Unix()
	...
}

convertToLeader(): server由candidate变为leader,更新server状态并且更新lastReceive时间。

func (rf *Raft) convertToFollower(newTerm int) {
	DPrintf("[%d]: convert from [%s] to [%s]", rf.me, rf.state, Follower)
	rf.state = Follower
	rf.currentTerm = newTerm
	rf.votedFor = -1
	rf.lastReceive = time.Now().Unix()
}

convertToFollower(): server由candidate或leader降级为follower。更新term同时重置votedFor,更新lastReceive时间。

func (rf *Raft) convertToCandidate() {
	DPrintf("[%d]: convert from [%s] to [%s]", rf.me, rf.state, Candidate)
	rf.state = Candidate
	rf.currentTerm += 1
	rf.votedFor = rf.me
	DPrintf("[%d]: now term is [%d], voted for [%d]", rf.me, rf.currentTerm, rf.votedFor)

	rf.lastReceive = time.Now().Unix()
}

convertToCandidate(): server由于超时从follower变为candidate,或者candidate开始新一轮选举,currentTerm加一,为自己投票同时更新lastReceive时间。

主要函数

//
// the service or tester wants to create a Raft server. the ports
// of all the Raft servers (including this one) are in peers[]. this
// server's port is peers[me]. all the servers' peers[] arrays
// have the same order. persister is a place for this server to
// save its persistent state, and also initially holds the most
// recent saved state, if any. applyCh is a channel on which the
// tester or service expects Raft to send ApplyMsg messages.
// Make() must return quickly, so it should start goroutines
// for any long-running work.
//
func Make(peers []*labrpc.ClientEnd, me int,
	persister *Persister, applyCh chan ApplyMsg) *Raft {

	rf := &Raft{}
	rf.mu = sync.Mutex{}
	rf.peers = peers
	rf.persister = persister
	rf.me = me

	rf.state = Follower
	rf.currentTerm = 0
	rf.votedFor = -1
	rf.lastReceive = -1
	...
	// initialize from state persisted before a crash
	rf.readPersist(persister.ReadRaftState())

	go rf.leaderElection()
	...
	return rf
}

Make函数是创建Raft server实例的入口,此处我们初始化Raft实例的各个变量,并且在goroutine中开始选主计时。

// return currentTerm and whether this server
// believes it is the leader.
func (rf *Raft) GetState() (int, bool) {
	var term int
	var isleader bool
	// Your code here (2A).
	rf.mu.Lock()
	defer rf.mu.Unlock()
	term = rf.currentTerm
	isleader = rf.state == Leader
	return term, isleader
}

实验需要实现GetState()函数,以便于测试代码能够检测server状态。注意这里要加锁,一方面保证临界区不会在中间状态读取到不一致的变量值,另一方面确保go的内存模型及时更新共享变量。

选主(Leader Election)

由于实验建议不要使用go的Timer,所以我们这里在goroutine中,使用for循环加time.Sleep(),周期性检测上一次收到有效RPC的时间点(lastReceive)的方式。

func (rf *Raft) leaderElection() {
	sleepTime := time.Duration(electionInterval + rand.Int63n(timeoutSection))
	startTime := time.Now().Unix()
	for {
		if rf.killed() {
			return
		}
		time.Sleep(sleepTime * time.Millisecond)
		rf.mu.Lock()
		lastStartTime := startTime
		startTime = time.Now().Unix()
		predictTime := rf.lastReceive + electionInterval + rand.Int63n(timeoutSection)
		diff := predictTime - startTime
		if lastStartTime > rf.lastReceive || diff <= 0 {
			//DPrintf("[%d]: current state is [%s].", rf.me, rf.state)

			if rf.state != Leader {
				// kickoff election
				DPrintf("[%d]: is not leader, start election.", rf.me)
				rf.kickOffLeaderElection()
			}
			sleepTime = time.Duration(electionInterval + rand.Int63n(timeoutSection))
		} else {
			sleepTime = time.Duration(diff)
		}
		rf.mu.Unlock()
	}
}

代码中,startTime是我们本次循环周期的开始时间,lastStartTime是我们上一次循环周期的开始时间。predictTime是我们根据lastReceive预测的下一个循环周期开始的时间。通常的一个循环周期如下图所示,goroutine在lastStartTime和startTime间sleep,期间在lastReceive时间点收到了有效RPC,在startTime醒来后计算predictTime。predictTime与startTime的差值为diff,也就是接下来需要sleep的时间。 那么什么时候进入选主阶段呢?这里主要分为三种情况:

  • lastStartTime大于lastReceive,即在两次循环周期间都没有收到有效的RPC,意味着已经超时,进入选主阶段。
  • lastStartTime小于或等于lastReceive,说明两次循环周期间收到有效的RPC,这时我们需要看我们预测的predictTime是否比当前startTime大,如果大意味着可以继续sleep。这也是图示的情况。
  • 如果predictTime比当前startTime小,则意味着虽然收到了有效的RPC,但是predictTime随机的值太小,并没有影响当前超时的到来,进入选主阶段。 进入选主阶段后要重置sleepTime。

这段代码对follower超时变为candidate、candidate超时进入新一轮选举都适用。注意这里要在操作共享变量前上锁,并在每次sleep前将锁释放。此外,实验的测试会调用rf.Kill()来关闭Raft实例,所以我们在要在循环中调用rf.killed()来检查,并正确关闭。

func (rf *Raft) kickOffLeaderElection() {
	rf.convertToCandidate()
	...
	voteCount := 1
	totalCount := 1
	cond := sync.NewCond(&rf.mu)

	...

	for i := 0; i < len(rf.peers); i++ {
		if i != rf.me {
			// 此处lastLogIndex及lastLogTerm在Lab 2A中无需关心。
			go func(serverTo int, term int, candidateId int, lastLogIndex int, lastLogTerm int) {
				args := RequestVoteArgs{term, candidateId, lastLogIndex, lastLogTerm}
				reply := RequestVoteReply{}
				DPrintf("[%d]: term: [%d], send request vote to: [%d]", candidateId, term, serverTo)
				ok := rf.sendRequestVote(serverTo, &args, &reply)

				rf.mu.Lock()
				defer rf.mu.Unlock()

				totalCount += 1
				if !ok {
					cond.Broadcast()
					return
				}
				if reply.Term > rf.currentTerm {
					rf.convertToFollower(reply.Term)
					...
				} else if reply.VoteGranted && reply.Term == rf.currentTerm {
					voteCount += 1
				}
				cond.Broadcast()
			}(i, rf.currentTerm, rf.me, -1, -1)
		}
	}
    
	go func() {
		rf.mu.Lock()
		defer rf.mu.Unlock()

		for voteCount <= len(rf.peers)/2 && totalCount < len(rf.peers) && rf.state == Candidate {
			cond.Wait()
		}
		if voteCount > len(rf.peers)/2 && rf.state == Candidate {
			rf.convertToLeader()
			...
			go rf.operateLeaderHeartbeat()
		}
	}()
}

接下来我们正式开始选主。在kickOffLeaderElection()中,主要分为发送RequestVote RPC请求投票和计票两大部分。

代码中,首先server状态要变为candidate,接着初始化统计票数的两个变量voteCount和totalCount,分别代表candidate收到的票数和总数。cond用于请求投票的goroutine和计票goroutine之间的同步。

由于RPC是个耗时的过程,所以我们在遍历集群除自己外的每一个server时,用goroutine发送RPC并等待和处理结果。这里临时变量i要通过函数参数按值传递,不能通过闭包,因为闭包获得的i值不确定。在sendRequestVote()返回后,获取锁,更新totalCount,如果RPC失败则直接唤醒计票goroutine并返回。如果没有失败,那么:

  • 判断返回的term是否比当前term大,如果大那么立即降级为follower
  • 否则如果返回的VoteGranted为true,且是当前的term(避免过时的RPC),voteCount加一
  • 唤醒计票goroutine。

如果获得多数派投票,或者达到totalCount总数,或者不再是Candidate,那么计票goroutine不再条件等待。如果在Candidate状态下获得了多数派的投票,那么晋升为leader。

sendRequestVote()以及后面的sendAppendEntry()封装好了实验提供的RPC的Call函数,我们直接指定server id、过程名并传参即可。

func (rf *Raft) sendRequestVote(server int, args *RequestVoteArgs, reply *RequestVoteReply) bool {
	ok := rf.peers[server].Call("Raft.RequestVote", args, reply)
	return ok
}

func (rf *Raft) sendAppendEntry(server int, args *AppendEntryArgs, reply *AppendEntryReply) bool {
	ok := rf.peers[server].Call("Raft.AppendEntry", args, reply)
	return ok
}

下面先看一下RequestVote请求投票这个RPC是如何被处理的:

func (rf *Raft) RequestVote(args *RequestVoteArgs, reply *RequestVoteReply) {
	// Your code here (2A, 2B).
	rf.mu.Lock()
	defer rf.mu.Unlock()
	DPrintf("[%d]: received vote request from [%d]", rf.me, args.CandidateId)

	if args.Term < rf.currentTerm {
		reply.Term = rf.currentTerm
		reply.VoteGranted = false
		return
	}
	...

	// If RPC request or response contains term T > currentTerm:
	// set currentTerm = T, convert to follower (§5.1)
	if args.Term > rf.currentTerm {
		rf.convertToFollower(args.Term)
		...
	}
	reply.Term = rf.currentTerm
	DPrintf("[%d]: status: term [%d], state [%s], vote for [%d]", rf.me, rf.currentTerm, rf.state, rf.votedFor)

	if (rf.votedFor < 0 || rf.votedFor == args.CandidateId) ...){
		rf.votedFor = args.CandidateId
		rf.lastReceive = time.Now().Unix()
		reply.VoteGranted = true
		DPrintf("[%d]: voted to [%d]", rf.me, args.CandidateId)
		return
	}

	reply.VoteGranted = false
}

根据论文,如果term < currentTerm则返回false。 如果args.Term比currentTerm大,那么降级为follower。 如果没投票或者投给过这个candidate,那么授予投票。后续实验还要判断candidate的log是否至少和接受者的log一样新(up-to-date),不过在Lab 2A不涉及。

心跳(AppendEntries)

在赢得选举晋升为leader后,leader要立即并周期性的通过AppendEntry发送心跳。

func (rf *Raft) operateLeaderHeartbeat() {
	for {
		if rf.killed() {
			return
		}
		rf.mu.Lock()
		if rf.state != Leader {
			rf.mu.Unlock()
			return
		}

		for i := 0; i < len(rf.peers); i++ {
			if i != rf.me {
				...
				// 此处prevLogIndex、prevLogTerm、entries和leaderCommit在Lab 2A中不需要关心
				go func(serverTo int, term int, leaderId int, prevLogIndex int, prevLogTerm int, entries []LogEntry, leaderCommit int) {
					args := AppendEntryArgs{term, leaderId, prevLogIndex, prevLogTerm, entries, leaderCommit}
					reply := AppendEntryReply{}
					ok := rf.sendAppendEntry(serverTo, &args, &reply)

					rf.mu.Lock()
					defer rf.mu.Unlock()
					if !ok {
						return
					}

					if reply.Term > rf.currentTerm {
						rf.convertToFollower(reply.Term)
						...
						return
					}
					...
				}(i, rf.currentTerm, rf.me, -1, -1, nil, -1)
			}
		}
		rf.mu.Unlock()
		time.Sleep(time.Duration(heartBeatInterval) * time.Millisecond)
	}
}

我们还是通过在for循环加time.Sleep()实现。在每个循环中要获取锁,然后同样在新的goroutine中发送心跳RPC,等待并处理结果。Lab 2A对AppendEntries没有太多要求,只需要判断返回的term看是否降级为follower即可。

func (rf *Raft) AppendEntry(args *AppendEntryArgs, reply *AppendEntryReply) {
	// Your code here (2A, 2B).
	rf.mu.Lock()
	defer rf.mu.Unlock()

	if args.Term < rf.currentTerm {
		reply.Term = rf.currentTerm
		reply.Success = false
		return
	}
	...
	// If RPC request or response contains term T > currentTerm:
	// set currentTerm = T, convert to follower (§5.1)
	if args.Term > rf.currentTerm || rf.state == Candidate {
		rf.convertToFollower(args.Term)
		...
	}
	...
	rf.lastReceive = time.Now().Unix()
	reply.Term = rf.currentTerm
	reply.Success = true
	return
}

处理AppendEntry这个RPC的代码,与RequestVote类似。 如果term < currentTerm则返回false。 如果args.Term比currentTerm大,或者candidate收到了新leader的心跳,那么降级为follower。最后更新lastReceive并返回true。

总结

本文讲解了MIT 6.824 Lab 2A,按照要求实现了Raft的选主和心跳,仅供参考。论文更多细节我们将在后续的Lab 2B继续实现。 🏆 技术专题第五期 | 聊聊分布式的那些事......

参考文献