Overview
日志复制
特性
- Raft 算法保证所有已提交的日志条目都是持久化的并且最终会被所有可用的状态机执行。
- 日志匹配特性:
- 如果在不同的日志中的两个条目拥有相同的索引和任期号,那么他们存储了相同的指令。
- 如果在不同的日志中的两个条目拥有相同的索引和任期号,那么他们之前的所有日志条目也全部相同。
流程
- 在领导人将创建的日志条目复制到大多数的服务器上的时候,日志条目就会被提交。同时,领导人的日志中之前的所有日志条目也都会被提交,包括由其他领导人创建的条目。
- 一旦跟随者知道一条日志条目已经被提交(commited),那么他也会将这个日志条目按照日志的顺序应用(apply)到本地的状态机中。
AppendEntries
AppendEntries 在 Lab2A 中已经实现了一部分功能,此时我们需要根据 Figure 2 来继续实现。
接收者除了在 Lab2A 实现的检查 args.Term 与 rf.currentTerm 是否过期以外,还需要:
- 如果在 args.PrevLogIndex 的日志项的 Term 与 args.PrevLogTerm 不一致,那么返回 false。
- 如果接收者存在一个日志项,与复制的日志产生冲突(相同 index 的 term 不一致),那么删除该 index 及之后的所有日志项。
- 插入所有不存在于接收者的需要复制的日志。
- 如果 args.LeaderCommit > rf.commitIndex,那么更新 commitIndex=min(args.LeaderCommit, 最后一个日志项的 index)。
除了实现接收者的逻辑,还需要实现 Leader 在接收到 AppendEntries 回复的逻辑:
- 当从 client 接收到命令时,将日志项插入到本地日志中,并在日志项 applied 到状态机时进行响应。
- 如果最后的日志 index ≥ nextIndex[followerID],那么将从 nextIndex[followerID] 开始的日志都通过 AppendEntries RPC 发送到 follower。
- 如果成功:更新 follower 的 nextIndex 和 matchIndex。
- 如果由于日志不一致而失败,减少 nextIndex 并重试。
- 如果存在一个日志 index N,满足下面三个条件,那么将 leader 的 commitIndex 设为 N。
- 大多数 follower 的 matchIndex 大于 N,
- N 大于目前 leader 的 commitIndex,
- leader 的 log[N].Term 等于 rf.currentTerm。
Persistence
Lab2C 的持久化实现相比 2B 来说简单不少,不用实现 log compaction 也可以通过所有测试。
主要是实现持久化和读取持久化,其他主要是依赖于 2B 的实现,在 2C 中失败的测试用例,可能是 2B 中一些地方没做好。
Implementation
日志复制
从实现 Start()
开始,根据 Figure 2 实现 AppendEntries RPC 来完成日志复制的功能。
日志持久化
实现 persist()
和 readPersist
,对 Figure 2 中写明需要持久化的几个状态进行持久化和读取:
- currentTerm
- votedFor
- logs
请求处理模型
在 Lab2A 中的请求处理模型已经不能满足我们的需求了:
- 不支持顺序请求队列。日志的复制是需要按顺序且不冗余插入的,我们希望 server 能够同一时间只处理一个请求,直到该请求处理完再继续处理下一个请求。
- 不支持异步回调,例如在发送心跳中,需要等所有请求处理完才做响应操作。我们希望多个请求之间不互相阻塞,并能够在请求处理完对响应做回调处理。
基于上面两个需求,使用一种基于 channel 的生产者-消费者的处理模型:
- 由两个无缓冲 channel(requestCh 和 replyCh)作为消息队列来接收请求和响应。
- 有一个消费者的 goroutine 来监听这两个 channel 来处理请求和响应,保证同时只能处理一个请求/响应。
- 当 server 接收到 RPC 请求的时候,将 RPC 请求加入到 requestCh 中,并阻塞等待消费者处理完请求再返回。
- 当 server 接收到 RPC 响应的时候,将 RPC 响应加入到 replyCh 中,等待消费者处理完返回。
生产者
// 接收者
func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
rf.RequestCh <- Request{
Name: rpcMethodAppendEntries,
Args: args,
Reply: reply,
}
<-rf.RequestDone
}
// 发送者
go func(serverID int) {
if err := rf.sendAppendEntriesWithTimeout(serverID, args, reply); err != nil {
return
}
rf.ReplyCh <- reply
}(serverID)
消费者
func (rf *Raft) handleEvent() {
for {
if rf.killed() {
rf.cleanUpIfKilled()
return
}
select {
case req := <-rf.RequestCh:
switch req.Args.(type) {
case *RequestVoteArgs:
rf.handleRequestVoteRequest(req.Args.(*RequestVoteArgs), req.Reply.(*RequestVoteReply))
case *AppendEntriesArgs:
rf.handleAppendEntriesRequest(req.Args.(*AppendEntriesArgs), req.Reply.(*AppendEntriesReply))
}
rf.RequestDone <- struct{}{}
case reply := <-rf.ReplyCh:
switch reply.(type) {
case *RequestVoteReply:
rf.handleRequestVoteReply(reply.(*RequestVoteReply))
case *AppendEntriesReply:
rf.handleAppendEntriesReply(reply.(*AppendEntriesReply))
}
}
}
}
Apply
apply 的实现可以分为两种:
- 在 appendEntries reply 的时候去 apply。
- 起一个不断轮询的带 sleep 的 apply goroutine。
踩过的坑
- 对于 send heartbeat,我们不需要去统计成功接收到 heartbeat 的 server 数量是否为 majority 然后去改变 leader 的 state。因为在 election timeout 时间内没有接收到 heartbeat 的 follower 会重新发起选举。
- Start() 是异步保证日志复制到大多数节点的,只需要将 command append 到自身服务器的日志中,之后等待 heartbeat cronjob 去做日志复制的工作。而不是在 Start() 中将等待日志复制到大多数 server 再返回。
- AppendEntries RPC 中,只有在 Leader 与接收者日志不一致的情况下,才需要将 nextIndex[i] 递减。
- AppendEntries RPC 中,当日志成功复制,leader 的 matchIndex[i] 应该置为
args.PrevLogIndex + len(args.Logs)
,而不是 follower 的最后一条日志的 index。因为 follower 之前可能是一个 leader 并 append 了大量未复制到大多数节点的日志。 - Timer stop/reset 的操作不当可能导致 goroutine 阻塞或泄露。
- 在 Lab 2C 中,需要实现 nextIndex 的优化,否则不能通过 test。即在 appendEntries 中 server 发现日志不一致的时候,需要给 leader 下一次能匹配上的 index 去更新 nextIndex,不用让 leader 反复 retry。
- 其他的有点记不起来,有很多细节上的问题需要耐心 debug。