Task
完成 Raft 的持久化。
Step
如果前面做得好,只需要完成持久化。持久化的对象是 Persister,它模拟的是这个节点自己的磁盘,和 Service 层没有关系——Service 层是 Raft 之上的状态机,两者不要搞混。需要落盘的就是论文 Figure 2 里那三个 persistent state:currentTerm、votedFor、log。
- 把状态编码后交给
Persister
persist()
- Crash 重启后从
Persister读回状态
readPersist(data []byte)
2026-08 更新:补一条 2C 最容易踩的坑——必须在回复 RPC 之前完成 persist。
RequestVote里改了votedFor、AppendEntries里截断或 append 了log,这些修改都要先落盘,才能把 reply 发出去。如果先回复再持久化,节点在这个窗口里 crash,对外已经承诺了「我投过票」或「我存下了这条日志」,重启后却不认账,直接破坏 Raft 的安全性保证——同一任期投两次票、已提交的日志丢失都可能出现。用defer rf.persist()配合 handler 顶部的defer rf.mu.Unlock()就能保证顺序(defer后进先出,persist 在 Unlock 之前跑完,而 reply 是函数返回后才发出的)。
- 如果和我一样前面差点意思,就要小修一下 guide
- AppendEntries Handler 中,Follower 引入 XTerm 和 XIndex 来快速调整 nextIndex。
// handler 一进来就显式初始化,不能依赖 Go 零值
reply.XTerm, reply.XIndex = NULL, NULL
if args.PrevLogIndex > rf.getLastLogL().Index {
reply.Success, reply.Term = false, rf.currentTerm
reply.XIndex = rf.getLastLogL().Index + 1 // Follower's nextIndex
return
}
// entry logAt prevLogIndex whose term doesn't match prevLogTerm
DPrintf(dInfo, "S%v prevLogTerm:%v, prevLogIndex:%v,log[prevLogIndex].Term:%v", rf.me, args.PrevLogTerm, args.PrevLogIndex, rf.logAt(args.PrevLogIndex).Term)
if args.PrevLogTerm != rf.logAt(args.PrevLogIndex).Term {
reply.Success, reply.Term = false, rf.currentTerm
reply.XIndex, reply.XTerm = rf.commitIndex+1, rf.logAt(args.PrevLogIndex).Term
for i := args.PrevLogIndex; i > rf.commitIndex+1; i-- {
if reply.XTerm != rf.logAt(i-1).Term {
reply.XIndex = i
return
}
}
DPrintf(dInfo, "S%v XIndex = %v", rf.me, rf.commitIndex+1)
return
}
2026-08 更新:那句显式初始化不是可选的。第一个分支(
PrevLogIndex超出自己日志末尾)只设了XIndex,没碰XTerm,而XTerm的 Go 零值是0,代码里的NULL是-1——于是 Leader 侧if reply.XTerm == NULL这个判断永远不成立,会走进按 term 回溯的分支,拿XTerm == 0去比对,一路nextIndex--退化成线性扫描,快速回退的优化白写了。日志一长就能明显看出来。
- Leader 对 XTerm 和 XIndex 的处理
if rf.sendAppendEntries(peer, args, &reply) {
rf.mu.Lock()
defer rf.mu.Unlock()
if reply.Term > rf.currentTerm {
//changeState but not update election timer
} else if args.Term == rf.currentTerm {
if reply.Success {
// update nextIndex, matchIndex & commit
} else if reply.Term == rf.currentTerm{
// adjust nextIndex
if reply.XIndex != NULL {
DPrintf(dHeart, "S%v <- S%v heartbeat XIndex:%v, XTerm:%v", rf.me, peer, reply.XIndex, reply.XTerm)
if reply.XTerm == NULL {
rf.nextIndex[peer] = reply.XIndex
} else {
ok := false
for i := rf.nextIndex[peer] - 1; i > rf.lastIncludedIndex() && reply.XTerm <= rf.logAt(i).Term; i-- {
if rf.logAt(i).Term == reply.XTerm {
ok = true
rf.nextIndex[peer] = i + 1
break
}
}
if !ok {
rf.nextIndex[peer] = reply.XIndex
}
}
}
}
}
}
- electionTimer 不能在每次变成 Follower 的时候重置。
If election timeout elapses without receiving AppendEntries RPC from current Leader or granting vote to Candidate: convert to Candidate.
The distinction turns out to matter a lot, as the former implementation can result in significantly reduced liveness in certain situations.
electionTimer 重置仅在以下情况发生:
- 刚成为 Candidate。
- 给别人投出赞成票后。
- Follower 收到 AppendEntries 或者 InstallSnapshot,并且
args.Term >= rf.currentTerm(在前置芝士有谈到,其实这个 bug 是我在 2C 的时候才修的)。
2026-08 更正:原文写的是「只有
args.Term等于自己当前 term 才重置,大于时不重置」,并声称改成不重置后 2C 测试平均快了 15s。前者是错的(见上),后者是归因错误——那阵子同时还在改 RequestVote 的日志新旧判定和快速回退,提速大概率来自那些改动,这个数字别当真。