MIT6.824 Lab3 Raft A B C

272 阅读15分钟

一、前言

搞了一周 总算搞明白了一些,至少是实验的测试都通过了:)

这里就不水文章了,直接将lab的A、B、C全部写在一篇中,因为我觉得这三个实验是紧密连在一起的,A中的投票过程牵扯到B的日志,A、B正常完成之后,C就顺势搞定了。

本篇将结合raft论文以及lab中的提示来完成,每一步核心代码都与raft论文的内容对应起来。

二、具体实现

一个 Raft 集群包含若干个服务器节点;在任何时刻,每一个服务器节点都处于这三个状态之一:领导人、跟随者或者候选人。在通常情况下,系统中只有一个领导人并且其他的节点全部都是跟随者。跟随者都是被动的:他们不会发送任何请求,只是简单的响应来自领导人或者候选人的请求。

Raft 把时间分割成任意长度的任期, 任期在 Raft 算法中充当逻辑时钟的作用,任期使得服务器可以检测一些过期的信息:比如过期的领导人。每个节点存储一个当前任期号,这一编号在整个时期内单调递增。

Raft 算法中服务器节点之间通信使用远程过程调用(RPCs),并且基本的一致性算法只需要两种类型的 RPCs。请求投票(RequestVote) RPCs 由候选人在选举期间发起(章节 5.2),然后附加日志条目(AppendEntries)RPCs 由领导人发起,用来复制日志和提供一种心跳机制(章节 5.3)。

选自5.1 Raft基础

1、定义结构体

跟论文中的描述,需要定义三类结构体:

  1. 包含与节点当前状态相关的结构体,比如任期号、节点状态、日志条目等信息
  2. 请求投票相关的结构体
  3. 请求附加日志条目|心跳相关的结构体

下面,根据论文中的内容来完善结构体中的字段:

1.1 raft


type Raft struct {
	... ...
	currentTerm int  
	votedFor    int 
	log         []LogEntry

	role           Role      
	nextExpireTime time.Time  
	votesNums      int		 

	commitIndex int
	lastApplied int
	applyCh     chan ApplyMsg  

	nextIndex  []int
	matchIndex []int
}

type Role int
const (
	FOLLOWER Role = iota
	CANDIDATE
	LEADER
)

type LogEntry struct {
	Command interface{}
	Term    int
	Index   int
}

除了论文的图中给出来的字段,这里还有一些额外有用的字段:

  • role 记录当前节点的状态|角色
  • nextExpireTime 选举超时时间
  • votesNums 当前节点获取的投票数
  • applyCh 实验规定提交的条目需要发送的通道

1.2 投票

type RequestVoteArgs struct {
	Term         int
	CandidateID  int
	LastLogIndex int
	LastLogTerm  int
}

type RequestVoteReply struct {
	Term        int
	VoteGranted bool
}

1.3 追加日志条目|心跳

type AppendEntriesArgs struct {
	Term         int
	LeaderID     int
	PrevLogIndex int
	PrevLogTerm  int
	Entries      []LogEntry
	LeaderCommit int
}

type AppendEntriesReply struct {
	Term    int
	Success bool

	XTerm  int 
	XIndex int 
	XLen   int 
}

其中XTerm、XIndex、Xlen是由实验中提出来的来弥补论文中的细节,论文原文:

如果需要的话,算法可以通过减少被拒绝的附加日志 RPCs 的次数来优化。例如,当附加日志 RPC 的请求被拒绝的时候,跟随者可以(返回)冲突条目的任期号和该任期号对应的最小索引地址。借助这些信息,领导人可以减小 nextIndex 一次性越过该冲突任期的所有日志条目;这样就变成每个任期需要一次附加条目 RPC 而不是每个条目一次。在实践中,我们十分怀疑这种优化是否是必要的,因为失败是很少发生的并且也不大可能会有这么多不一致的日志。

选自Raft论文5.3 日志复制

实验中定义的这个三个字段含义:

XTerm: 追随者与领导者冲突条目对应的任期号

XIndex: 追随者与领导者日志中第一个与XTerm匹配的日志条目的索引

XLen: 追随者的日志长度

2、结构体初始化

在节点的服务启动中,需要对上述的raft结构体字段进行初始化,

  • role:当服务器程序启动时,他们都是跟随者身份
  • currentTerm:在服务器首次启动时初始化为0,单调递增
  • log[] :初始索引为1
  • votedFor:如果没有投给任何候选人 则为空(选择-1 为空标识)
  • commitIndex:初始值为0,单调递增
  • lastApplied:初始值为0,单调递增

选自raft论文图2

这里展示实验中Make函数的内容:

func Make(peers []*labrpc.ClientEnd, me int,
	persister *Persister, applyCh chan ApplyMsg) *Raft {
	rf := &Raft{}
	rf.peers = peers
	rf.persister = persister
	rf.me = me

	rf.currentTerm = 0
	rf.votedFor = -1
	rf.log = []LogEntry{{nil, -1, 0}}

	rf.votesNums = 0
	rf.role = FOLLOWER
	rf.nextExpireTime = generateNextExpireTime()

	rf.commitIndex = 0
	rf.lastApplied = 0
	rf.applyCh = applyCh

	rf.readPersist(persister.ReadRaftState())

	go rf.applier()

	go rf.ticker()

	return rf
}

同时开启两个协程:rf.applier() 用来发送提交日志条目到applyCh中,rf.ticker()用来监听集群状态。

3、领导人选举

3.1 触发选举

如果一个跟随者在一段时间内没有接收到任何消息,也就是选举超时,那么他就会认为系统中没有可用的领导人,并且发起选举用以选出领导人。

选自Raft论文5.2领导选举


超时时间的选择

Raft算法使用随机选举超时时间的方法来确保很少会发生选票瓜分的情况,就算发生也能很快的解决。为了阻止选票起初就被瓜分,选举超时时间是从一个固定的区间(例如 150-300 毫秒)随机选择。这样可以把服务器都分散开以至于在大多数情况下只有一个服务器会选举超时;然后他赢得选举并在其他服务器超时之前发送心跳包。同样的机制被用在选票瓜分的情况下。每一个候选人在开始一次选举的时候会重置一个随机的选举超时时间,然后在超时时间内等待投票的结果;这样减少了在新的选举中另外的选票瓜分的可能性。

选自Raft论文5.2领导选举

但是实验代码中给出的超时时间如下:

func generateNextExpireTime() time.Time {
	millisecond := 150 + rand.Intn(150)
	return time.Now().Add(time.Duration(millisecond) * time.Millisecond)
}

实验中同时也给了解释:

论文第5.2节提到了选举超时在150到300毫秒范围内。只有当领导者发送心跳的频率远高于每150毫秒一次(如每10毫秒一次)时,这个范围才有意义。由于测试器限制你每秒发送的心跳次数不超过10次,你将不得不使用比论文中的150到300毫秒更大的选举超时,但也不能太大,否则你可能无法在5秒内选举出领导者

根据上面的逻辑,这里实现的代码如下:

func (rf *Raft) ticker() {
	ticker := time.NewTicker(20 * time.Millisecond)
	defer ticker.Stop()

	for !rf.killed() {
		select {
		case <-ticker.C:
			rf.mu.Lock()
			nowTime := time.Now()
			if nowTime.After(rf.nextExpireTime) && rf.role != LEADER {
				go rf.startElection()
			}
			rf.mu.Unlock()
		}
	}
}

3.2 开始选举

要开始一次选举过程,跟随者先要增加自己的当前任期号并且转换到候选人状态。

选自Raft论文5.2领导选举

在转变成候选人后就立即开始选举过程:

  • 自增当前的任期号(currentTerm)
  • 给自己投票
  • 重置选举超时计时器
  • 发送请求投票的 RPC 给其他所有服务器

选自Raft论文图2-Rules for Servers

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

	rf.role = CANDIDATE
	rf.currentTerm++
	rf.votedFor = rf.me
	rf.persist()
	rf.votesNums = 1
	rf.nextExpireTime = generateNextExpireTime()
    ... ...
}

然后他会并行地向集群中的其他服务器节点发送请求投票的RPCs来给自己投票

选自Raft论文5.2领导选举

func (rf *Raft) startElection() {
    ... ...
    size := len(rf.log)
	for serverID := range rf.peers {
		if serverID == rf.me {
			continue
		}

		arg := &RequestVoteArgs{
			Term:         rf.currentTerm,
			CandidateID:  rf.me,
			LastLogIndex: rf.log[size-1].Index,
			LastLogTerm:  rf.log[size-1].Term,
		}

		go rf.sendRequestVote(serverID, arg)
	}
}

3.3 请求投票接口

func (rf *Raft) sendRequestVote(serverID int, args *RequestVoteArgs) {
	reply := RequestVoteReply{}
	if ok := rf.peers[serverID].Call("Raft.RequestVote", args, &reply); !ok {
		return
	}
    ... ...
}

3.4 投票接口处理请求

如果此次RPC的任期号比自己小,那么候选人就会拒绝这次的RPC并且继续保持候选人的状态

选自Raft论文5.2领导选举

func (rf *Raft) RequestVote(args *RequestVoteArgs, reply *RequestVoteReply) {
    ... ...    
    if args.Term > rf.currentTerm {
		rf.convertRole(FOLLOWER)
		rf.currentTerm = args.Term
		rf.persist()
	}
    ... ...
}

请求投票RPC实现了这样的限制:RPC中包含了候选人的日志信息,然后投票人会拒绝掉那些日志没有自己新的投票请求。raft 通过比较两份日志中最后一条日志条目的索引值和任期号定义谁的日志比较新:

  • 如果两份日志最后的条目的任期号不同,那么任期号大的日志就更新
  • 如果两份日志最后的条目任期号相同,那么日志比较长的那个就更新

选自5.4.1选举限制

func (rf *Raft) RequestVote(args *RequestVoteArgs, reply *RequestVoteReply) {
    ... ...
    size := len(rf.log)
	if args.LastLogTerm != rf.log[size-1].Term {
		if args.LastLogTerm >= rf.log[size-1].Term {
			acceptRequest = true
		}
	} else { 
		if args.LastLogIndex >= len(rf.log)-1 { 
			acceptRequest = true
		}
	}
    ... ...
}

如果 votedFor 为空或者为 candidateId,并且候选人的日志至少和自己一样新,那么就投票给他

选自Raft论文图2

func (rf *Raft) RequestVote(args *RequestVoteArgs, reply *RequestVoteReply) {
    ... ...
	if (rf.votedFor == -1 || rf.votedFor == args.CandidateID) && acceptRequest {
		rf.votedFor = args.CandidateID
		rf.persist()
		reply.VoteGranted = true
		rf.nextExpireTime = generateNextExpireTime()
	}
}

3.5 处理投票结果

在等待投票的时候,候选人可能会从其他的服务器接收到声明它是领导人的附加条目(AppendEntries)RPC。如果这个领导人的任期号(包含在此次的 RPC中)不小于候选人当前的任期号,那么候选人会承认领导人合法并回到跟随者状态。

选自Raft论文5.2领导选举

func (rf *Raft) sendRequestVote(serverID int, args *RequestVoteArgs) {
    ... ...
    if rf.currentTerm < reply.Term {
		rf.convertRole(FOLLOWER)
		rf.currentTerm = reply.Term
		rf.persist()
		return
	}
    ...
}

当一个候选人从整个集群的大多数服务器节点获得了针对同一个任期号的选票,那么他就赢得了这次选举并成为领导人。

选自Raft论文5.2领导选举

func (rf *Raft) sendRequestVote(serverID int, args *RequestVoteArgs) {
	... ...
	if rf.currentTerm == args.Term && rf.role == CANDIDATE && reply.VoteGranted {
		rf.votesNums++
		if rf.votesNums > len(rf.peers)/2 && rf.role == CANDIDATE {
			rf.convertRole(LEADER)
			rf.votesNums = 0
		}
	}
}

一旦候选人赢得选举,他就立即成为领导人。然后他会向其他的服务器发送心跳消息来建立自己的权威并且阻止发起新的选举。

选自Raft论文5.2领导选举

领导人针对每一个跟随者维护了一个nextIndex,这表示下一个需要发送给跟随者的日志条目的索引地址。当一个领导人刚获得权力的时候,他初始化所有的 nextIndex 值为自己的最后一条日志的 index 加1:

  • nextIndex[]: 初始值为领导人最后的日志条目的索引+1
  • matchIndex[]:初始值为0,单调递增

选自Raft论文5.3日志复制和图2-State

func (rf *Raft) convertRole(role Role) {
	switch role {
	... ...
	case LEADER:
		rf.role = LEADER
		rf.nextIndex = make([]int, len(rf.peers))

		lenLog := len(rf.log)
		for i := range rf.nextIndex {
			rf.nextIndex[i] = lenLog
		}
		rf.matchIndex = make([]int, len(rf.peers))
		for i := range rf.matchIndex {
			rf.matchIndex[i] = 0
		}
        
		go rf.sendHeartBeat()
	}
}

4、追加日志条目|心跳

一旦一个领导人被选举出来,他就开始为客户端提供服务。客户端的每一个请求都包含一条被复制状态机执行的指令。领导人把这条指令作为一条新的日志条目附加到日志中去,然后并行地发起附加条目 RPCs 给其他的服务器,让他们复制这条日志条目。当这条日志条目被安全地复制(下面会介绍),领导人会应用这条日志条目到它的状态机中然后把执行的结果返回给客户端。如果跟随者崩溃或者运行缓慢,再或者网络丢包,领导人会不断的重复尝试附加日志条目 RPCs (尽管已经回复了客户端)直到所有的跟随者都最终存储了所有的日志条目。

选自Raft论文5.3日志复制

4.1 触发追加日志条目|心跳

func (rf *Raft) sendHeartBeat() {
	for rf.role == LEADER && !rf.killed() {
		for i := range rf.peers {
			if i == rf.me {
				continue
			}
			args := rf.buildAppendEntriesArgs(i)

			go rf.sendAppendEntries(i, args)
		}

		time.Sleep(100 * time.Millisecond)
	}
}

4.2 构建追加日志条目|心跳的请求

  • Term、LeaderID、LeaderCommit字段直接从领导者节点中的raft结构体直接获取。
  • 追加日志条目需要以下处理:

如果对于一个跟随者,最后日志条目的索引值大于等于 nextIndex(lastLogIndex ≥ nextIndex),则发送从 nextIndex 开始的所有日志条目。

选自Raft论文图2

	if len(rf.log) >= rf.nextIndex[index] {
		sendEntries = rf.log[(rf.nextIndex[index]):]
	}
  • 针对PrevLogIndex、PrevLogTerm而言,需要检查边界条件:
if rf.nextIndex[index]-1 <= 0 {
		args.PrevLogIndex = 0
		args.PrevLogTerm = -1
	} else {
		args.PrevLogIndex = rf.nextIndex[index] - 1
		args.PrevLogTerm = rf.log[args.PrevLogIndex].Term
	}

最终代码:

func (rf *Raft) buildAppendEntriesArgs(index int) *AppendEntriesArgs {
	rf.mu.Lock()
	defer rf.mu.Unlock()

	var sendEntries []LogEntry

	if len(rf.log) >= rf.nextIndex[index] {
		sendEntries = rf.log[(rf.nextIndex[index]):]
	}

	args := &AppendEntriesArgs{
		Term:         rf.currentTerm,
		LeaderID:     rf.me,
		Entries:      sendEntries,
		LeaderCommit: rf.commitIndex,
	}

	if rf.nextIndex[index]-1 <= 0 {
		args.PrevLogIndex = 0
		args.PrevLogTerm = -1
	} else {
		args.PrevLogIndex = rf.nextIndex[index] - 1
		args.PrevLogTerm = rf.log[args.PrevLogIndex].Term
	}
	return args
}

4.3 请求追加日志|心跳接口

func (rf *Raft) sendAppendEntries(serverID int, args *AppendEntriesArgs) {
    reply := AppendEntriesReply{}
	if ok := rf.peers[serverID].Call("Raft.AppendEntries", args, &reply); !ok {
		return
	}
 ... ...
}

4.4 追加日志条目|心跳接口处理请求

如果领导人的任期小于接受者的当前任期,返回假

选自Raft论文图2-AppendEntries RPC

func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
    
	reply.Term = rf.currentTerm

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

}

如果接受者日志中如果没能能找到一个与领导者的preLogIndex以及prevLogTerm一样的索引和任期的日志条目,那么返回假

选自Raft论文图2-AppendEntries RPC

    if rf.log[args.PrevLogIndex].Term != args.PrevLogTerm {
			reply.XTerm = rf.log[args.PrevLogIndex].Term
			for i := 0; i <= args.PrevLogIndex; i++ {
				if rf.log[i].Term == reply.XTerm {
					reply.XIndex = rf.log[i].Index
					break
				}
			}
			reply.XLen = lenLog
			reply.Success = false
			return
		}
  • 如果一个已经存在的条目和新条目(即刚刚收到的日志条目)发送了冲突(索引相同,任期不同),那么就删除这个已经存在的条目以及它之后的所有条目
  • 追加日志中尚未存在的任何新条目

选自Raft论文图2-AppendEntries RPC

for i, argEntry := range args.Entries { 
		if argEntry.Index < len(rf.log) && argEntry.Term != rf.log[argEntry.Index].Term { // 对应的
			rf.log = rf.log[:(argEntry.Index)]
			rf.persist()
		}
    
		if argEntry.Index >= len(rf.log) {
			rf.log = append(rf.log, args.Entries[i:]...)
			rf.persist()
			break
		}
	}

如果领导者所知的最高提交日志条目的索引大于接收者所知的最高提交日志条目的索引(即 leaderCommit > commitIndex),那么将接收者的 commitIndex 更新为 领导者的 leaderCommit 与上一个新条目的索引之间的较小值。

选自Raft论文图2-AppendEntries RPC

if args.LeaderCommit > rf.commitIndex {
		if args.LeaderCommit < rf.log[len(rf.log)-1].Index {
			rf.commitIndex = args.LeaderCommit
		} else {
			rf.commitIndex = rf.log[len(rf.log)-1].Index
		}
	}

4.5 处理追加日志条目|心跳结果

任期检查

如果接收到的 RPC 请求或响应中,任期号T > currentTerm,则令 currentTerm = T,并切换为跟随者状态

选自Raft论文图2-Rules for Servers

	if rf.currentTerm < reply.Term {
		rf.convertRole(FOLLOWER)
		rf.currentTerm = reply.Term
		rf.persist()
		return
	}

	if rf.currentTerm != args.Term || rf.role != LEADER {
		return
	}

reply.success=True的时候

在领导人将创建的日志条目复制到大多数的服务器上的时候,日志条目就会被提交(例如在图 6 中的条目 7)。同时,领导人的日志中之前的所有日志条目也都会被提交,包括由其他领导人创建的条目。

选自Raft论文5.3日志复制

	rf.matchIndex[serverID] = args.PrevLogIndex + len(args.Entries)
	rf.nextIndex[serverID] = rf.matchIndex[serverID] + 1
	
    for i := rf.log[len(rf.log)-1].Index; i >= rf.matchIndex[serverID] && i > rf.commitIndex; i-- {
		count := 0
		for j := range rf.peers {
			if rf.matchIndex[j] >= i {
				count++
			}
			if count > (len(rf.peers)/2) && rf.log[i].Term == rf.currentTerm {
				rf.commitIndex = i
				break
			}
		}
    }

reply.success=False的时候

如果一个跟随者的日志和领导人不一致,那么在下一次的附加日志 RPC 时的一致性检查就会失败。在被跟随者拒绝之后,领导人就会减小 nextIndex 值并进行重试。最终 nextIndex 会在某个位置使得领导人和跟随者的日志达成一致。

选自Raft论文5.3日志复制

针对不一致的情况,根据实验中提出来的XTerm、XIndex、Xlen字段来更新本次跟随者的nextIndex逻辑如下:

  • Case 1: leader doesn't have XTerm: nextIndex = XIndex

  • Case 2: leader has XTerm: nextIndex = leader's last entry for XTerm

  • Case 3: follower's log is too short: nextIndex = XLen

上面的逻辑翻译过来就是:

  • 领导节点是否包含返回结果中的XTerm(即追随者与领导者冲突条目对应的任期号)
    • 如果没有,nextIndex等于该追随者的XIndex(追随者与领导者日志中第一个与XTerm匹配的日志条目的索引,直接跳到匹配处)
    • 如果有,那么nextIndex应该等于领导者日志中最后一个任期为XTerm的条目。
  • 跟随者的日志太短了,如果跟随者的最后一条日志索引(即XLen)小于领导者的索引PrevLogIndex,则nextIndex应该被设置为跟随者的日志长度(XLen)。
    if (reply.XLen - 1) < args.PrevLogIndex { 
			if reply.XLen != -1 {
				rf.nextIndex[serverID] = reply.XLen
			}
			return
	}

    lastMatchTerm := -1
    for i := len(rf.log) - 1; i >= 0; i-- {
        if rf.log[i].Term == reply.XTerm {
            lastMatchTerm = rf.log[i].Index
            break
        }
    }

    if lastMatchTerm == -1 {
        rf.nextIndex[serverID] = reply.XIndex
    } else {
        rf.nextIndex[serverID] = lastMatchTerm
    }

4.6 日志数据应用到状态机

上面的逻辑中,当日志条目被提交时,commitIndex会被更新。

如果commitIndex > lastApplied,则 lastApplied 递增,并将log[lastApplied]应用到状态机中

选自Raft论文图2-Rules for Servers

在服务启动的时候,代码中开启了一个协程,监听commitIndex和lastApplied的大小关系,用以将log[lastApplied]应用到状态机中。

func (rf *Raft) applier() {
	for !rf.killed() {
		time.Sleep(10 * time.Millisecond)
		for rf.commitIndex > rf.lastApplied {
			rf.lastApplied++
			rf.applyCh <- ApplyMsg{
				CommandValid: true,
				Command:      rf.log[rf.lastApplied].Command,
				CommandIndex: rf.log[rf.lastApplied].Index,
			}
		}
	}
}

5、持久化

论文中提到的需要持久化的字段:

根据上面的字段,完善实验中的persist和readPersist函数:

func (rf *Raft) persist() {
	w := new(bytes.Buffer)
	e := labgob.NewEncoder(w)
	e.Encode(rf.currentTerm)
	e.Encode(rf.votedFor)
	e.Encode(rf.log)
	raftState := w.Bytes()
	rf.persister.Save(raftState, nil)
}

func (rf *Raft) readPersist(data []byte) {
	if data == nil || len(data) < 1 {
		return
	}
    
	r := bytes.NewBuffer(data)
	d := labgob.NewDecoder(r)
	var CurrentTerm int
	var VotedFor int
	var Log []LogEntry

	if d.Decode(&CurrentTerm) != nil || d.Decode(&VotedFor) != nil || d.Decode(&Log) != nil {
		log.Println("持久化读取失败!")
	} else {
		rf.currentTerm = CurrentTerm
		rf.votedFor = VotedFor
		rf.log = Log
	}
}

在服务启动的时候调用读取持久化函数:

func Make(peers []*labrpc.ClientEnd, me int,
	persister *Persister, applyCh chan ApplyMsg) *Raft {
    ... ...
    rf.readPersist(persister.ReadRaftState())
    ... ...
    }

在每个修改currentTerm、votedFor、log的地方调用持久化函数,见上面的代码。

三、总结

1、关于sync.Mutex的使用

  • 使用lock函数,最好配合defer 在后面直接使用unlock解锁,加锁和解锁的逻辑放到一起,防止漏写
  • 针对需要加锁的逻辑,最好提取出现在一个函数内
  • sync.Mutex是不可重入锁,即在一个Goroutine中,不能嵌套使用,否则会导致死锁

2、关于Raft逻辑的实现

在论文中图2虽然已经将大部分的逻辑写出来了,但是每个小节里面还有对图2中的内容补充,在实现的时候,建议将论文里面的内容根据rpc请求整理出来,然后对应实现。

四、参考文献

Raft论文(中文译文) github.com/maemual/raf…

mit6.5840-spring2024 lab3:raft pdos.csail.mit.edu/6.824/labs/…