MIT 6.824分布式 LAB3:kvraft

Lab 3要求实现数据库和raft算法的结合。分别需要设计客户端和服务端,Lab3的代码的复杂性远不如Lab2,因此代码量不是很多,尽量也避免修改raft的源码,不然出了bug改起来也头疼。

客户端和服务端进行连接,服务端处运行着数据库服务,服务端同样还需要运行raft算法进行共识。

客户端能够向服务端发起的请求有put,append,get。当客户端需要进行指定的功能时,给服务端的发送消息,然后会将操作进行raft共识,共识结束服务端的数据库程序执行该操作。

介绍

go test -race -run 3

注意事项(实验教程中的Hint)

1、服务端在接受到客户端的请求后,将操作通过Start函数进行raft共识,服务端必须要等到该操作的共识结果出来后,才能进行返回消息给客户端。同时,服务端需要实时关注applyCh中的完成共识的操作,并及时进行执行。同时要避免kvraft和raft之间的锁的造成的死锁问题。

2、我们可以根据自己的设计来修改raft中的applyMsg的数据结构,但是这并非是必要的。

3、服务端在处理get操作的时候,必须确保get返回的值是最新的,即get前面的修改数据的操作都已经执行。一个简单的方法就是将get也看做为修改数据的操作,需要经过共识放到applyCh才能执行。当然也可以进行优化,针对这种read-only的操作特别设计机制,可以参考论文中的section8.

4、注意避免死锁,注意使用 -race来侦查数据竞争问题

5、需要注意一种情况,当客户端发送给一个过时的领导者(可能是发生分区 ,已经 有了一个更高term的领导者),这个过时的领导者收到请求后进行共识。应对这种情况,一种解决方法:raft节点注意到自己不是领导者了,立即停止共识并通知客户端。如果是发生了分区,客户端和这个过时的领导者被分到了同个分区,那么客户端将无法和最新的领导者进行连接,那么就只能等到网络分区恢复,然后客户端和正确的领导者建立连接。

6、客户端应该能够存储leader的索引,简单来说就是经过一次搜索leader的索引后,之后的就认定这个索引index,servers[index]为leader,后续请求操作向servers[index]发起rpc操作即可。

7、需要给客户端发送的每个请求都赋予一个独一无二的标识符,避免网络丢包等问题带来的,修改操作执行二次的问题,服务器每次仅需查看该请求的标识符,即可判断该请求是否执行过。

8、对于那些重复的请求,服务端能够做到快速回复的机制。但是需要确保这个机制要及时释放内存,实验中kvserver在接受到一个rpc请求后就表明之前的rpc请求都已经收到回复,不会再出现重复请求的问题。

9、在kvserver进行快照时,需要认真考虑需要保存哪些数据到快照里面。raft存储快照数据是使用SaveStateAndSnapshot()函数,读取最新的快照数据是使用ReadSnapshot()函数。

10、在kvserver进行快照的时候,需要注意和快速回复重复请求机制有关的数据也需要保存,避免服务端崩溃重启后,重复执行请求。

11、需要进行快照的数据的数据结构的首字母大写。

12、如果在本实验中发现了raft实现中的一些bug,修复后请重新把lab 2测试跑一遍。

13、lab3全部测试应该在400秒的real time,700s的cpu time以内。此外,TestSnapshotSize应该在20秒以内通过。

本次实验内容

客户端部分

1、完善Clerk结构体

2、完善MakeClerk函数

3、完善Get函数

4、完善PutAppend函数

服务端部分

1、完善Op结构体

2、完善KVServer结构体

3、设计侦查重复请求机制

3、完善Get函数

4、完善PutAppend函数

5、设计executeThread函数

6、完善StartKVServer函数

公共部分

1、完善PutAppendArgs和GetArgs结构体

Raft算法部分

1、提供访问快照和读取raftstate大小的接口

2、修复一个奇怪的bug,在Start函数中

代码阶段

公共部分

完善PutAppendArgs和GetArgs结构体

我在这俩结构体里面简单加了一个属性Id,这个属性就是用于标识这个独一无二的请求,避免重复执行。

// Put or Append
type PutAppendArgs struct {
    Key   string
    Value string
    Op    string // "Put" or "Append"
    // You'll have to add definitions here.
    // Field names must start with capital letters,
    // otherwise RPC will break.
    Id int64
}
​
type GetArgs struct {
    Key string
    // You'll have to add definitions here.
    Id int64
}

客户端部分

完善Clerk结构体

我在其中添加了leader和peerNum以及name,这三个字段。

leader字段用于搜索到的领导者的index

peerNum用于记录服务器的数量

name是在实验过程中调试用的,可以无视

type Clerk struct {
    servers []*labrpc.ClientEnd
    // You will have to modify this struct.
    leader  int
    peerNum int
    name    string
}

完善MakeClerk函数

客户端刚刚启动时,并不知道领导者的位置,因此设置leader字段为-1。

func MakeClerk(servers []*labrpc.ClientEnd) *Clerk {
    ck := new(Clerk)
    ck.servers = servers
    // You'll have to add code here.
    ck.leader = -1
    ck.peerNum = len(servers)
    ck.name = strconv.FormatInt(nrand(), 10)
    return ck
}

完善Get函数

客户端发起Get请求时,首先需要判断领导者是否已经确认,如果为-1,则需要进行搜索Leader,此处的搜索方式直接简单粗暴,进行遍历轮询,由于raft的复制数量一般不会太多,所以一次轮询带来的性能开销并不会很大。

Get请求的RPC的参数中需要有Key和一个Id

客户端和服务端之间交互可能碰到一些问题,我在实验过程中考虑到了以下几种。

  1. Consensus error:这个错误意味服务端那边对该请求共识过程发生了错误,无法完成共识,从而无法执行该请求。可能是请求的服务端不再是合法leader了,或者是raft集群出问题了,无法完成共识了。我这里采取的措施是重新进行遍历搜索另一个Leader,这可以十分直观地解决第一种情况,如果是第二种情况,也会重新搜索到同一个leader再次请求,直到raft集群恢复。

  2. Not Leader:这个错误意味着,一个非leader的服务端接收到了客户端的请求。客户端需要重新向下一个服务器尝试发起请求。

  3. RPC请求无法到达服务器,表示网络出了问题,无法和服务器建立连接进行RPC请求,那么就向下一个服务器尝试发起请求。

func (ck *Clerk) Get(key string) string {
​
    // You will have to modify this function.
    if ck.leader == -1 {
        ck.leader = 0
    }
    args := &GetArgs{key, nrand()}
    reply := &GetReply{}
    for {
        ok := ck.servers[ck.leader].Call("KVServer.Get", args, reply)
        if reply.Err == "Consensus error" {
            ck.leader = (ck.leader + 1) % ck.peerNum
        }
        if reply.Err == "Not leader" {
            ck.leader = (ck.leader + 1) % ck.peerNum
        }
        if !ok {
            ck.leader = (ck.leader + 1) % ck.peerNum
        }
        if ok && reply.Err == "" {
            return reply.Value
        }
        reply.Err = ""
    }
}

完善PutAppend函数

原理和Get函数同理,唯一的区别就是返回值的差别。PutAppend函数的返回值中没有value值。

func (ck *Clerk) PutAppend(key string, value string, op string) {
    // You will have to modify this function.
    if ck.leader == -1 {
        ck.leader = 0
    }
    args := &PutAppendArgs{key, value, op, nrand()}
    reply := &PutAppendReply{}
    for {
        ok := ck.servers[ck.leader].Call("KVServer.PutAppend", args, reply)
        if reply.Err == "Consensus error" {
            ck.leader = (ck.leader + 1) % ck.peerNum
        }
        if reply.Err == "Not leader" {
            ck.leader = (ck.leader + 1) % ck.peerNum
        }
        if !ok {
            ck.leader = (ck.leader + 1) % ck.peerNum
        }
        if ok && reply.Err == "" {
            break
        }
        reply.Err = ""
    }
​
}

服务端部分

完善Op结构体

这个Op结构体用于给我们服务端的简单数据库进行执行请求用的。

同时这个结构体也将作为command发送到raft中进行共识。

type Op struct {
    // Your definitions here.
    // Field names must start with capital letters,
    // otherwise RPC will break.
    Key       string
    Value     string
    Operation string
    Id        int64
}

完善KVServer结构体

CommandLog:用于存储已经执行过的请求,因此使用map这个数据类型,key为执行过的请求的Id值。可以通过这个变量来查看指令Id是否有执行记录。

CommandLogQueue:这是一个队列,用于记录最近的几条请求。进行快照时,需要把最近的该栈中指定的最近的请求记录都保存起来,确保服务端重启后,能够快速回复那些最近的重复请求。

cond:这是一个条件变量,后续用于进行同步执行

Data:这个变量作为简单的数据库,用于存储指令的执行结果

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.
    CommandLog      map[int64]string
    CommandLogQueue List
    cond            *sync.Cond
    Data            map[string]string
    // checkIfSuccess  map[int64]chan int
}

设计侦查重复请求机制

由于现实中的网络是不稳定的,存在丢包等问题。可能会出现服务端执行完请求,返回给客户端的包在网络中发生丢失的现象,此时客户端就误以为请求失败,向服务端发起重复请求。因此,需要避免服务端重复执行同一条请求。

首先,论文中指出可以给每一条请求都分配一个独一无二的id。服务端每执行完一条请求就可以把这条id给保存起来,并在接受请求时,首先检查这个请求的id是否已经执行过了,若是则直接返回保存起来的执行结果即可。开始,我是简单粗暴地将每条请求的执行结果都保存了。但是,需要注意一点,kvserver进行快照的时候,保存的数据为数据库的状态以及侦查重复请求机制有关的数据也同样需要保存起来。那么就会导致快照的数据太大了,这将无法通过lab3b的测试。

随后,我通过加入队列的数据结构(CommandLogQueue),这个队列负责保存最近执行的几条请求的结果。由于,kvserver在收到一个rpc请求后 ,表明前面的rpc请求均已顺利完成,不会再有重复请求的问题,因此仅需保存最近的几条rpc请求的数据即可。进行快照时,也仅需保存最近的几条请求的执行结果即可。

下面介绍队列的简单数据结构设计

type List struct {
	Data []interface{}
	Len  int
}

func (list *List) Push(args interface{}) {
	list.Data = append(list.Data, args)
	list.Len = len(list.Data)
}

func (list *List) Pop() (result interface{}) {
	result = list.Data[0]
	list.Data = list.Data[1:]
	list.Len = len(list.Data)
	return
}

完善Get函数

服务端收到客户端的请求,首先需要判断该条请求是否重复,即通过kv.CommandLog来查看是否已有记录。

若该条请求为重复请求,则直接返回上回的执行结果即可。

若该条请求非重复请求,将RPC参数中的请求封装为Op结构体,并作为command,通过Start函数进行raft共识。调用Start函数对该条请求进行共识开始,执行结果有多种情况需要考虑:

若进行raft共识的Start返回本节点并非为leader,则返回错误信息“Not leader"

若进行raft共识的Start返回本节点为leader,后续便开设一个计时器协程,同时进行阻塞等待请求的共识完成并成功执行,如何判断是否共识成功并执行?每当有一条请求共识成功进入applyCh中,由executeThread线程(该函数可见下面介绍)进行执行时,都会唤醒这些阻塞的线程,被唤醒的线程判断是否为自己负责的请求被完成了,若不是则继续阻塞,那么就会有以下两种情况:(1)共识成功并成功执行,;(2)本节点由于网络分区已经是过时的leader,始终无法完成请求的共识成功,计时器超时后依旧没有完成,便返回错误信息“Consensus error";

若完成raft共识后,则等待该请求的完成。通过开启一个协程executeThread来实时监控applyCh管道中的请求,并执行管道中的请求。每有一条请求被执行,Get函数都查看是否为本请求被成功执行,若成功执行则查看执行结果并返回。

我在KVServer结构体中设计了一个cond条件变量就是用于协调executeThread协程和Get协程,Get协程发现本请求成功共识后,后续查看本请求是否被及时执行,若尚未执行则进入阻塞状态。而executeThraed协程在每执行完一条指令后,都会唤醒所有正在等待本请求执行结束的协程,让它们查看是否自己的请求被执行成功了,若成功则可返回结果给客户端。

func (kv *KVServer) Get(args *GetArgs, reply *GetReply) {
	// Your code here.
	kv.cond.L.Lock()
	result, ok := kv.CommandLog[args.Id]
	if ok {
		reply.Value = result
		kv.cond.L.Unlock()
		return
	}
	command := Op{Key: args.Key, Operation: "Get", Id: args.Id}
	kv.cond.L.Unlock()
	_, term, ok := kv.rf.Start(command)
	if !ok {
		reply.Err = "Not leader"
		return
	}
	var timerController bool = true
	var timeoutTime = 0
	kv.cond.L.Lock()
	for {
        //判断是否请求被完成
		result, ok = kv.CommandLog[args.Id]
		if ok {
			reply.Value = result
			timerController = false
			kv.cond.L.Unlock()
			return
		}
		nowTerm, isLeder := kv.rf.GetState()
        //请求并未完成,但是发现本raft节点已经不是leader节点,或者是等待请求结果已经超时3次了,或者发现本节点的状态已经和当时发起共识时不一样了则此次动作作废,以上几种情况均是失败的,直接返回共识错误给客户端
		if nowTerm != term || !isLeder || timeoutTime == 3 {
			reply.Err = "Consensus error"
			timerController = false
			kv.cond.L.Unlock()
			return
		}
		go func() {
            //计时器线程,每经过100ms都没有完成请求的执行就由计时器来自动唤醒,查看是什么情况,若超过3次自动唤醒都没有完成,则直接终止,对应上面
			time.Sleep(time.Duration(100) * time.Millisecond)
			kv.cond.L.Lock()
			if timerController {
				timeoutTime++
				kv.cond.L.Unlock()
				kv.cond.Broadcast()
			} else {
				kv.cond.L.Unlock()
				return
			}
		}()
		kv.cond.Wait()
	}
}

完善PutAppend函数

逻辑和Get函数一模一样,此处不赘述

func (kv *KVServer) PutAppend(args *PutAppendArgs, reply *PutAppendReply) {
	// Your code here.
	kv.cond.L.Lock()
	_, ok := kv.CommandLog[args.Id]
	if ok {
		kv.cond.L.Unlock()
		return
	}
	command := Op{Key: args.Key, Operation: args.Op, Value: args.Value, Id: args.Id}
	kv.cond.L.Unlock()
	_, term, ok := kv.rf.Start(command)
	if !ok {
		reply.Err = "Not leader"
		return
	}
	var timerController bool = true
	var timeoutTime = 0
	kv.cond.L.Lock()
	for {
		_, ok = kv.CommandLog[args.Id]
		if ok {
			timerController = false
			kv.cond.L.Unlock()
			return
		}
		nowTerm, isLeder := kv.rf.GetState()
		if nowTerm != term || !isLeder || timeoutTime == 3 {
			reply.Err = "Consensus error"
			timerController = false
			kv.cond.L.Unlock()
			return
		}
		go func() {
			time.Sleep(time.Duration(100) * time.Millisecond)
			kv.cond.L.Lock()
			if timerController {
				timeoutTime++
				kv.cond.L.Unlock()
				kv.cond.Broadcast()
			} else {
				kv.cond.L.Unlock()
				return
			}
		}()
		kv.cond.Wait()
	}
}

设计executeThread函数

该函数启动一个线程来实时监控applyCh中的完成共识的请求,并将在第一时间将请求执行。

首先通过command.SnapshotValid来判断这个请求是否为执行快照的applyMsg的特殊msg,若是则解码其中的信息并恢复。

若不是执行快照的msg,则通过类型断言来将interface{}类型转为Op类型,获取command中的信息,并执行其中的信息。

同时,那些负责客户端请求的线程在发起请求的共识后,就会阻塞自我等待请求的完成,因此每个请求执行结束后,都应该执行kv.cond.Broadcast()进行广播,来唤醒那些正在等待请求执行结果的线程,让它们返回执行结果给客户端。

此外由于侦查重复请求机制的设计,还需要把完成的请求的id插到队列中进行保存,同时我在实验中设计的队列的长度为5,队列中保存最近执行的5条请求的id。当进行快照保存数据的时候,就会把队列指定的5个请求和数据库的状态保存为快照。

同时,Lab3B要求实现快照机制,触发快照机制的条件为:raft传递给persister.SaveRaftState()函数进行持久化状态的数据的大小超过maxraftstate的时候,就需要进行快照了。但是快照动作由kvserver发起,但是该线程无法访问kv.rf.persister对象的任何信息,因为这个对象是小写开头,为private属性。因此需要在raft中设计两个接口来供kvserver来调用persister.RaftStateSize(),从而获取raft当前的持久化数据的大小。

我设计的接口名字为GetStateSizeAndSnapshotIndex(),这个函数会返回raft当前持久化保存的数据大小以及当前快照中的log的index值,即为lastIncludedIndex。

为什么还要lastIncludedIndex?

因为,有一种特殊情况,一个节点再断连很长时间后,再次连接后,leader会给这个follower发送大量的log。此时follower进行持久化时,数据量大小必然超过maxraftstate,然而这个节点的kvserver在执行第一个请求后发现数据量太大就会立马进行快照,即使仅执行了一条请求,那么快照仅会减少一个单位的rf.log的大小,数据量依旧大于maxraftstate。后续每执行一条命令就会快照一次,这就出现问题了。因此,我们需要设计确保进行快照时,距离上次快照的状态已经至少相差20个log,即至少要执行20条请求,才能再次执行快照。

func (kv *KVServer) executeThread() {
	for {
		// var command raft.ApplyMsg
		var command = <-kv.applyCh
		kv.mu.Lock()
		if command.SnapshotValid {
            // 若为执行快照的msg,则解析其中的数据并恢复快照的数据
			r := bytes.NewBuffer(command.Snapshot)
			d := labgob.NewDecoder(r)
			var data map[string]string
			var commandLog map[int64]string
			var commandLogQueue List
			if d.Decode(&data) != nil || d.Decode(&commandLog) != nil || d.Decode(&commandLogQueue) != nil {
				log.Printf("									Error: server%d readSnapshot.", kv.me)
			} else {
				kv.Data = data
				kv.CommandLog = commandLog
				kv.CommandLogQueue = commandLogQueue
			}
			kv.mu.Unlock()
			continue
		} else if command.CommandValid {
            //执行applyMsg中的请求
			var msg = command.Command.(Op)
			_, ok1 := kv.CommandLog[msg.Id]
			if !ok1 {
				if msg.Operation == "Get" {
					result, ok2 := kv.Data[msg.Key]
					if ok2 {
						msg.Value = result
					} else {
						msg.Value = ""
					}
				}
				if msg.Operation == "Put" {
					kv.Data[msg.Key] = msg.Value
				}
				if msg.Operation == "Append" {
					kv.Data[msg.Key] += msg.Value
				}
				if kv.CommandLogQueue.Len >= 5 {
					kv.CommandLogQueue.Pop()
				}
				kv.CommandLogQueue.Push(msg.Id)
				kv.CommandLog[msg.Id] = msg.Value
				var statesize, snapshotIndex = kv.rf.GetStateSizeAndSnapshotIndex()
				if kv.maxraftstate != -1 && statesize >= kv.maxraftstate && command.CommandIndex-snapshotIndex >= 20 {
					// the size of raftstate is approaching maxraftstate
					w := new(bytes.Buffer)
					e := labgob.NewEncoder(w)
					var encodeCommandLog = make(map[int64]string, 5)
					for i := 0; i < kv.CommandLogQueue.Len; i++ {
						var msgId = kv.CommandLogQueue.Data[i].(int64)
						encodeCommandLog[msgId] = kv.CommandLog[msgId]
					}
					e.Encode(kv.Data)
					e.Encode(encodeCommandLog)
					e.Encode(kv.CommandLogQueue)
					data := w.Bytes()
					kv.rf.Snapshot(command.CommandIndex, data)
				}
			}
			kv.mu.Unlock()
			kv.cond.Broadcast()
		}
	}
}

完善StartKVServer函数

我在这里添加我上述额外添加的一些变量和函数。

kv.CommandLog、kv.CommandLogQueue、kv.cond、kv.Data都进行了初始化

同时,一个kvserver在启动的时候,必须检查是否有快照,若有快照则需要恢复快照中的状态。因此,我们需要获取raft中的快照的数据信息,我设计了GetSnapshot()接口来供kvserver来调用获取快照的数据大小,若有快照则数据大小不为0.

并开启一个线程来执行executeThread函数来实时监控applyCh管道并执行其中的请求。

func StartKVServer(servers []*labrpc.ClientEnd, me int, persister *raft.Persister, maxraftstate int) *KVServer {
	// call labgob.Register on structures you want
	// Go's RPC library to marshall/unmarshall.
	labgob.Register(Op{})

	kv := new(KVServer)
	kv.me = me
	kv.maxraftstate = maxraftstate

	// You may need initialization code here.

	kv.applyCh = make(chan raft.ApplyMsg)
	kv.rf = raft.Make(servers, me, persister, kv.applyCh)
	kv.CommandLog = make(map[int64]string)
	kv.cond = sync.NewCond(&kv.mu)
	kv.Data = make(map[string]string)
	kv.CommandLogQueue = List{}
	var snapshot = kv.rf.GetSnapshot()
	if len(snapshot) != 0 {
		r := bytes.NewBuffer(snapshot)
		d := labgob.NewDecoder(r)
		var data map[string]string
		var commandLog map[int64]string
		var commandLogQueue List
		if d.Decode(&data) != nil || d.Decode(&commandLog) != nil || d.Decode(&commandLogQueue) != nil {
			log.Printf("									Error: server%d readSnapshot.", kv.me)
		} else {
			kv.Data = data
			kv.CommandLog = commandLog
			kv.CommandLogQueue = commandLogQueue
		}
	}
	go kv.executeThread()
	// You may need initialization code here.

	return kv
}

Raft算法部分

提供访问快照和读取raftstate大小的接口

func (rf *Raft) GetStateSizeAndSnapshotIndex() (int, int) {
	rf.mu.Lock()
	var index = rf.lastIncludedIndex
	rf.mu.Unlock()
	return rf.persister.RaftStateSize(), index
}

func (rf *Raft) GetSnapshot() []byte {
	return rf.persister.ReadSnapshot()
}

修复一个奇怪的bug,在Start函数中

由于碰到data race,显示上面这种方式将rf.log中的内容赋值给args.Entries将会导致数据竞争,即rf.log发生修改时,args.Entreis也会发生变化。由于上面的这种赋值方式是浅拷贝,因此我们需要进行深拷贝,就是让args.Entreis和rf.log独立开来,避免rf.log改变会导致args.Entreis发生改变。

原代码为上面,修改后的代码为下面3行。

args.Entries = rf.log[nextIndex-rf.lastIncludedIndex : index+1-rf.lastIncludedIndex]





var tempLog = rf.log[nextIndex-rf.lastIncludedIndex : index+1-rf.lastIncludedIndex]
args.Entries = make([]LogEntry, len(tempLog))
copy(args.Entries, tempLog)

实验结果图

MIT 6.824分布式 LAB3:kvraft_第1张图片

 

你可能感兴趣的:(分布式,数据库,go,golang)