《Distributed Systems》(6.824)LAB3B(Fault-tolerant Key/Value Service)

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写入阻塞的位置。两方面都确保了此种情况不会产生死锁。

执行结果

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值