本次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去掉后挂脚本跑了几十次,均通过,可以认为通过了该实验。
总结
该实验的难点在于没有告诉你该怎么处理这种场景,需要自己设计方案实现,这是很难想到的。这对本来码力就不够的我更加雪上加霜。还望再接再厉。