lab3B--日志复制
在这个实验中,我们需要基于上次实验的Leader选举
和心跳机制
实现日志复制
这一内容。还是在这里给出一些参考资料:
任务简介
在论文中,日志复制逻辑如下:
- Leader:
- 客户端向Raft集群的一个节点发送命令,如果发送的节点不是
Leader
,那么该节点就会通过心跳得知leader
并返回给client
。但是不是所有的Follower
节点都有Leader
消息,如果某个Follower
因为网络问题暂时没有收到心跳,它不会立即知道谁是Leader
。如果这个Follower
收到了客户端的请求,它会直接返回错误,告诉客户端自己不是Leader
。然后客户端会重试。 Leader
收到了命令,在Start()
函数中将其构造为一个日志项,添加当前节点的currentTerm
为日志项的Term
, 并将其追加到自己的log
中。一旦将其追加到自己的log
中,那么位于sendHeartbeats()
协程中的判断条件就会通过,得知自己可以发送日志。Leader
通过心跳发送函数发送AppendEntries RPC
给所有节点,其他节点根据这个消息复制日志,AppendEntries RPC
需要增加PrevLogIndex
、PrevLogTerm
以供follower
校验, 其中PrevLogIndex
、PrevLogTerm
由nextIndex
确定。- 如果
RPC
返回了成功, 则更新matchIndex
和nextIndex
, 同时寻找一个满足过半的matchIndex[i] >= N
的索引位置N
, 将其更新为自己的commitIndex
, 并提交直到commitIndex
部分的日志项。这里的提交部分是另起一个协程去检测。 - 如果
RPC
返回了失败,且伴随的的Term
更大, 表示自己已经不是Leader
了,需要退化为Follower
并且重置自己的投票,进入新的任期后必须重置投票,因为每个节点在新的任期都可进行一次投票。 - 如果
RPC
返回了失败,,且伴随的的Term
和自己的currentTerm
相同,将nextIndex
自减再重试。如果Follower在PrevLogIndex位置的日志项的Term与PrevLogTerm不匹配,就会出现这种情况。Leader将nextIndex自减,意味着下次会尝试发送更早的日志,这是一个"回退"机制,目的是找到Leader和Follower日志一致的位置。
- 客户端向Raft集群的一个节点发送命令,如果发送的节点不是
- Follower:
follower
收到AppendEntries RPC
后,currentTerm
不匹配直接告知更新的Term
,并返回false
。follower
收到AppendEntries RPC
后,通过PrevLogIndex
、PrevLogTerm
可以判断出"leader
认为自己log
的结尾位置"是否存在并且Term
匹配,如果不匹配的话,返回false。- 如果位置信息匹配的话,则需要判断插入位置是否有旧的日志项, 如果有, 则向后将
log
中冲突的内容清除。 - 将
RPC
中的日志项追加到log
中。 - 根据
RPC
的传入参数更新commitIndex
,,并提交直到commitIndex
部分的日志项。
官方提示
- 您的第一个目标是通过 TestBasicAgree3B() 测试。首先实现 Start() 函数,然后编写代码通过 AppendEntries RPC 发送和接收新的日志条目,遵循论文中的图2。在每个节点上,通过 applyCh 发送每个新提交的条目。
- 需要实现选举限制,就是说如果一个服务器要成为
Leader
,那么它的日志必须具有权威。 - 代码可能包含重复检查某些事件的循环。不要让这些循环持续不断地执行而不暂停,因为这会使您的实现变得太慢,导致测试失败。使用 Go 的条件变量,或在每个循环迭代中插入 time.Sleep(10 * time.Millisecond)。
代码设计
根据论文可知,上次实验中实现的心跳实际上就是一种特殊的AppendEntries
,其特殊在Entries
长度为0。
• If last log index ≥ nextIndex for a follower: send AppendEntries RPC with log entries starting at nextIndex • If successful: update nextIndex and matchIndex for follower (§5.3) • If AppendEntries fails because of log inconsistency: decrement nextIndex and retry (§5.3)
AppendEntries
除了RPC
失败的情况下,会一直重试,直到返回true
,那么如果我们单独创建一个协程用于发送真正的不为心跳的AppendEntries
, 需要考虑如下的问题:
- 重试是应该立即重试,还是设置一个重置超时。
- 如何触发这个处理
AppendEntries
的协程,是累计了多个个日志项后再出发处理协程? 还是一旦有一个日志项就触发? - 发射心跳处理函数时也会附带
PrevLogIndex
和PrevLogTerm
以供follower
验证, 心跳函数的这些参数会不会和之前的AppendEntries
冲突?follower
端如何处理这些重复的内容?
由上述知,如果将AppendEntries
和心跳的发射器分开实现,会增加代码的复杂度,同时AppendEntries
也具有重复发送的特点,这和心跳的特点完美契合,因此,我们得出如下结论: AppendEntries
可以和心跳公用同一个发射器。
首先我们需要按照RPC
设计图添加几个Raft结构体的参数:
go
// A Go object implementing a single Raft peer.
type Raft struct {
......
commitIndex int // 提交日志的索引
lastApplied int // 给上层应用日志的索引
nextIndex []int // 发给 follower[i] 的下一条日志索引
matchIndex []int // follower[i] 已复制的最大日志索引
applyCh chan ApplyMsg // 向上层应用传递消息的管道
}
由于我们在Raft结构体中添加了新的参数,所以我们之前的心跳发射器需要更改部分代码:
go
// leader定期发送心跳
func (rf *Raft) sendHeartbeats() {
......
args := &AppendEntriesArgs{
Term: rf.currentTerm,
LeaderId: rf.me,
PrevLogIndex: rf.nextIndex[i] - 1,
PrevLogTerm: rf.log[rf.nextIndex[i]-1].Term,
LeaderCommit: rf.commitIndex,
}
if len(rf.log)-1 >= rf.nextIndex[i] {
// 如果有新的log需要发送,则就是一个真正的AppendEntries而不是心跳
args.Entries = rf.log[rf.nextIndex[i]:]
DPrintf("leader %v 开始向 server %v 广播新的AppendEntries\n", rf.me, i)
} else {
// 如果没有新的log发送,就发送一个长度为0的切片表示心跳
args.Entries = make([]Entry, 0)
DPrintf("leader %v 开始向 server %v 广播新的心跳, args = %+v \n", rf.me, i, args)
}
go rf.handleHeartBeat(i, args)
......
}
我们来解析一下构造args
的参数:
- 其中Term和LeaderId都是帮助
Follower
知道当前是在哪个任期,哪个Leader负责发送日志,是当前Raft集群的相关信息。 - PrevLogIndex和PrevLogTerm:PrevLogIndex表示新日志条目之前的那个日志索引,而PrevLogTerm表示PrevLogIndex对应的任期号。这两个参数的作用是确保日志的一致性。当
Leader
向Follower
发送AppendEntries RPC时,会包含这两个参数,Follower
会检查自己在这个位置的日志是否与Leader
一致。如果一致,Follower
会接受新的日志条目;如果不一致,Follower
会拒绝,Leader
会通过回退nextIndex
来找到双方日志一致的位置。这种机制确保了即使在网络分区或节点故障的情况下,系统最终也能达到日志一致的状态。 - LeaderCommit:它表示Leader当前已提交的日志索引。
Follower
会根据这个参数来更新自己的commitIndex
。具体来说,如果LeaderCommit
大于Follower
当前的commitIndex
,Follower
会将commitIndex
更新为LeaderCommit
和自身日志长度中的较小值。这个机制确保了Follower
能够及时提交已经被Leader
确认的日志条目,从而保持整个集群的日志一致性。
由if len(rf.log)-1 >= rf.nextIndex[i]
这个判断条件知,当Leader
将客户端的请求变为日志追加到自己的log中时,如果具备向其中一个服务器发送log的能力,就向其发送,因为发送AppendEntires
是由单独的协程起的,所以可以异步进行。
然后是Leader
向每个服务器发送AppendEntires
的处理函数handleHeartBeat()
:
go
func (rf *Raft) handleHeartBeat(serverTo int, args *AppendEntriesArgs) {
......
if reply.Success {
// server回复成功
rf.matchIndex[serverTo] = args.PrevLogIndex + len(args.Entries)
rf.nextIndex[serverTo] = rf.matchIndex[serverTo] + 1
// 如果有超过一半的节点都收到某一日志,则可以提交
n := len(rf.log) - 1
for n > rf.commitIndex {
count := 1 // 包括Leader自己
for i := 0; i < len(rf.peers); i++ {
if i == rf.me {
continue
}
if rf.matchIndex[i] >= n && rf.log[n].Term == rf.currentTerm {
count += 1
}
}
if count > len(rf.peers)/2 {
// 如果至少一半的follower回复了成功, 更新commitIndex
rf.commitIndex = n
break
}
n -= 1
}
rf.mu.Unlock()
return
}
if reply.Term > rf.currentTerm {
// 回复了更新的term, 表示自己已经不是leader了
DPrintf("server %v 旧的leader收到了来自 server % v 的心跳函数中更新的term: %v, 转化为Follower\n", rf.me, serverTo, reply.Term)
rf.currentTerm = reply.Term
rf.state = Follower
rf.votedFor = -1
rf.timeStamp = time.Now()
rf.mu.Unlock()
return
}
if reply.Term == rf.currentTerm && rf.state == Leader {
// term仍然相同, 且自己还是leader, 表名对应的follower在prevLogIndex位置没有与prevLogTerm匹配的项
// 将nextIndex自减再重试
rf.nextIndex[serverTo] -= 1
rf.mu.Unlock()
return
}
}
注意,当前函数并不需要死循环去轮询对应Follower
是否接收成功了AppendEntires
,因为心跳重试机制在sendHeartbeats()
中已经存在了,对应for !rf.killed()
和time.Sleep(time.Duration(HeartBeatTimeOut) * time.Millisecond)
,所以这个函数不需要死循环。
函数在向Follower
发送RPC
之后还需要处理回复,逻辑如下:
- 如果回复成功的话,我就更新
matchIndex
和nextIndex
中对应服务器的值,便于我们发送下一次AppendEntires
时跟踪对应服务器需要哪些日志。 - 接下来的逻辑是判断如果有超过一半的节点都收到某一日志,则可以提交。通过一个循环进行,注意,这个循环在每个向不同
Follower
发送AppendEntries
的协程中都存在,所以commitIndex
是不断更新的,直到最后一个协程处理完回复之后才是最新。 if reply.Term > rf.currentTerm
这个判断条件是检测到Follower
的任期比自己的大,退化为Follower
,重置选票之后直接退出。if reply.Term == rf.currentTerm && rf.state == Leader
这个判断条件表示:表名对应的follower在prevLogIndex位置没有与prevLogTerm匹配的项,我们需要将nextIndex自减后重试。
下面修改每个Follower
收到AppendEntires
的处理函数,先理清楚一下这个函数需要做的事情:
- 检测是心跳还是
AppendEntires
。 - 检测当前节点的日志与
args
参数是否匹配,匹配的话就拓展日志。 - 不匹配的话就返回信息,便于
Leader
重试。 - 有必要的情况下更新
commitIndex
。
下面是对应代码:
go
// 处理AppendEntries RPC(心跳)
func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
......
if len(args.Entries) == 0 {
//心跳函数
DPrintf("server %v 接收到 leader &%v 的心跳\n", rf.me, args.LeaderId)
} else {
DPrintf("server %v 收到 leader %v 的的AppendEntries: %+v \n", rf.me, args.LeaderId, args)
}
if args.PrevLogIndex >= len(rf.log) || rf.log[args.PrevLogIndex].Term != args.PrevLogTerm {
// PrevLogIndex和PrevLogTerm不合法
reply.Term = rf.currentTerm
rf.mu.Unlock()
reply.Success = false
DPrintf("server %v 检查到心跳中参数不合法:\n\t args.PrevLogIndex=%v, args.PrevLogTerm=%v, \n\tlen(self.log)=%v, self最后一个位置term为:%v\n", rf.me, args.PrevLogIndex, args.PrevLogTerm, len(rf.log), rf.log[len(rf.log)-1].Term)
return
}
if len(args.Entries) != 0 && len(rf.log) > args.PrevLogIndex+1 && rf.log[args.PrevLogIndex+1].Term != args.Entries[0].Term {
// 发生了冲突, 移除冲突位置开始后面所有的内容
DPrintf("server %v 的log与args发生冲突, 进行移除\n", rf.me)
rf.log = rf.log[:args.PrevLogIndex+1]
}
// append逻辑
rf.log = append(rf.log, args.Entries...)
if len(args.Entries) != 0 {
DPrintf("server %v 成功进行apeend\n", rf.me)
}
reply.Success = true
reply.Term = rf.currentTerm
// 根据Leader中的提交信息更新当前节点的提交信息
if args.LeaderCommit > rf.commitIndex {
rf.commitIndex = int(math.Min(float64(args.LeaderCommit), float64(len(rf.log)-1)))
}
rf.mu.Unlock()
}
按照官方提示,我们还需要完成Start()
函数,我们先来看一下这个函数是做什么的,框架代码中的注释说明了:Start
函数只是将command
追加到自己的log
中,因此其不保证command
一定会提交。因此我们在之前的心跳发送器中检测到了log的变更,所以可以进行发送日志的判断,如果成功的话就发送日志。
go
func (rf *Raft) Start(command interface{}) (int, int, bool) {
rf.mu.Lock()
defer rf.mu.Unlock()
if rf.role != Leader {
return -1, -1, false
}
newEntry := &Entry{Term: rf.currentTerm, Cmd: command}
rf.log = append(rf.log, *newEntry)
return len(rf.log) - 1, rf.currentTerm, true
}
还需要一个将日志应用到状态机的协程,CommitChecker
也是一个轮询的协程,其不断检查rf.commitIndex > rf.lastApplied
,将rf.lastApplied
递增然后发送到管道applyCh
。
go
func (rf *Raft) CommitChecker() {
// 检查是否有新的commit
for !rf.killed() {
rf.mu.Lock()
for rf.commitIndex > rf.lastApplied {
rf.lastApplied += 1
msg := &ApplyMsg{
CommandValid: true,
Command: rf.log[rf.lastApplied].Cmd,
CommandIndex: rf.lastApplied,
}
rf.applyCh <- *msg
DPrintf("server %v 准备将命令 %v(索引为 %v ) 应用到状态机\n", rf.me, msg.Command, msg.CommandIndex)
}
rf.mu.Unlock()
time.Sleep(CommitCheckTimeInterval)
}
}
最后一个需要修改的函数就是选举函数,根据论文,nextIndex[]
和matchIndex[]
,都是存储在内存中的数据,如果Leader
宕机之后需要重新初始化,而这两个变量的初始化,nextIndex[]
的初始化是乐观的,它总是认为其他节点的日志与自己的一样,如果发生不匹配的情况就会将nextIndex[]
自减然后重试。matchIndex[]
是悲观初始化的,它总是认为其他节点没有与Leader
已经同步的日志,所以初始化为0,在处理Follower
对于Leader
的回应时会通过rf.matchIndex[serverTo] = args.PrevLogIndex + len(args.Entries)
,快速对齐。
go
func (rf *Raft) collectVote(serverTo int, args *RequestVoteArgs) {
...
if rf.voteCount > len(rf.peers)/2 {
...
rf.role = Leader
// 需要重新初始化nextIndex和matchIndex
for i := 0; i < len(rf.nextIndex); i++ {
rf.nextIndex[i] = len(rf.log)
rf.matchIndex[i] = 0
}
...
}
rf.muVote.Unlock()
}
执行测试:
