1 前言
MIT6.824 实现起来可真的繁琐,分布式的bug调起来过于麻烦,看了无数个别人得实现,我还是太菜了,QAQ,所幸通过了Lab2。
lab2 的内容是要实现一个除了节点变更功能外的 raft 算法。论文中的图二(如下图)是实现raft的关键。如果想要形象化得看Raft算法是如何运转得,可以看这个(Raft Consensus Algorithm。之前把代码开源出来收到邮件提醒叫我别这么做,我就不分享我的代码,只分享一些片段了。
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 之间网络故障),则会主动发起选举。
选举过程:
-
- 首先切换自己的状态
folower -> canadiate,增加自己的current term,给自己投一票
- 首先切换自己的状态
-
- 之后给剩余节点发送
RequestVote,如果其他节点发现canadiate的term比自己的term大的话(在lab2A中是这样的,之后的实现还需要考虑log replication和safety),就会投票给那个节点
- 之后给剩余节点发送
- 等待回复
可能的结果:
- 收到的结果为大多数同意,则成为
leader - 被告知其他的更新的
leader存在,切换状态为follower - 一段时间没有收到大多数投票也没有被告知更新
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,达到数据备份的效果。
运行流程
- 客户端向
Leader发送命令,希望该命令被所有状态机执行; Leader先将该命令追加到自己的日志中;Leader并行地向其它节点发送AppendEntries RPC,等待响应;收到超过半数节点的响应,则认为新的日志记录是被提交的:Leader将命令传给自己的状态机,然后向客户端返回响应;此外,一旦Leader知道一条记录被提交了,将在后续的AppendEntries RPC中通知已经提交记录的Followers;Follower将已提交的命令传给自己的状态机- 如果
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
prevLogIndexin its log, it should return withconflictIndex = len(log)andconflictTerm = None. - If a follower does have
prevLogIndexin its log, but the term does not match, it should returnconflictTerm = log[prevLogIndex].Term, and then search its log for the first index whose entry has term equal toconflictTerm. - 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 setnextIndexto 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. 翻译一下就是添加ConflictIndex和ConflictTerm字段:
- 若
follower没有prevLogIndex处的日志,则直接置conflictIndex = len(log),conflictTerm = None;
leader收到返回体后,肯定找不到对应的term,则设置nextIndex = conflictIndex;- 其实就是
leader对应的nextIndex直接回退到该follower的日志条目末尾处,因为prevLogIndex超前了
- 若
follower有prevLogIndex处的日志,但是term不匹配;则设置conlictTerm为prevLogIndex处的term,且肯定可以找到日志中该term出现的第一个日志条目的下标,并置conflictIndex = firstIndexWithTerm;
leader 收到返回体后,有可能找不到对应的 term,即 leader 和 follower 在conflictIndex处以及之后的日志都有冲突,都不能要了,直接置nextIndex = conflictIndex
若找到了对应的term,则找到对应term出现的最后一个日志条目的下一个日志条目,即置nextIndex = lastIndexWithTerm + 1;这里其实是默认了若 leader 和 follower 同时拥有该 term 的日志,则不会有冲突,直接取下一个 term 作为日志发起就好,是源自于 safety 的安全性保证
如果还有冲突,leader 和 follower 会一直根据以上规则回溯 nextIndex
2.4 持久化
持久化是最简单的部分,在Raft论文中需要持久化的字段只有3个 currentTerm,voteFor,log[],我是有相关变动的时候直接持久化一次。
2.5 日志压缩和快照
随着时间推移,存储的日志会越来越多,不但占据很多磁盘空间,服务器重启做日志重放也需要更多的时间。如果没有办法来压缩日志,将会导致可用性问题:要么磁盘空间被耗尽,要么花费太长时间启动。所以日志压缩是必要的。 日志快照就是把当前状态记录下来,把该状态前的所有操作信息都丢弃。 对于go语言来说,这篇bolg很好的讲了如何让内存不泄露go 避免切片内存泄露 - hubb - 博客园 (cnblogs.com) 快照时间:
-
- 服务端触发日志压缩
-
- leader发来的InstallSnapshot请求:当收到其他节点的压缩请求后,先上报上层应用,然后再决定是否安装快照。
- 对于
leader发过来的InstallSnapshot,只需要判断term是否正确,如果无误则follower只能无条件接受。 - 对于服务上层触发的 CondInstallSnapshot,与上面类似,如果 snapshot 没有更新的话就没有必要去换,否则就接受对应的 snapshot 并处理对应状态的变更。
3 结语
做Lab2的时候真的痛苦,感觉人都要麻了,到处报错。不过写完之后感觉自己对分布式了解了不少,感谢大佬们留下的文档,没有前人的文章和实现的片段代码我是做不完这个lab2的。感谢!