前置要求
如果你还没有读过lab3的要求/学生指南,请先去阅读对应内容nil.csail.mit.edu/6.824/2020/...
zhuanlan.zhihu.com/p/343560811
三个问题
我们会发现对于部分问题lab3要求/学生指南给出了我们明确的做法,但对于下面这两个问题则有些模糊
- 幂等性保证(重复检测)
- 应用客户端操作(何时响应客户端):当一次请求到达后,我们会先把他传给raft,当raft将其提交后应用到kv数据库,之后再响应客户端,而kv数据库如何及时感知某个日志被应用并作出响应。
除此之外,一个请求可能因为网络错误/服务器宕机而被丢失,因此问题3就是
- 当客户端未能收到某个请求的明确响应时该怎么办。
我们这里以1->3->2的方式依次解答这三个问题
幂等性保证
重要的一点
当一个日志被commit后,会被应用到kv服务器(即执行日志对应命令),然后修改lastApplied。我们一定要单线程执行日志命令(lab3没有要求读操作优化,但建议完成),而因为写操作的频率很低,所以不会造成过大的性能劣化。
初步实现
最简单的,我们可以为每个客户端请求分配一个唯一的req_id(雪花算法,UUID,客户端id+单客户端自增id得到均可,唯一就行),然后服务端维护一个map(名字叫做req_map)来记录这个req_id是否被执行,当单线程执行某个日志时,我们就可以从日志中获取到他的req_id并通过req_id是否在req_map中存在来判断其是否执行过。
- 如果已执行,则直接响应执行成功并返回
- 否则才真实执行命令并修改map。
多服务共享
上述方案其实有个问题,req_map其实是只在当前leader中被维护的,当发生主从切换后,req_map中的内容就会丢失,从而导致某些执行过的req_id被误判为未执行(因为req_map丢失导致执行过的req_id在req_map中也不存在)
一个最为简单的方式是:我们把req_id也放到日志中去同步到副本,这样当新leader当选并执行日志时,可以通过日志中的req_id来恢复req_map
go
// 一个例子
// 假设日志如下
// 1.index=1,req_id=1,command="{key:"sayHello",value:"hello ",op:"set"}"
// 2.index=2,req_id=2,command="{key:"sayHello",value:"world",op:"append"}"
// 3.index=3,req_id=2,command="{key:"sayHello",value:"world",op:"append"}"
// 之后恢复日志得到的结果就是
{
"sayHello": "hello world"
}
// req_map为
{
1: true //req_id=1的日志执行过了
2: true // 在index=2的日志执行后,这个kv对被添加。index=3的日志执行时发现对应的req_id已执行过,忽略。
}
//之后客户端由重试req_id=2的日志,也会被成功发现,直接返回success并不实际执行
而即使某个日志在主从同步中被丢弃,也是req_id和command一起被丢弃,并不会导致重复执行。
通过这种方式我们可以很好的保证幂等性。
客户端未收到明确响应
在保证了幂等性之后,这个问题就很好解决了,我们只需要无限期重试直到得到明确响应即可。
ps:你可能会觉得这种方案可用性较低,但这是正常的,毕竟一款CP系统在极端情况下是要牺牲可用性的。
应用客户端操作(何时响应客户端)
当一次请求到达后,我们会先把他传给raft,当raft将其提交后由单线程(命令执行线程)执行命令并应用到kv数据库,之后再响应客户端,而kv数据库的请求处理线程如何及时感知某个日志被应用并作出响应是一个问题。
线程通信
最简单的方式是我们可以利用goroutine来进行命令执行线程和请求处理线程之间的通信。
一般情况下,一个index只会对应一个命令。因此,我们可以为每个index对应的命令添加一个channel,当对应index的日志执行成功后把response写入该channel。
伪代码如下
go
// 请求处理线程
{
index,term,isLeader=kv.rf.Start(command)
// 这个锁必须不能包含Start,否则会出现
// 请求处理时kv.Lock()->rf.Lock()
// commit日志并执行时 rf.Lock()->kv.Lock()
// 带来死锁
kv.lock.Lock()
ch = kv.getIndexChannel(index)
kv.lock.Unlock()
select{
case rsp<-ch:
if 执行成功 {return success}
else {return fail};
}
}
// 命令执行线程
{
kv.lock.Lock()
defer kv.lock.Unlock()
if 未执行过req_id{
执行命令
}
ch = kv.getIndexChannel(index)
// 如果不存在channel就直接返回,这里最好不要新建
if ch == nil {
return
}
ch <-response
}
通过这种方式我们可以在某个命令执行成功后立即通知对应的请求处理线程,并响应客户端。
日志丢弃
当主从切换时,一个日志可能会被丢弃,此时就可能存在,多个请求处理线程在调用start的时候都收到同一个index,但每个index对应的command不同,且最后也只有一个command被成功应用到kv服务器的情况。
对于这种情况,命令执行线程对于这个index只会写入一个respons,从而导致剩下的请求处理线程全部死锁等待。一个最简单的处理方案就是,我们可以在某个请求处理线程获取到respone后直接销毁channel,从而使得其余请求处理线程得到默认响应(channel被销毁后会给读channel的goroutine响应默认值)。
主从切换怎么办
对单机服务来说这种方案看起来已经没问题了,我们看一种极端情况(假设命令执行线程会新建channel)。
5个server(A,B,C,D,E)出现了分区,其中AB一组(leader为A),CDE一组(leader为C)
- A收到了index=3的请求,调用Start并创建channel1,但因为无法同步过半服务导致一直没法commit
- 客户端超时重试,把index=3的请求发送给C,C调用Start并创建channel2,然后成功commit日志并执行,命令执行线程执行成功后把response写入channel2,channel2成功回收。
- 分区解除,channel1仍然未能收到response。请求处理线程死锁,channel1永远不会回收。
事实上,这个问题和channel的写入时机关系也不是太大,只要某个leader的请求处理线程在执行到一半时发生主从切换,那么他都会因无法得到响应从而死锁。
加入超时时间
解决死锁的一个最简单的方式就是添加超时时间。
csharp
// 请求处理线程
{
index,term,isLeader=kv.rf.Start(command)
// 这个锁必须不能包含Start,否则会出现
// 请求处理时kv.Lock()->rf.Lock()
// commit日志并执行时 rf.Lock()->kv.Lock()
// 带来死锁
kv.lock.Lock()
ch = kv.getIndexChannel(index)
kv.lock.Unlock()
select{
case rsp<-ch:
销毁channel()
if 执行成功 {return success}
else {return fail}
// 新增超时,超时时响应失败,客户端收到失败响应后重试,因为前面的幂等性保证,所以重试后会得到正确结果
case 超时:
销毁channel()
return fail
}
}
为什么说不要在命令执行线程中新建channel
在添加超时时间后,如果我们在命令执行线程中创建channel,可能出现这样一种情况。 请求执行线程创建channel->超时->销毁channel->消息成功commit并执行->命令执行线程创建channel->OOM。