MIT6.824 Lab3实现与踩坑

Lab3要在Lab2的Raft层之上实现一个key-value存储系统,实现数据的多副本存储,要求满足线性一致性。理论上来说,Raft只能保证共识,即保证各个节点的log是相同的,这与系统的一致性无关。系统是否满足线性一致性取决于上层应用的实现方式。对于本实验来说,要想满足线性一致性,就要求无论是读请求还是写请求,都需要在Raft层提交之后才在KVServer上执行

Lab3并不像Lab2一样按照一篇论文来实现,需要自己设计。但其实这个系统的实现思路基本就一种,没有太多自己发挥的空间。我看了网上的其它博客,实现的各有不同,但设计思路是一样的。

一、设计思路

Server端的RPC方法实现: 客户端会给服务器端发送RPC请求,本实验中有两种RPC请求,分别对应Get操作和Put/Append操作。server接到请求时如果自己不是leader,则直接返回false,不需要执行任何操作,寻找正确leader这一工作交给client实现。如果是leader,则提交这个请求给Raft层。等请求完成后返回RPC响应,如果是Get请求则需要返回得到的值,Put/Append请求只需要返回Error。

Server层与Raft层的交互: 在Lab2实现Raft时,实现了Start()方法和apply()方法,在Lab3A中,有且只有这两个方法能够实现Raft层与Server层的交互。交互流程为:Server(leader)收到RPC请求 → \rightarrow Start() → \rightarrow Raft层共识 → \rightarrow Server(全部)监听applyCh → \rightarrow Server(全部)执行操作 → \rightarrow Server(leader)返回RPC请求。

在数据库(用一个map实现)上执行操作是所有server都执行,但提交请求和响应RPC只有当前的leader需要做。Raft层通过applyCh给上层server提交消息,监听applyCh的过程显然需要一个单独的goroutine实现,这个goroutine需要将请求执行完成的消息通知给执行RPC方法的goroutine,因此server端还需要一个channel(记作commitIndexCh)来做这件事。

实际情况会更复杂一些,因为多个client会同时发送请求,在server端看来就会有多个goroutine分别处理每个RPC请求(但这由RPC框架实现,对程序透明,我们要实现的只是RPC处理方法)。那么,监听applyCh的goroutine执行完一个请求后怎么确定给哪个RPC处理goroutine发消息呢?因此commitIndexCh并不是一个channel,而是一个int -> channel的map,每个command index对应一个channel。server提交请求时,会得到这条请求在raft log中的index,RPC处理goroutine监听的是这个index对应的channel。监听applyCh的goroutine也会知道提交上来的command在raft log中的index,也是将请求完成的消息提交给对应index的channel。

在完成Lab2后,我们知道index并不能唯一对应一条client请求,因为一条log entry在达到共识之前是不确定的。具体来说是这样一种情况:

  • client 1发来请求A,client 2发来请求B。但由于网络分区,A和B被不同的“leader”提交给Raft层,且都提交到了各自raft log中index = y的位置
  • 在Raft共识后,所有节点上index = y这个位置的log entry全变成了请求A
  • 监听applyCh的goroutine执行请求A,发送完成的消息给index = y对应的channel,这条消息被处理client 2请求的goroutine收到

这时候系统要能够发现,client 2的请求B并没有得到处理,因此每个client的每个请求都要有唯一的标识能够在最后用来判断。显然,使用clientId + requestId能够作为唯一标识,这在之后client端的实现部分再说。

超时机制: 在上面的情况中,处理client 1 RPC请求的goroutine会永远等不到请求A执行成功的消息(虽然请求A已经成功执行了),因此还要加入一个超时机制,不要让某个处理RPC请求的goroutine永远等下去。这样RPC的处理过程变成了等待两种情况之一发生:

  1. 这个index对应的channel收到了消息,说明请求执行完成。之后根据clientId + requestId判断执行完成的请求是不是这个client发来的请求
  2. 达到了设置的超时时间,不用再等了,返回RPC

避免请求重复执行: 加入了超时机制后,处理client 1 RPC请求的goroutine在上面这种情况中会返回一个timeout错误给client 1,之后client 1会重新发送这条请求给server,这条请求可能会重新执行一次。但刚才我们提到,这条请求已经执行过了,如果是Put/Append请求,server不能让这条请求再次执行,因此server端在执行一条请求之前先要判断是否重复,同样是根据clientId + requestId来判断一条请求是否已经执行过。如果是Get请求,不用判断重复,要正常执行。

数据的持久化: 我们的KV系统数据是存储在内存中的,要想保证重启后数据不丢失,需要将map中存储的KV数据持久化。在Lab2C中我们已经实现了raft log的持久化,理论上可以通过重放log恢复数据库中的内容,但log不能无限增长,否则存储log和重放log的开销都是无法接受的。Lab3B要求在raft state size(raft中需要持久化的所有属性的size之和)达到一定阈值时,将现有的KV数据做成snapshot持久化存储,同时抛弃在此之前的raft log。因此又需要一个单独的goroutine监听raft state size是否达到阈值。注意:这个过程是所有的server都要做的,不只是leader要做。follower也可以根据自己的需要进行持久化并抛弃相应的log,但leader还有另一个任务,就是给落后太多的节点直接发送snapshot。

Client端: 客户端只要实现一个功能,就是向当前状态为leader的server发送RPC请求,等待返回结果。如果得到的RPC返回结果是执行不成功,则需要重新发送。之前说到,每个请求需要clientId+requestId作为唯一标识,课程提供的代码中有一个生成随机数的函数nrand(),可以用来生成clinetId(test中client数量只有5个,可以认为随机数不会重复。实际的分布式系统中会使用雪花算法等方法生成唯一id)。requestId是每个client自己维护的一个属性,只有在请求成功后才将requestId+1。

二、Lab3A

Server结构体和Server给Raft发的消息的结构体为:

// Op 由raft.log记录的一个操作
type Op struct {
	Command   string
	Key       string
	Value     string
	ClientId  int64
	RequestId int64
}

type KVServer struct {
	mu                sync.Mutex
	me                int
	rf                *raft.Raft
	applyCh           chan raft.ApplyMsg
	commitIndexCh     map[int]chan Op // 一个 index 对应一个 channel,根据提交的index决定等待哪个channel的消息
	FinishSet         map[int64]int64 // 记录一个 client 已经处理过的最大 requestId
	dead              int32
	maxraftstate      int // snapshot if log grows this big
	DB                map[string]string
	lastIncludedIndex int // 已经执行的最大index,Lab3B使用
}

FinishSet用于去重,记录的是每个client已经处理过的最大requestId。不要直接记录全部的[2]int{clientId, requestId}来判断重复重,因为FinishSet在Lab3B中需要持久化,而持久化数据的大小有限制,不能浪费空间。

从channel等待消息并等待超时: 使用select case语句,让goroutine同时等待两个事件之一完成。

select {
	// 等待从 index 对应的 channel 中传来 Op
	case retOp := <-ch:
		if retOp.Command == "duplicate" {
			reply.Value = retOp.Value
			reply.Err = "duplicate"
		} else if retOp.ClientId != args.ClientId {
			reply.Err = "wrong"
		} else if retOp.RequestId != args.RequestId {
			reply.Err = "wrong"
		} else {
			reply.Value = retOp.Value
			reply.Err = ""
		}
	// 没等到,超时了
	case <-time.After(TIMEOUT * time.Millisecond):
		reply.Err = "timeout"
		reply.Value = ""
	}

commitIndexCh中的每个channel一定设置成有缓冲的,如果是无缓冲的,遇到下面的情况会死锁:
goroutine A(处理RPC请求的goroutine):

  1. select {从channel接受消息;或超时}
  2. 关闭channel
  3. 从commitIndexCh中删除这个channel

goroutine B(监听applyCh的goroutine):

  1. 判断commitIndexCh中是否有channel
  2. 如果有,向channel发送消息

goroutine B可能在goroutine A的步骤1之后发送消息,导致消息无人接受,goroutine B阻塞。而有缓冲的channel可以将goroutine B的步骤2与goroutine A解耦,即使有缓冲channel中的消息无人接受,也可以close。

从commitIndexCh中取出channel和操作channel的原子性: 在取出commitIndexCh中的某个channel前,先要判断是否存在这个channel,之后再操作channel(close或收发消息),有三个地方需要这种操作:

  1. leader将请求提交Raft层之后,判断commitIndexCh中是否有index对应的channel,如果没有,则需要创建channel并放入map
  2. leader返回RPC请求前,需要判断commitIndexCh中是否有index对应的channel,如果有,需要关闭channel并从map中删除
  3. server的数据库执行了某个请求后,如果是这个节点是leader,需要判断commitIndexCh中是否有index对应的channel,如果有,给这个channel发消息(为什么有这种情况?因为提交command和执行command的leader不是同一个节点了)

以上三个操作都需要保证原子性,以第一处为例,代码为:

	kv.mu.Lock()
	ch, have := kv.commitIndexCh[index]
	if !have {
		ch = make(chan Op, 1)
		kv.commitIndexCh[index] = ch
	}
	kv.mu.Unlock()

即使用了sync.Map代替map,虽然sync.Map和channel的操作都是原子性的,但并不能保证这两条语句结合在一起也是原子性的,因此还是要加锁。另外,这个Lab中的三个map都不要使用sync.Map代替,因为Lab3B需要持久化map,而sync.Map无法进行Gob编码。

Client怎么发送RPC请求: client要记录上一次执行成功的leader Id,下一次发送请求时直接给这个节点发,如果返回失败,则更换下一个节点,再次发送。因此,client的发送请求过程是在for{}循环中的,直到成功了才结束。RPC的reply不要重复使用,每次发送RPC请求都初始化一个新的。

func (ck *Clerk) Get(key string) string {
	rid := atomic.AddInt64(&ck.requestId, 1)
	args := GetArgs{
		Key:       key,
		ClientId:  ck.clientId,
		RequestId: ck.requestId,
	}
	i := 0
	for {
		// RPC的reply不要重复使用
		reply := GetReply{}
		// 下一个serverId
		serverId := (ck.leader + i) % len(ck.servers)
		DPrintln("client", ck.clientId, "send a GET operation to ", serverId, "requestId =", ck.requestId, "key =", args.Key)
		ok := ck.servers[serverId].Call("KVServer.Get", &args, &reply)
		if ok {
			switch reply.Err {
			case "not leader", "timeout", "wrong":
			case "duplicate":
				ck.leader = serverId
				ck.requestId = rid
				return reply.Value
			case "":
				ck.leader = serverId
				ck.requestId = rid
				return reply.Value
			}
		}
		i++
	}
	return ""
}
三、Lab3B

Lab3B要求实现KV数据的持久化和raft log的抛弃,对Server的修改不多,主要的问题在于对Raft代码的修改,需要增加一个InstallSnapshot RPC方法,并考虑log index转换的问题。

Server端需要增加两部分内容,一是监听Raft层提交的请求时,要判断这个请求是snapshot还是正常的Get/Put/Append操作,分别对应这不同处理。如果是snapshot,则对请求中携带的snapshot做解码,覆盖到当前的KV数据库中。这里涉及到两个index属性:

  1. Server端保存一个lastIncludedIndex属性,表示自己已经执行过的请求的最大index
  2. Raft中也需要增加一个lastIncludedIndex属性,所有的snapshot消息都会带有这个属性,表示这个snapshot是对于lastIncludedIndex及其之前的log做的

如果server.lastIncludedIndex > snapshot.lastIncludedIndex,则不能使用发来的snapshot替代自己的KV数据,因为自己的数据更新。

// 监听 applyCh,收到消息后执行操作
func (kv *KVServer) listenApplyCh() {
	for kv.killed() == false {
		applyMsg := <-kv.applyCh
		if applyMsg.CommandValid {
			kv.mu.Lock()
			if applyMsg.IsSnapshot {
				// 这是一个snapshot,保存之后等下一条消息
				// 先做判断,避免snapshot覆盖已经提交的消息
				if applyMsg.CommandIndex > kv.lastIncludedIndex {
					kv.readSnapshot(applyMsg.Snapshot)
					kv.lastIncludedIndex = max(kv.lastIncludedIndex, applyMsg.CommandIndex)
				}
				kv.mu.Unlock()
				continue
			}
			// 这是Get/Append/Put操作,正常执行
			// 。。。。。
		}
	}
}

Server的另一个工作就是一直检测Raft State Size是否超过阈值,超过了需要通知Raft层将lastIncludedIndex及其之前的log都删除,自己将现有的KV数据和FinishSet序列化做成snapshot发给Raft层。Snapshot一定包含FinishSet,因为server重启了,或接受了别人的snapshot后还要能保证正确的去重。

之后就是对Lab2代码的修改了。Raft结构中需要增加两个属性:lastIncludedIndex和lastIncludedTerm。两个属性的作用和InstallSnapshot RPC的参数、返回值、实现在Raft论文里都有。主要的bug是index的转换和一些条件判断。

raft log数组删除前面的一部分后,log entry在数组中的下标发生变化。比如有一条log entry的index = a,现在index = b及其之前的log都删除了,那index = a的log entry会被放到index = a-b的位置。这就引发了index转换的问题,我最开始想到了两种方法:

  1. 每次得到snapshot后,将index相关属性(lastApplied,commitIndex等)重置
  2. 保持index相关属性的递增,读写log时做index转换

第一种方法会导致lastIncludedIndex的错乱,不可取。在之后的实现中,所有index相关的属性都是递增的,不可能由于snapshot回退到之前的位置。因此不能使用index相关属性直接作为下标读取log数组,要将index转换为实际数组下标。原则是:实际下标 = 逻辑index - lastIncludedIndex

index转换的4种情况:

  1. log的最大index = len(rf.log) - 1 + lastIncludedIndex
  2. log[index] = log[index - lastIncludedIndex]
  3. 为了方便raft算法的prevLogIndex比较,index = 0的log是一个占位log entry,不实际使用,初始时设置term = -1(随便取的一个无意义值),经过snapshot后第0个log的term = lastIncludedTerm,否则发送AppendEntries RPC时参数中的prevLogTerm会出错
  4. 初始化raft时,index相关属性从lastIncludedIndex开始,且index = 0的log entry的term = lastIncludedTerm,因此这两个属性需要持久化

InstallSnapshot RPC的实现: 论文里已经给出了基本的实现。这里补充两点:

  1. 收到InstallSnapshot RPC请求,如果rf.lastApplied < args.lastIncludedIndex,需要更新lastApplied和commitIndex。修改lastIncludedIndex和lastIncludedTerm要在修改完log后修改,顺序不能错(因为log的下标转换要使用之前的lastIncludedIndex)
  2. leader发送InstallSnapshot RPC,对方返回true后要修改自己的matchIndex和nextIndex,和AppendEntries RPC的规则一样

由于RPC的在传输过程中的重排序,和函数的非原子性,可能会出现以下几种情况单独处理:

  1. 收到AppendEntries RPC请求,如果args.PrevLogIndex < rf.lastIncludedIndex,对于参数中index < rf.lastIncludedIndex部分的log,按照上面的index转换规则会导致index<0,不要处理这部分log
  2. Raft的apply()方法,提交一个command前先检查其index是否 > rf.lastIncludedIndex。如果不是,说明这条消息已经被做成snapshot了,不需要提交。为什么会出现这种情况?向applyCh发消息时不持有锁,这时raft收到了snapshot并抛弃了之后正准备提交的一些command
  3. raft收到上层server的通知要抛弃index = a及其之前的log时,先检查 a 是否 > rf.lastIncludedIndex,如果a <= rf.lastIncludedIndex,说明a之前的log已经做成snapshot了并删除了,不要接受上层发来的snapshot。为什么会出现这种情况?上层server发来snapshot的消息的同时,raft正在接受leader发来的更新的snapshot
  • 6
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值