文章目录
1. 任务
执行领导者和追随者代码,添加新的日志条目,以便 go test -run 2B
测试通过。
其实在LAB2A中,我们就已经实现了大部分的结构体定义了,2B就得额外添加上RequestVote RPC
的选举条件和AppendEntries RPC
的选举条件。另外,如果说2A是领导选举和心跳,那么2B的心跳就得加上日志条目信息了。这个任务官方给出的难度是Hard,但如果2A按照我那样做出来了,那我觉得这部分要做的就并不多了,也就Simple水平,所以感谢前面为我们未来铺的路吧,无论是项目还是人生。 我何德何能,敢在几天前说出这种话令后人耻笑,我已经debug到快要崩溃了。
我们本次要完成的任务就是:
- 完善
AppendEntries()
- 完善
Ticker()
- 完善
Make()
- 实现
Start()
2. log replication of Raft
2.1 Raft结构体
我觉得这里要重点理解一下几个变量,这几个变量我们在2A没有用到,在2B要用到了。
commitIndex
已知可以提交的日志条目下标,会和lastApplied
搭配使用
lastApplied
已经提交了的日志条目下标
commitIndex
和lastApplied
组成了 follower
的日志提交方式
nextIndex[]
leader 专有,nextIndex
为乐观估计,指代 leader 保留的对应 follower 的下一个需要传输的日志条目,应该初始化为len(rf.log)
matchIndex[]
leader 专有,matchIndex
为悲观估计,初始化为-1,指代 leader 已经传输给对应 follower 的日志条目下标,即follower 目前所拥有的的总日志条目,通常为nextIndex - 1
commitIndex,lastApplied,nextIndex[],matchIndex[]共同组成了 leader 的提交规则,并且 leader 总是最先提交的,可以认为 leader 为这个集群的代表,leader 提交后,follower 才会提交
applyChan chan ApplyMsg
将日志写入channel,测试的时候就会验证该通道
2.2 Start函数
这是外部接口函数,该函数是接受一个command,如果当前节点是leader,则将该command加入到日志中。
func (rf *Raft) Start(command interface{}) (int, int, bool) {
index := -1
term := -1
rf.mu.Lock()
defer rf.mu.Unlock()
if rf.state != Leader {
return index, term, false
}
// 添加新日志
e := logEntry{command, rf.currentTerm}
rf.log = append(rf.log, e)
index = len(rf.log)
term = rf.currentTerm
return index, term, true
}
2.3 初始化
这部分和lab2a一样,没什么变化。当时在做lab2a的时候就已经实现了。需要注意的一点是,这里我们将lastApplied
初始化为了-1,其实也能理解嘛,这个参数是可commit的index,如果为0,那岂不是一开始就能commit了。
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.log = make([]logEntry, 0)
rf.commitIndex = -1
rf.lastApplied = -1
rf.nextIndex = make([]int, len(peers))
rf.matchIndex = make([]int, len(peers))
rf.state = Follower
rf.voteCount = 0
rf.timer = Timer{timer: time.NewTicker(time.Duration(150 + rand.Intn(200))*time.Millisecond)}
// initialize from state persisted before a crash
rf.readPersist(persister.ReadRaftState())
// start ticker goroutine to start elections
go rf.ticker()
return rf
}
2.4 ticker
这一部分主要是我们在执行sendRequestVote
和sendAppendEntries
的一些参数初始化。我这几天大部分时间都是在这个参数初始化上面折腾,搞得我血压真的高了(可能也是我太菜了吧)。PrevLogIndex
都是初始化为rf.nextIndex[i] - 1
,这个nextIndex
会在后续不断的心跳检测中变换,以便将日志提交给follower。
func (rf *Raft) ticker() {
for rf.killed() == false {
select {
case <-rf.timer.timer.C:
rf.mu.Lock()
switch rf.state {
case Follower: // follower->candidate
rf.state = Candidate
// fmt.Println(rf.me, "进入candidate状态")
fallthrough
case Candidate: // 成为候选人,开始拉票
rf.currentTerm++
rf.voteCount = 1
rf.timer.reset()
rf.votedFor = rf.me
// 开始拉票选举
for i := 0; i < len(rf.peers); i++ {
if rf.me == i { // 排除自己
continue
}
args := RequestVoteArgs{Term: rf.currentTerm, CandidateId: rf.me, LastLogIndex: len(rf.log)-1}
if len(rf.log) > 0 {
args.LastLogTerm = rf.log[len(rf.log)-1].Term
}
reply := RequestVoteReply{}
go rf.sendRequestVote(i, &args, &reply)
}
case Leader:
rf.timer.resetHeartBeat()
for i := 0; i < len(rf.peers); i++ {
if i == rf.me {
continue
}
args := AppendEntriesArgs{Term: rf.currentTerm, LeaderId: rf.me, PrevLogIndex: rf.nextIndex[i] - 1,
PrevLogTerm: 0, Entries: nil, LeaderCommit: rf.commitIndex}
if args.PrevLogIndex >= 0 {
args.PrevLogTerm = rf.log[args.PrevLogIndex].Term
}
if rf.nextIndex[i] < len(rf.log) { // 刚成为leader的时候更新过 所以第一次entry为空
entries := rf.log[rf.nextIndex[i]:] //如果日志小于leader的日志的话直接拷贝日志
args.Entries = make([]logEntry, len(entries))
copy(args.Entries, entries)
}
// fmt.Println("写入的主机是:", i,"len(rf.log):", len(rf.log), "PrevLogIndex:", args.PrevLogIndex, "rf.nextIndex[i]:", rf.nextIndex[i], "rf.currentTerm:", rf.currentTerm, "rf.commitIndex:", rf.commitIndex)
reply := AppendEntriesReply{}
go rf.sendAppendEntries(i, &args, &reply)
}
}
rf.mu.Unlock()
}
}
}
2.5 AppendEntries
关于何时附加日志,首先明确,follower 只会在收到AppendEntries rpc
请求后执行提交。
结构体
type AppendEntriesArgs struct {
Term int // leader's term
LeaderId int // Leader的id
PrevLogIndex int // 前一个日志的日志号
PrevLogTerm int // 前一个日志的任期号
Entries []logEntry // 当前日志体
LeaderCommit int // leader的已提交日志号 若leadercommit>commitIndex,那么把commitIndex设为min(若leadercommit, index of last new entry)
}
type AppendEntriesReply struct {
Term int // 自己当前的任期号
Success bool // 如果follower包括前一个日志,则true
CommitIndex int // 用于返回与Leader.Term的匹配项,方便同步日志
}
follower的具体流程如下:
- 判断自己的
Term
和LeaderTerm
大小,若自己更大的话,拒绝,reply里面有自己的term,所以leader会变为follower; - 进行conflict判断,Leader保存的
nextIndex
一开始为节点的日志总长度,但是Follower节点的日志数目肯定不大于nextIndex
,原因有很多,比如这个follower之前是leader,一部分数据没有提交,亦或是单纯有一些数据丢失了,所以这个时候就需要依靠PrevLogIndex
来寻找尽可能多相同的日志。这一点在论文5.3preLogIndex
大于当前日志最大下标,说明缺失日志,拒绝附加;利用CommitIndex
定位到日志长度的位置,这就是下一次的位置;- 在相同index下日志的Term不同,说明日志冲突,拒绝附加;这里用到了课上说的快重传加速,加速找到follower需要写入的位置,这样就不用一个一个地去回溯了。
- 若到了第三步,说明不存在冲突问题了,现在要做的就是将传来的日志进行判断:
- 将args中后面的日志一次性全部添加进log,并对比
LeaderCommit
,执行Commit操作;注意,由于我们之前就将PrevLogIndex
一步到位了,所以没什么额外的操作了; - 若日志为空,说明这个请求只是一个
HeartBeat
,只需要判断一下是否有可提交的新日志(对比LeaderCommit
)
- 将args中后面的日志一次性全部添加进log,并对比
RPC函数
func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
rf.mu.Lock()
defer rf.mu.Unlock()
reply.Term = rf.currentTerm
reply.Success = false
reply.CommitIndex = 0
// fmt.Println("收到心跳")
// 收到rpc的term比自己的小 (§5.1)
if args.Term < rf.currentTerm { // 并通知leader变为follower
return
}
if args.Term > rf.currentTerm { // 承认对方是leader
rf.convert2Follower(args.Term)
}
if args.PrevLogIndex >= 0 && // 首先leader要有日志
(len(rf.log)-1 < args.PrevLogIndex || // 1. preLogIndex大于当前日志最大下标,说明缺失日志,拒绝附加
rf.log[args.PrevLogIndex].Term != args.PrevLogTerm) { // 2. 在相同index下日志不同
reply.CommitIndex = len(rf.log) - 1
if reply.CommitIndex > args.PrevLogIndex {
reply.CommitIndex = args.PrevLogIndex // 多出来的日志会被舍弃掉,需要和leader同步
}
curTerm := rf.log[reply.CommitIndex].Term
for reply.CommitIndex >= 0 {
if rf.log[reply.CommitIndex].Term == curTerm { // speed up,更快找到下标
reply.CommitIndex--
} else {
break
}
}
reply.Success = false // 返回false说明此节点日志没有跟上leader,或者有多余日志,或者日志有冲突
} else if args.Entries == nil { // heartbeat 用于更新状态
if rf.lastApplied < args.LeaderCommit {
rf.commitIndex = args.LeaderCommit
go rf.applyLogs() // 提交日志
}
reply.CommitIndex = len(rf.log) - 1 // 用于leader更新对应主机的nextIndex
reply.Success = true
} else { // 需要同步日志
// fmt.Println("同步日志开始, lastApplied:", rf.lastApplied, "LeaderCommit: ", args.LeaderCommit)
rf.log = rf.log[:args.PrevLogIndex + 1] // 第一次调用的时候prevlogIndex为-1
rf.log = append(rf.log, args.Entries...) // 将args中后面的日志一次性全部添加进log
if rf.lastApplied < args.LeaderCommit {
rf.commitIndex = args.LeaderCommit // 与leader同步信息
go rf.applyLogs()
}
reply.CommitIndex = len(rf.log) - 1 // 用于leader更新对应主机的nextIndex
if args.LeaderCommit > rf.commitIndex && args.LeaderCommit < len(rf.log) - 1{
reply.CommitIndex = args.LeaderCommit // 令 commitIndex 等于 leaderCommit 和 新日志条目索引值中较小的一个
}
}
rf.timer.reset()
}
上面的心跳执行完了后,就要在sendAppendEntries
处理我们Leader的逻辑了:
- 如果返回值中的Term大于leader的Term,证明出现了分区,节点状态转换为follower;
- 如果RPC成功的话更新leader对于各个服务器的状态;
- 如果RPC失败的话证明两边日志不一样,使用前面提到的reply。CommitIndex作为nextIndex,用于请求参数中的PrevLogIndex。
func (rf *Raft) sendAppendEntries(server int, args *AppendEntriesArgs, reply *AppendEntriesReply) {
// 一直请求rpc,直到成功
if ok := rf.peers[server].Call("Raft.AppendEntries", args, reply); !ok {
// fmt.Println("RPC心跳连接失败,他的id是:", server)
return
}
rf.mu.Lock()
defer rf.mu.Unlock()
if rf.state != Leader || args.Term < rf.currentTerm || rf.currentTerm != args.Term {
return
}
// 自己的term没别人的大,变为follower
if rf.currentTerm < reply.Term {
rf.convert2Follower(reply.Term)
return
}
if reply.Success {
rf.nextIndex[server] = reply.CommitIndex + 1 // CommitIndex为对端确定两边相同的index 加上1就是下一个需要发送的日志
rf.matchIndex[server] = rf.nextIndex[server] - 1
if rf.nextIndex[server] > len(rf.log) {
rf.nextIndex[server] = len(rf.log)
rf.matchIndex[server] = rf.nextIndex[server] - 1
}
commitCount := 1 // 自己
for i := 0; i < len(rf.peers); i++ {
if i == rf.me {
continue
}
if rf.matchIndex[i] >= rf.matchIndex[server] {
// fmt.Println("机器数量为:", len(rf.peers), "commitCount:", commitCount)
commitCount++
}
}
// fmt.Printf("commitCount:%d\ncommitIndex: %d, rf.matchIndex[server]: %d\nrf.log[rf.matchIndex[server]].Term: %d, rf.currentTerm: %d", commitCount,rf.commitIndex,rf.matchIndex[server],rf.log[rf.matchIndex[server]].Term,rf.currentTerm )
if commitCount >= len(rf.peers)/2+1 && // 超过一半的数量接收日志了
rf.commitIndex < rf.matchIndex[server] && // 保证幂等性 即同一条日志正常只会commit一次
rf.log[rf.matchIndex[server]].Term == rf.currentTerm {
rf.commitIndex = rf.matchIndex[server]
go rf.applyLogs() // 提交日志
}
} else {
rf.nextIndex[server] = reply.CommitIndex + 1
if rf.nextIndex[server] > len(rf.log) {
rf.nextIndex[server] = len(rf.log)
}
}
}
2.5 另外一些辅助函数
// 将日志写入管道
func (rf *Raft) applyLogs() {
rf.mu.Lock()
defer rf.mu.Unlock()
if rf.commitIndex > len(rf.log)-1 {
fmt.Println("出现错误 : raft.go commitlogs()")
}
// fmt.Println("开始写入管道")
// 初始化是-1
for i := rf.lastApplied + 1; i <= rf.commitIndex; i++ {
rf.applyChan <- ApplyMsg{
CommandValid: true,
Command: rf.log[i].Command,
CommandIndex: i + 1,
}
}
rf.lastApplied = rf.commitIndex
}
func (rf *Raft) convert2Follower(term int) {
rf.currentTerm = term
rf.state = Follower
rf.voteCount = 0
rf.votedFor = -1
rf.timer.reset()
}
3. 测试
TestBasicAgree2B:最基础的追加日志测试。发送日志并查看提交情况
TestRPCBytes2B:基于RPC的字节数检查保证每个cmd都只对每个peer发送一次。空log不算在字节数里
For2023TestFollowerFailure2B:断联leader后剩余节点能否正常提交,断联所有节点后再追加log能否保证不提交
For2023TestLeaderFailure2B:3个peer断联第一个leader后保证提交,断联第二个leader后保证不提交
TestFailAgree2B:断连小部分,不影响整体Raft集群的情况检测追加日志。
TestFailNoAgree2B:断连过半数节点,保证无日志可以正常追加。然后又重新恢复节点,检测追加日志情况。
TestConcurrentStarts2B:模拟客户端并发发送多个命令
TestRejoin2B:Leader 1断连,再让旧leader 1接受日志,再给新Leader 2发送日志,2断连,再重连Leader 1,提交日志,再让2重连,再提交日志。
TestBackup2B:先给Leader 1发送日志,然后断连3个Follower(总共1Ledaer 4Follower),网络分区。提交大量命令给1。然后让leader 1和其Follower下线,之前的3个Follower上线,向它们发送日志。然后在对剩下的仅有3个节点的Raft集群重复上面网络分区的过程。
TestCount2B:检查无效的RPC个数,不能过多。
终于!!!我激动地在实验室打拳跳了起来!!!
4. 总结
这个LabB是我目前为止花时间最多的一个Lab,好吧,其实之前也就做了2个。这几天真的是茶饭不思,午休的时候也一直在想,晚上睡觉失眠也在想,健身也在想。一开始脑子混乱,到昨天晚上突然茅塞顿开,然后今天又花了一天肝了出来,激动之心难以言表。昨天还在感慨马上8月中旬了,我Lab2都还没做完一半,心里那个急啊,想着马上做完这个就得开始复习了,不过好在今天把这个给做出来了,据说Lab2c很简单(希望不要骗我了)。这个测试我也只跑了一遍,可能还有很多概率性错误,但我不想想了暂时,这几天真的累死了。就这样吧,今天早点回去休息一下,弹下吉他,早点休息了。
给个建议,还是得细品论文,如果你不想看论文,可以看官网的动画演示,自己模拟,兴许就有思路了。我当时就是这样做的,这个动画真的能解答你很多的疑惑。