6.584-Lab4B
Lab4B:当底层的 Raft 中的日志 log 过大时,上层的 K/V Server 会生成快照SnapShot,并将底层 Raft 的 log 中已经被 K/V Server生成快照的部分截断,从而减短底层 Raft 的 log 的长度,减少内存压力。
本次文章旨在记录 参考Reference Blog实现相关代码过程中的理解,具体代码请参考上面的 【Reference Code】。
K/V Server 如何控制 Raft 生成快照
func (kv *KVServer) GenSnapShot() []byte {
w := new(bytes.Buffer)
e := labgob.NewEncoder(w)
e.Encode(kv.db)
e.Encode(kv.historyMap)
serverState := w.Bytes()
return serverState
}
// 加载 K/Vserver 的快照(加锁使用)
func (kv *KVServer) LoadSnapShot(snapshot []byte) {
if snapshot == nil || len(snapshot) == 0 {
return
}
r := bytes.NewBuffer(snapshot)
d := labgob.NewDecoder(r)
var db map[string]string
var historyMap map[int64]*result
if d.Decode(&db) != nil || d.Decode(&historyMap) != nil {
DPrintf("server %v LoadSnapShot 加载快照失败\n", kv.me)
} else {
kv.db = db
kv.historyMap = historyMap
}
}
这是 K/V Server 实现的生成快照GeSnapShot
和 加载快照LoadSnapShot
函数。
GeSnapShot
:将 K/V Server 应用 Apply 过命令的本地数据库 db 和历史记录进行编码,持久化保存。
LoadSnapShot
:将传入的快照 snapshot
进行解码然后取出快照中的 db 和 历史记录。
// 每收到一个log,判断是否需要生成快照
if kv.maxraftstate != -1 && kv.persister.RaftStateSize() >= kv.maxraftstate/100*95 {
// 当达到95%容量时生成快照
snapShot := kv.GenSnapShot()
kv.rf.Snapshot(log.CommandIndex, snapShot)
}
在 kvraft/server.go 的函数ApplyHandler
中有触发生成快照的条件:当底层 Raft 的 State 的大小超过 阈值kv.maxraftstate
的95%时生成快照。博主香草美人这样设置的原因是:K/V Server 会处理底层 Raft 通过通道kv.applych
传来的绵绵不断的log,而且 RPC 会有一定的延迟,所以用kv.maxraftstate * 95%
作为新的阈值。
K/V Server 将生成的快照和快照最后一条 log 的下标传入函数kv.rf.Snapshot
。
rf.Snapshot
:
func (rf *Raft) Snapshot(index int, snapshot []byte) {
// Your code here (2D).
rf.mu.Lock()
defer rf.mu.Unlock()
if rf.commitIndex < index || index <= rf.lastIncludedIndex {
DPrintf("server %v 拒绝了 Snapshot 请求, 其index=%v, 自身commitIndex=%v, lastIncludedIndex=%v\n", rf.me, index, rf.commitIndex, rf.lastIncludedIndex)
return
}
DPrintf("server %v 同意了 Snapshot 请求, 其index=%v, 自身commitIndex=%v, 原来的lastIncludedIndex=%v, 快照后的lastIncludedIndex=%v\n", rf.me, index, rf.commitIndex, rf.lastIncludedIndex, index)
// 保存snapshot
rf.snapShot = snapshot
rf.lastIncludedTerm = rf.log[rf.RealLogIdx(index)].Term
// 截断log
rf.log = rf.log[rf.RealLogIdx(index):] // index位置的log被存在0索引处
rf.lastIncludedIndex = index
if rf.lastApplied < index {
rf.lastApplied = index
}
rf.persist()
}
func (rf *Raft) persist() {
// TODO: 持久化lastIncludedIndex和lastIncludedTerm时, 是否需要加锁?
// DPrintf("server %v 开始持久化, 最后一个持久化的log为: %v:%v", rf.me, len(rf.log)-1, rf.log[len(rf.log)-1].Cmd)
w := new(bytes.Buffer)
e := labgob.NewEncoder(w)
// 2C
e.Encode(rf.votedFor)
e.Encode(rf.currentTerm)
e.Encode(rf.log)
// 2D
e.Encode(rf.lastIncludedIndex)
e.Encode(rf.lastIncludedTerm)
raftstate := w.Bytes()
rf.persister.Save(raftstate, rf.snapShot)
}
kvraft/persister.go
type Persister struct {
mu sync.Mutex
raftstate []byte
snapshot []byte
}
func (ps *Persister) Save(raftstate []byte, snapshot []byte) {
ps.mu.Lock()
defer ps.mu.Unlock()
ps.raftstate = clone(raftstate)
ps.snapshot = clone(snapshot)
}
可以看到底层 Raft 会根据传入的快照的下标去截断自己的 log,并将 K/V Server 传入的快照进行持久化保存。其中 K/V Server 传入的快照 snapshot
持久化在 ps.snapshot
;底层 Raft 的一些需要持久化的状态经过编码后持久化在ps.raftstate
。
所以 ps.snapshot
中保存的一直都是 K/V Server 的db & historyMap
。
快照生成&流向:
kv.raft.statesize >= 0.95*kv.maxraftstate
(生成快照条件)–> K/V Server 将db & historyMap
生成快照传入rf.Snapshot
--> 底层 Raft 根据传来快照的下标截断自己的 log,并将自己的一些状态和传来的 K/V Server的快照 分别持久化在本地的ps.raftstate
和ps.snapshot
.
K/V Server 如何从 Raft 加载快照
kvraft/server.go
:
func (kv *KVServer) ApplyHandler() {
for !kv.killed() {
log := <-kv.applyCh // Raft层处理完负责的部分(选举、生成日志、Snapshot等),Raft将提交的Cmd通过通道应用到K/V的db(数据库)
if log.CommandValid {
...
} else if log.SnapshotValid {
// 传过来的log中包含的不是一个cmd命令而是是个 快照snapshot
kv.mu.Lock()
if log.SnapshotIndex >= kv.lastApplied {
kv.LoadSnapShot(log.Snapshot)
kv.lastApplied = log.SnapshotIndex
}
kv.mu.Unlock()
}
}
}
能够看到,K/V Server在判断到从通道中拿到的是快照之后,会加载快照到 K/V Server。
现在去看一下什么情况下 K/V Server 拿到的是快照、什么情况下从通道中拿到的是包含 Cmd 的 log。
raft/raft.go
:
func (rf *Raft) CommitChecker() {
// 检查是否有新的commit
for !rf.killed() {
...
for rf.commitIndex > tmpApplied {
...
msg := &ApplyMsg{
CommandValid: true,
Command: rf.log[rf.RealLogIdx(tmpApplied)].Cmd,
CommandIndex: tmpApplied,
SnapshotTerm: rf.log[rf.RealLogIdx(tmpApplied)].Term,
}
msgBuf = append(msgBuf, msg)
}
for _, msg := range msgBuf {
...
rf.applyCh <- *msg
...
}
}
}
在底层 Raft 检查到有 Commit 的 log 并没有 Apply 的话就会向通道发送一个包含 Cmd 的 log,去让上层的 K/V Server 接收到 Cmd 从而应用到 db。
raft/raft.go
:
func (rf *Raft) InstallSnapshot(args *InstallSnapshotArgs, reply *InstallSnapshotReply) {
rf.mu.Lock()
defer rf.mu.Unlock()
...
msg := &ApplyMsg{
SnapshotValid: true,
Snapshot: args.Data,
SnapshotTerm: args.LastIncludedTerm,
SnapshotIndex: args.LastIncludedIndex,
}
...
rf.applyCh <- *msg
rf.persist()
}
此时底层 Raft 向通道中发送的就是一个快照,K/V Server接收到快照后会用来更新自己的db & historyMap
。
快照加载&流向:底层 Raft 中 Leader 检测到 Follower 缺失的 log 在快照中,会通过“心跳”向 Follower 发送快照 – > Follower 接收到 Leader 发来的快照后更新一下自己的字段,然后同时将接收到的快照通过通道向上发送给自己的 K/V Server --> Follower 的K/V Server 接收到发来的
log(msg)
,通过log.SnapshotValid
字段发现是一个快照,那么就加载这个快照中的db & historyMap
更新自己。
修改之前lab中的代码
博主香草美人在 blog 中也修改了部分之前的代码,主要由一下部分:
Raft 层
- 修复过多的 AppendEntries RPC
- 修复过多的 InstallSnapshot RPC
K/V Server 层
- 调用 Start 前过滤 Clerk 发送的重复的指令,而不是一股脑交给 Raft 层处理,然后通过返回的结果判断是否之前已经处理过。
- 由于调用 Raft 层的 GetState 获得 Raft 层的大锁,增加 Raft 层对锁的争用,减少 Raft 层 GetState 的调用
- 为了避免 Clerk 因网络等原因立即重发的 RPC 由会阻塞而超时导致再次重发 RPC,会在网络中积攒较多的 RPC,让 Clerk 先 sleep 再重试
具体修改位置和修改原因,请参考 Reference Blog。