LAB3B的任务是实现一个快照,但是相对之前难度在于代码量非常大,逻辑处理起来较为复杂,需要处理server层和raft层的交互,以及raft层与raft层之间的交互。client的代码较之前的lab无需改变。
流程梳理
需要在raft层的log大于给定maxraftstate的时,server层的kv系统进行快照,raft根据应用层的快照再压缩raft层的日志,并且将snapshot和log写入持久化层。而在leader向follower发送心跳包时,根据rf.nextIndex[]和rf.lastIncludedIndex判断是发送正常日志RPC还是发送快照RPC。某些情况server层可能没有执行raft层的所有日志,而raft层的快照server层又无法直接读取一一执行,因此需要在raft层每次修改了快照时,通过applyCh管道向引用层安装快照,之后应用层便可以通过applyCh管道一一读取raft层的日志执行。
可能遇到的问题
- 死锁问题:在经过前面几个实验的磨练以后应该很难遇到死锁,只要我们遵从再调用其他层或者向其他进程发送RPC时,要把自己目前的锁给释放掉。不要拿着自己的锁去申请别的锁。因此执行的过程可能被打断,有时候要进行double check。
- 引入快照以后,日志的索引要进行转换,把据对索引转成相对索引,多写一些工具函数便于实现。
- 再判断raft层的log大于maxraftstate时,将判断逻辑写再server层,server层单独开一个协程自旋判断raft层是否需要生成快照能降低代码复杂度。
- 由于本实验要基于之前的实验又要修改大量代码,在实现的过程中需要不停的跑lab2和lab3A来确保实现时没有破坏代码的逻辑。
实现
代码实现参考MIT 6.824: Distributed Systems- 实现Raft Lab3B,首先,在serve层开一个协程自旋调用raft层提供的方法判断是否需要压缩日志,生成快照。
func (kv *KVServer) snapshotLoop() {
for !kv.killed() {
var snapshot []byte
var lastIncludedIndex int
// 锁内dump snapshot
func() {
// 如果raft log超过了maxraftstate大小,那么对kvStore快照下来
if kv.maxraftstate != -1 && kv.rf.ExceedLogSize(kv.maxraftstate) { // 这里调用ExceedLogSize不要加kv锁,否则会死锁
// 锁内快照,离开锁通知raft处理
kv.mu.Lock()
w := new(bytes.Buffer)
e := labgob.NewEncoder(w)
e.Encode(kv.kvStore) // kv键值对
e.Encode(kv.seqMap) // 当前各客户端最大请求编号,也要随着snapshot走
snapshot = w.Bytes()
lastIncludedIndex = kv.lastAppliedIndex
DPrintf("KVServer[%d] KVServer dump snapshot, snapshotSize[%d] lastAppliedIndex[%d]", kv.me, len(snapshot), kv.lastAppliedIndex)
kv.mu.Unlock()
}
}()
// 锁外通知raft层截断,否则有死锁
if snapshot != nil {
// 通知raft落地snapshot并截断日志(都是已提交的日志,不会因为主从切换截断,放心操作)
kv.rf.TakeSnapshot(snapshot, lastIncludedIndex)
}
time.Sleep(10 * time.Millisecond)
}
}
在raft层添加如下方法提供应用层来调用,ExceedLogSize
方法判断日志是否需要压缩,TakeSnapshot
方法对上层的快照和raft层的日志进行截断再持久化。
// 日志是否需要压缩
func (rf *Raft) ExceedLogSize(logSize int) bool {
rf.mu.Lock()
defer rf.mu.Unlock()
if rf.persister.RaftStateSize() >= logSize {
return true
}
return false
}
// 保存snapshot,截断log
func (rf *Raft) TakeSnapshot(snapshot []byte, lastIncludedIndex int) {
rf.mu.Lock()
defer rf.mu.Unlock()
// 已经有更大index的snapshot了
if lastIncludedIndex <= rf.lastIncludedIndex {
return
}
// 快照的当前元信息
DPrintf("RafeNode[%d] TakeSnapshot begins, IsLeader=%v snapshotLastIndex[%d] lastIncludedIndex[%d] lastIncludedTerm[%d]",
rf.me, rf.votedFor==rf.me, lastIncludedIndex, rf.lastIncludedIndex, rf.lastIncludedTerm)
// 要压缩的日志长度
compactLogLen := lastIncludedIndex - rf.lastIncludedIndex
// 更新快照元信息
rf.lastIncludedTerm = rf.log[rf.index2LogPos(lastIncludedIndex)].Term
rf.lastIncludedIndex = lastIncludedIndex
// 压缩日志
afterLog := make([]LogEntry, len(rf.log) - compactLogLen)
copy(afterLog, rf.log[compactLogLen:])
rf.log = afterLog
// 把snapshot和raftstate持久化
rf.persister.SaveStateAndSnapshot(rf.raftStateForPersist(), snapshot)
DPrintf("RafeNode[%d] TakeSnapshot ends, IsLeader=%v] snapshotLastIndex[%d] lastIncludedIndex[%d] lastIncludedTerm[%d]",
rf.me, rf.votedFor==rf.me, lastIncludedIndex, rf.lastIncludedIndex, rf.lastIncludedTerm)
}
leader需要向应用层安装快照,通过rf.applyCh管道,当安装快照时,CommandValid字段设置为false,raft层代码如下:
func (rf *Raft) installSnapshotToApplication() {
var applyMsg *ApplyMsg
// 同步给application层的快照
applyMsg = &ApplyMsg{
CommandValid: false,
Snapshot: rf.persister.ReadSnapshot(),
LastIncludedIndex: rf.lastIncludedIndex,
LastIncludedTerm: rf.lastIncludedTerm,
}
// 快照部分就已经提交给application了,所以后续applyLoop提交日志后移
rf.lastApplied = rf.lastIncludedIndex
//DPrintf("RaftNode[%d] installSnapshotToApplication, snapshotSize[%d] lastIncludedIndex[%d] lastIncludedTerm[%d]", rf.me, len(applyMsg.Snapshot), applyMsg.LastIncludedIndex, applyMsg.LastIncludedTerm)
rf.applyCh <- *applyMsg
return
}
之后需要修改应用层的applyLoop
方法根据raft层传递上来的是正常日志还是快照,代码如下:
func (kv *KVServer) applyLoop() {
for !kv.killed() {
select {
case msg := <-kv.applyCh:
// 如果是安装快照
if !msg.CommandValid {
func() {
kv.mu.Lock()
defer kv.mu.Unlock()
if len(msg.Snapshot) == 0 { // 空快照,清空数据
kv.kvStore = make(map[string]string)
kv.seqMap = make(map[int64]int64)
} else {
// 反序列化快照, 安装到内存
r := bytes.NewBuffer(msg.Snapshot)
d := labgob.NewDecoder(r)
d.Decode(&kv.kvStore)
d.Decode(&kv.seqMap)
}
// 已应用到哪个索引
kv.lastAppliedIndex = msg.LastIncludedIndex
//DPrintf("KVServer[%d] installSnapshot, kvStore[%v], seqMap[%v] lastAppliedIndex[%v]", kv.me, len(kv.kvStore), len(kv.seqMap), kv.lastAppliedIndex)
}()
} else { // 如果是普通log
cmd := msg.Command
index := msg.CommandIndex
func() {
kv.mu.Lock()
defer kv.mu.Unlock()
// 更新已经应用到的日志
kv.lastAppliedIndex = index
// 操作日志
op := cmd.(*Op)
opCtx, existOp := kv.reqMap[index]
prevSeq, existSeq := kv.seqMap[op.ClientId]
kv.seqMap[op.ClientId] = op.SeqId
if existOp { // 存在等待结果的RPC, 那么判断状态是否与写入时一致
if opCtx.op.Term != op.Term {
opCtx.wrongLeader = true
}
}
// 只处理ID单调递增的客户端写请求
if op.Type == OP_TYPE_PUT || op.Type == OP_TYPE_APPEND {
if !existSeq || op.SeqId > prevSeq { // 如果是递增的请求ID,那么接受它的变更
if op.Type == OP_TYPE_PUT { // put操作
kv.kvStore[op.Key] = op.Value
} else if op.Type == OP_TYPE_APPEND { // put-append操作
if val, exist := kv.kvStore[op.Key]; exist {
kv.kvStore[op.Key] = val + op.Value
} else {
kv.kvStore[op.Key] = op.Value
}
}
}
} else { // OP_TYPE_GET
if existOp {
opCtx.value, opCtx.keyExist = kv.kvStore[op.Key]
}
}
//DPrintf("KVServer[%d] applyLoop, kvStore[%v]", kv.me, len(kv.kvStore))
// 唤醒挂起的RPC
if existOp {
close(opCtx.committed)
}
}()
}
}
}
}
raft函数中的选举rpc需要处理,因为添加了快照,索引要从绝对索引转成存储在log[]中的相对索引,处理主要在于对于下标的修改,引入下列两个工具函数降低代码逻辑复杂度。
// 最后的index
func (rf *Raft) lastIndex() int {
return rf.lastIncludedIndex + len(rf.log)
}
// 最后的term
func (rf *Raft) lastTerm() (lastLogTerm int) {
lastLogTerm = rf.lastIncludedTerm // for snapshot
if len(rf.log) != 0 {
lastLogTerm = rf.log[len(rf.log)-1].Term
}
return
}
// 日志index转化成log数组下标
func (rf *Raft) index2LogPos(index int) (pos int) {
return index - rf.lastIncludedIndex - 1
}
leader需要向其他follower发送快照。常见如下调用函数和RPC。
//考虑通过在心跳包中发送附加AppendEntries数据
func (rf *Raft) Heartbeat(heartbeatTerm int) {
...
...
for server, _ := range rf.peers {
if (server == rf.me) {
continue;
}
if rf.nextIndex[server] <= rf.lastIncludedIndex { //安装快照
rf.doInstallSnapshot(server)
continue
}
...
...
}
func (rf *Raft) doInstallSnapshot(peerId int) {
//DPrintf("RaftNode[%d] doInstallSnapshot starts, leaderId[%d] peerId[%d]\n", rf.me, rf.votedFor, peerId)
args := InstallSnapshotArgs{}
args.Term = rf.currentTerm
args.LeaderId = rf.me
args.LastIncludedIndex = rf.lastIncludedIndex
args.LastIncludedTerm = rf.lastIncludedTerm
args.Offset = 0
args.Data = rf.persister.ReadSnapshot()
args.Done = true
reply := InstallSnapshotReply{}
go func() {
if rf.sendInstallSnapshot(peerId, &args, &reply) {
rf.mu.Lock()
defer rf.mu.Unlock()
// double check,如果不是rpc前的leader状态了,那么啥也别做了
if rf.currentTerm != args.Term {
return
}
if reply.Term > rf.currentTerm { // 变成follower
rf.ConvToFollower(reply.Term, -1)
rf.persist()
return
}
rf.nextIndex[peerId] = rf.lastIndex() + 1 // 重新从末尾同步log(未经优化,但够用)
rf.matchIndex[peerId] = args.LastIncludedIndex // 已同步到的位置(未经优化,但够用)
rf.updateCommitIndex() // 更新commitIndex
//DPrintf("RaftNode[%d] doInstallSnapshot ends, leaderId[%d] peerId[%d] nextIndex[%d] matchIndex[%d] commitIndex[%d]\n", rf.me, rf.votedFor, peerId, rf.nextIndex[peerId], rf.matchIndex[peerId], rf.commitIndex)
}
}()
}
func (rf *Raft) sendInstallSnapshot(server int, args *InstallSnapshotArgs, reply *InstallSnapshotReply) bool {
ok := rf.peers[server].Call("Raft.InstallSnapshot", args, reply)
return ok
}
func (rf *Raft) InstallSnapshot(args *InstallSnapshotArgs, reply *InstallSnapshotReply) {
rf.mu.Lock()
defer rf.mu.Unlock()
//DPrintf("RaftNode[%d] installSnapshot starts, rf.lastIncludedIndex[%d] rf.lastIncludedTerm[%d] args.lastIncludedIndex[%d] args.lastIncludedTerm[%d] logSize[%d]", rf.me, rf.lastIncludedIndex, rf.lastIncludedTerm, args.LastIncludedIndex, args.LastIncludedTerm, len(rf.log))
reply.Term = rf.currentTerm
if args.Term < rf.currentTerm {
return
}
// 发现更大的任期,则转为该任期的follower
if args.Term > rf.currentTerm {
rf.ConvToFollower(args.Term, args.LeaderId)
rf.persist()
// 继续向下走
}
// leader快照不如本地长,那么忽略这个快照
if args.LastIncludedIndex <= rf.lastIncludedIndex {
return
} else { // leader快照比本地快照长
if args.LastIncludedIndex < rf.lastIndex() { // 快照外还有日志,判断是否需要截断
if rf.log[rf.index2LogPos(args.LastIncludedIndex)].Term != args.LastIncludedTerm {
rf.log = make([]LogEntry, 0) // term冲突,扔掉快照外的所有日志
} else { // term没冲突,保留后续日志
leftLog := make([]LogEntry, rf.lastIndex() - args.LastIncludedIndex)
copy(leftLog, rf.log[rf.index2LogPos(args.LastIncludedIndex)+1:])
rf.log = leftLog
}
} else {
rf.log = make([]LogEntry, 0) // 快照比本地日志长,日志就清空了
}
}
// 更新快照位置
rf.lastIncludedIndex = args.LastIncludedIndex
rf.lastIncludedTerm = args.LastIncludedTerm
// 持久化raft state和snapshot
rf.persister.SaveStateAndSnapshot(rf.raftStateForPersist(), args.Data)
// snapshot提交给应用层
rf.installSnapshotToApplication()
//DPrintf("RaftNode[%d] installSnapshot ends, rf.lastIncludedIndex[%d] rf.lastIncludedTerm[%d] args.lastIncludedIndex[%d] args.lastIncludedTerm[%d] logSize[%d]", rf.me, rf.lastIncludedIndex, rf.lastIncludedTerm, args.LastIncludedIndex, args.LastIncludedTerm, len(rf.log))
}
对于上面那个博主说到的一点,如下
最后提一个死锁的坑点:
raft层持有rf.mu向applyCh写入可能阻塞,此时如果kv层出现一种代码逻辑是先拿到了kv.mu然后再去拿rf.mu的话,此时肯定无法拿到rf.mu(因为raft层持有rf.mu并阻塞在chan),而此刻kv层如果正在处理前一条log并试图加kv.mu,那么也无法拿到kv.mu,就会死锁。
解决办法就是kv层不要拿着kv.mu去请求rf.mu,一定要在kv.mu的锁外操作raft,谨记这一点即可。
我认为这种死锁是不会发生的。因为在写代码时候我始终保证了kv层调用raft层时是释放掉kv锁的。同时,kv.applyLoop方法拿管道applyCh数据时不需要申请锁,因此,raft也不会总卡在applyCh写入阻塞的位置。两方面都确保了此种情况不会产生死锁。