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

本次Lab是基于raft(lab2中实现的raft)实现kv存储系统(lab任务)。完成一个server.go服务端程序和client.go客户端程序。从这里开始变要求我们自己设计解决方案,并且实现,不像实现raft时候每一步都有论文和文档提示指导我们该怎么做,lab3B则要求我们实现快照功能从而防止日志过大。

任务

根据任务要求分析后,实际上就是要求实现一个能处理当日志中存在多条相同的请求时,能进行去重。(因为如果客户端请求的get或者putappend命令没有执行则不停的重试发送)。

细节理解

  • 该实现要保证论文里描述的linearizability,根据课程这个概念翻译过来是如果可以找到所有操作的总顺序,并且实时匹配(对于非重叠操作),并且每个读取都按顺序从前面的write中看到值,则执行历史是可线性化的。,我没有太明白是什么意思,但是对于该实验应该是满足该条件的,因为每个server对于一个raft节点,对于client来说,只会通过为leader结点server来读写数据。之前我以为raft直接充当应用层,读可以在follower结点上,这应该是个误解,raft只是底层的一个共识协议。而本次实现的kvraft的读写也发生在leader服务器上,因此强一致性有保证。有别于课程之前提到的zookeeper。
  • 对于单个client来说,请求是按照固定序列的,即当前get或者append、put成功执行了,才会执行下一条指令。
  • 对于server来说,面临同时被多个client同时请求的情况,因此要考虑竞争加锁。服务端和raft层通过start()和applyCh管道进行传输通信。
  • 对于日志中的请求去重,可以考虑使用对每个客户端设定一个ClientId + SeqId来解决,重复的直接不执行。

解决方案

先根据任务要求快速实现一个能通过lab3A第一个样例的实现版本,当单个客户端的时候,显然可以通过直接在server.go的rpc函数中进行select 监听管道applyCh/超时,来判断指令的执行情况,但是当多个客户端时,显然容易造成处理逻辑时候发生混乱,多个rpc同时监听applyCh管道,如果不是被正确的rpc拿到数据就难搞了。
参考MIT 6.824: Distributed Systems- 实现Raft Lab3A。这篇博客使用了OpContext结构体,定义如下,用来代表每一个Start后被阻塞的rpc,通过committed管道来唤醒对应的RPC,此外还将整个处理逻辑拿到rpc的外面用一个单独函数来监听applych,通过从applych管道中读取到的信息来执行对应的逻辑。这个设计实在太妙了!

// 等待Raft提交期间的Op上下文, 用于唤醒阻塞的RPC
type OpContext struct {
	op Op
	committed 	chan struct{}

	wrongLeader bool 	// 因为index位置log的term不一致, 说明leader换过了
	// ignored 	bool		// 因为req id过期, 导致该日志被跳过执行

	// Get操作的结果
	keyExist 	bool
	value 		string
}

下面以putappend的过程为例来分析。

客户端

putAppend()

func (ck *Clerk) PutAppend(key string, value string, op string) {
	// You will have to modify this function.
	args := PutAppendArgs{}
	args.Key	= key
	args.Value	= value
	args.Op		= op
	args.ClientId = ck.clientId
	args.SeqId = ck.seqId
	ck.seqId++
	//DPrintf("Client[%d] PutAppend(), key = %s,value = %s, op = %s", ck.clientId, key, value, op)
	reply := PutAppendReply{}
	
	for {
		ok := ck.servers[ck.leaderId].Call("KVServer.PutAppend", &args, &reply)
		if (ok && reply.Err == OK) {
			break;
		}
		ck.leaderId = (ck.leaderId + 1) % len(ck.servers)
		time.Sleep(time.Millisecond)
	}
	DPrintf("Client[%d] %v(%v, %v)", ck.clientId, op, key, value)
}

构造请求参数,通过for循环RPC,直到该请求被服务器执行为止。

服务端

KVServer结构体如下

type KVServer struct {
	mu      sync.Mutex
	me      int
	rf      *raft.Raft
	applyCh chan raft.ApplyMsg
	dead    int32 // set by Kill()

	maxraftstate int // snapshot if log grows this big

	// Your definitions here.
	db 		map[string]string	//应用层数据库
	reqMap	map[int]*OpContext	//log index -> 请求上下文
	seqMap	map[int64]int64 	//查看每个client的日志提交到哪个位置了。	
}

其中db模拟应用层数据库,seqMap对应每个客户端当前已经处理请求的最大seq号码(用来去重),reqMap的key:Start()时对应日志中的索引value:对应的上下文,用来记录当前正在处理的rpc请求。(当对应的rpc返回给客户端时,可能是处理成功或者超时等情况,则将对应的op上下文从reqMap中删除)。
PutAppend()RPC的设计如下:

func (kv *KVServer) PutAppend(args *PutAppendArgs, reply *PutAppendReply) {
	// Your code here.
	op := Op{}
	op.Key = args.Key
	op.Value = args.Value
	op.OpType = args.Op
	op.ClientId = args.ClientId
	op.SeqId = args.SeqId

	var isLeader bool
	op.Index, op.Term, isLeader = kv.rf.Start(op)
	if (isLeader == false) {
		reply.Err = ErrWrongLeader 
		return
	}
	//DPrintf("[%d] PutAppend RPC,OpType=%v, Key=%v, Value=%v", kv.me, args.Op, args.Key, args.Value)
	opCtx := &OpContext{
		op: op,
		committed: make(chan struct{}),
	}
	kv.mu.Lock()
	kv.reqMap[op.Index] = opCtx
	kv.mu.Unlock()
	select {
	case <-opCtx.committed:	// 如果提交了
		if opCtx.wrongLeader {	// 同样index位置的term不一样了, 说明leader变了,需要client向新leader重新写入
			reply.Err = ErrWrongLeader
		} else {
			reply.Err = OK
		}
	case <-time.After(2 * time.Second):	
		reply.Err = ErrWrongLeader
        DPrintf("[KVserver]%v(%v, %v) timeout 2s", args.Op, args.Key, args.Value)
	}
	//DPrintf("[%d] PutAppend RPC,OpType=%v, Key=%v, Value=%v, reply.Err=%v", kv.me, args.Op, args.Key, args.Value, reply.Err)
	func() {
	//超时或者已经处理,则清理上下文
		kv.mu.Lock()
		defer kv.mu.Unlock()
		if one, ok := kv.reqMap[op.Index]; ok {
			if (one == opCtx) {
				delete(kv.reqMap, op.Index)
			}
		}
	}()
}

处理逻辑就是构造请求上下文,在将其存到reqMap中,注意上锁防止数据竞争。在处理完请求后阻塞在case <-opCtx.committed处等待raft层将日志提交(可能执行成功,也可能更换leader导致该索引处的日志被替换)更超时。将对应的上下文从reqMap中删去。

applyLoop()

func (kv *KVServer) applyLoop() {
	for !kv.killed() {
		applyMsg  := <-kv.applyCh
		op := applyMsg.Command.(Op)
		index := applyMsg.CommandIndex
		term := applyMsg.CommandTerm
		//DPrintf("[%d] applyloop,index=%v,OpType=%v,Key=%v,Value=%v", kv.me, index, op.OpType, op.Key, op.Value)
		func() {
			kv.mu.Lock()
			defer kv.mu.Unlock()
			//DPrintf("[%v]kv.reqMap=%v", kv.me, kv.reqMap)
			opCtx, existOpt := kv.reqMap[index]
			prevSeq, existSeq := kv.seqMap[op.ClientId]
			
			//分析了一下,实际上请求号顶多相等,并不会op.SeqId < prevSeq的情况。
			if (!existSeq || op.SeqId > prevSeq) {
				kv.seqMap[op.ClientId] = op.SeqId
			}

			if (op.OpType == OpPut || op.OpType == OpAppend) {
				if (!existSeq || op.SeqId > prevSeq) {	//第一次记录该客户端,或者请求号是递增的
					if (op.OpType == OpPut) {	
						kv.db[op.Key] = op.Value
					} else {
						if _, exist := kv.db[op.Key]; exist {
							kv.db[op.Key] += op.Value
						} else {
							kv.db[op.Key] = op.Value
						}
					}
				}
			}

			if (existOpt == true) {	// 存在等待结果的RPC, 那么判断状态是否与写入时一致
				if opCtx.op.Term != term {	//说明更换了leader。
					opCtx.wrongLeader = true
				}
				if (op.OpType == OpGet) {
					opCtx.value, opCtx.keyExist = kv.db[op.Key]
				} else if (op.OpType == OpPut || op.OpType == OpAppend) {
					//可能是重复请求的,则老的rpc肯定已经断了,此处无需处理
				}
				//唤醒阻塞的rpc。
				close(opCtx.committed)
			}
		}()
	}
}

在applyLoop()中监听和raft层交互的管道。详细解释见注释。close(opCtx.committed)用来唤醒阻塞的rpc。

运行结果

-race超时

我在加入-race检测竞争时会超时或者报错当前运行了太多go routine,猜测是我的虚拟机内存太小或者实现不够好。在lab2时加入-race参数也遇到了类似情况,超时或者报错当前运行了太多go routine。
在这里插入图片描述

通过

我在把-race去掉后挂脚本跑了几十次,均通过,可以认为通过了该实验。
在这里插入图片描述

总结

该实验的难点在于没有告诉你该怎么处理这种场景,需要自己设计方案实现,这是很难想到的。这对本来码力就不够的我更加雪上加霜。还望再接再厉。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值