Service提供Get、Put、Append、Delete四种操作,其中,Append操作不具备幂等性。 由于分布式系统固有的不可靠性,客户端可能会重试请求,因此实验要求我们能够检测到重复的请求,并确保一个请求只会被执行一次。为了对请求进行重复性检测,就需要为每个请求生成一个唯一标识。一个客户端会执行发起多次请求,因此可以采用全局唯一客户端标识+序列号来标识每个请求。 具体来说,客户端在发出请求前先分配一个全局唯一标识,在随后的请求中,客户端同时使用自身递增的序列号标识每个请求。这样就只需要在请求的一开始生成一个标识即可,后续的序列号由客户端生成。
在生成全局唯一标识方面,主要有以下几种方式:
第一种方式生成的表示不能确保全局唯一,尤其是在分布式系统中。第二种方式比较常用,在这里不考虑。可以利用Raft来构建一个具备容错能力的分布式ID生成器。客户端首先向ID生成器发出请求, ID生成器利用Raft共识算法对请求进行commit后,对ID进行递增后,将ID返回给客户端。ID是int64类型时,可以生成2^63-1个不同的ID。 如此一来,就解决了为每个客户端生成一个全局唯一标识的问题。由于客户端的序列号也是递增的,因此 服务端可以通过客户端ID+请求序列号来判断请求是否重复,如果序列号不大于 记录的序列号,则说明请求重复。但是,当客户端同时发送多个不同序列号的请求时(比如序列号1-5),问题会更复杂。
因此,lab3给出了"客户端一次只会发送一个请求,如果请求失败,将会一直重试"这样的假设。
起因:我们不希望自己的KVServer被随意访问,因此想要给KVServer加上一层认证措施,只有符合条件的客户端的请求才能被处理。 这里当然会想到给KVServer设置密码,只有客户端提供正确的密码后,才是通过认证的客户端。 在通过认证后,分配给客户端一个唯一的标识;客户端后续请求时就携带上这个标识,表明客户端已经通过认证了(因为不希望每次请求都要携带上密码)。 参照Redis这样的设置,将密码保存在一个配置文件中,KVServer启动时就读取配置文件里保存的密码,并将其与客户端提供的密码进行核对。
**需求:**标识应当无规律,难以通过暴力尝试手段得到正确的标识,还需要确保生成的客户端标识的是分布式唯一的。
思路:B/S架构下的SessionId是一个参考,可以考虑给每个通过认证的Client生成一个唯一的SessionId(随机串,比如uuid),根据客户端提供的SessionId参数来验证会话的有效性。 但是在分布式情境下,即使是基于时间戳并在同一台机器上生成uuid,也是有重复的可能。一般采用的策略是是选择雪花算法(SnowFlake),亦或者利用分布式锁来生成。 基于Raft提供的强一致性保证,我们可以对标识达成共识,但标识是有可能重复的。可以选择对后续重复的标识回传一个结果,以指示该标识重复,需要重新生成,但这样做会浪费一次 共识所需的时间。不妨换个思路,利用共识在集群中生成一个唯一的int64整数。我们可以将这个整数作为SessionId的前缀,uuid作为SessionId的后缀。 而者通过非数字字符相连组成SessionId。 这样生成的uuid发生重复也是没关系的,因为前缀必定是不同的。
可以不持久化,也就是说,Server对SessionId的记录是可以丢失的,下面是我的理解:
首先考虑单Server的情况: 当Server崩溃重启时,会读取log并重放,因此Client与Server通信会出现以下几种情况:
即使持久化了,还是会出现上面三种情况(假如Client给Server发请求时,Server还没有将日志完全重放,则SessionId还是无效的) Server崩溃了,Client与Server的连接也会断开,RPC调用就会直接失败,是否可以通过这个来直接作废SessionId? 对于作废的SessionId,由go routine定时清理
再考虑集群的情况: 集群相较于单主机可能会复杂一点,但是有一点可以明确:只有Leader才能处理Client的请求。Leader是集群中log最为完整的。 基于Raft提供的强一致性保证,如果Leader没有发生切换,则Client发送给Leader的请求,情况和单Server是一样的。 假如当前Leader崩溃了,那么Client会找到新的Leader,而该Leader的日志至少与前Leader一样新,因此情况和单Server还是一样的。再思考深一点,如果就是想保证生成的uuid就是唯一的呢,可否用现有的Raft做到?
KVServer依靠Raft共识算法来达到强一致性,对抗网络分区、宕机等情况。KVServer对外暴露接口供客户端进行RPC调用。 一般情况下,KVServer是以集群的形式存在的,而根据Raft共识算法,只有集群中的Leader才能处理请求。 因此对KVServer的RPC调用在一开始很可能不会成功,所以需要对客户端进行一定的封装,才能更方便地使用KVServer。 在封装Client的过程中,需要给Client的使用者提供一个一致的错误模型。 对KVServer的RPC调用可能出现以下情况:
当有多个KVServer时,对于第一、二种情况来说,Client应尝试调用其他KVServer,只有调用过其他KVServer也无法找到Leader时,才应当认定服务器出现故障。 而对于第三种情况来说,客户端的请求可能会执行也可能不会执行;对客户端而言,命令没有达成共识和网络延迟是一样的情况,请求是否执行对于客户端来说也是不确定的。
从整个系统层面来看,raft和上层的service就是一个Producer-Consumer关系。raft负责对日志进行一致性复制,并生产committed log给service;service负责消费committed log并执行相应的操作。 另外,raft还负责持久化log和snapshot,某种层面上说,raft也相当于一个存储引擎。因此优化可以分别从两方面入手:生产消息和持久化消息。
leader
是100ms发送一次AppendEntries
RPC,可能需要花费200ms左右;而如果在收到新的log就启动一次 AppendEntries
RPC的话,当提交的log比较频繁时,RPC开销会很大。因此在RPC方面,可以考虑批量发送log(batch),比如在收到一定量的log后再启动RPC。某种程度上,类似于OS的缓冲区思想;另外,如kafka等消息队列的生产者也做了这个优化。 系统之间的设计相通+1。可以把RPC看成消息,因此可以对消息进行压缩,从而降低RPC的开销。而GRPC框架提供了高效的序列化。
raft对log和snapshot的持久化无需考虑到查找效率,因此也无需采用B+Tree的形式存储,另外,raft的持久化考虑更多的数据的可靠性,因此也无需采用LSM Tree的存储方式。 但可以考虑对数据进行分片,每个raft负责存储一部分数据,以提高系统的吞吐量。
1. 基于log’s index和log’s term的生产者-消费者模型
在处理请求方面,基于Raft的KVServer相较于传统的KVServer有很大不同。KVServer是需要等待命令达成共识才能执行请求的。 KVServer将Client的请求包装为一个Command提交给Raft, Raft会将达成共识的KVServer通过Channel发送给KVServer。 这里就引出了一个问题:对于每个请求,KVServer如何知晓这个请求执行是否成功? 换个说法,如何确定从channel
接收到的log和提交的log的对应关系? Service向Raft提交Command时,Raft将Command包装为log,并会返回对应log的index
和term
;根据Raft共识算法,index和term确定了log的唯一性。
因为follower在收到leader的AppendEntries RPC进行日志复制时,会检查PrevLogIndex处的log的term与leader的是否一致; 如果不一致,follower将会拒绝本次的请求,leader会根据follower回传的信息,选择是发送快照还是将PrevLogIndex减小。 具体可看下面这段Raft代码:
func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) error {
idx := 0
i := 0
offset := args.PrevLogIndex - rf.lastIncludedIndex
if offset > 0 {
// offset>0:需要比较第offset个log的term,这里减1是为了弥补数组索引,lastIncludedIndex 初始化为-1 也是如此
offset -= 1
if rf.log[offset].Term != args.PrevLogTerm {
reply.XTerm = rf.log[offset].Term
for offset > 0 {
if rf.log[offset-1].Term != reply.XTerm {
break
}
offset--
}
reply.XIndex = offset + rf.lastIncludedIndex + 1
rf.resetTimeout()
return nil
}
i = offset + 1
} else {
idx -= offset // offset <= 0:说明log在snapshot中,则令idx加上偏移量,比较idx及其之后的log
}
}
index
是否有channel
正在等待;有的话就回传ApplyResult(包含了Term),随后从map中删除相关记录,最后close。对于命令的处理流程,前后修改过很多,两个版本都是直接用本地变量ch
来接收signal
,而不是再用map中的channel
(方便清理map中不用的channel) 只有Leader能提交请求,提交请求后会设置相应的channel
,并让线程等待直到超时。
func (kv *KVServer) submit(op Op) (*ApplyResult, pb.ErrCode) {
commandIndex, commandTerm, isLeader := kv.rf.Start(op)
if !isLeader {
return nil, pb.ErrCode_WRONG_LEADER
}
kv.mu.Lock()
if c, _ := kv.replyChan[commandIndex]; c != nil {
kv.mu.Unlock()
return nil, pb.ErrCode_TIMEOUT
}
ch := make(chan ApplyResult, 1)
kv.replyChan[commandIndex] = ch
kv.mu.Unlock()
var res ApplyResult
select {
case res = <-ch:
break
case <-time.After(RequestTimeout):
kv.mu.Lock()
if _, deleted := kv.replyChan[commandIndex]; deleted {
kv.mu.Unlock()
res = <-ch
break
}
delete(kv.replyChan, commandIndex)
kv.mu.Unlock()
close(ch)
return nil, errCode
}
if res.Term == commandTerm {
return &res, pb.ErrCode_OK
} else {
return nil, pb.ErrCode_WRONG_LEADER
}
}
有两个问题:
c, _ := kv.replyChan[commandIndex]; c != nil
代码存在bug。当多个Client向同一个Leader提交请求时,获得的commandIndex
会不会相同? 设leader1是term1
的leader,假如出现了网络分区(Server之间的网络存在故障)且Leader1不处于主分区(它和绝大多数Server通信存在网络故障)。 Leader1仍然认为自己是leader(而此时主分区在term2
选举出了leader2,term2 > term1),并提交来自客户端的请求,很明显,这些请求不会commit。 leader2在commit一些命令后,与leader1的通信恢复正常。按照Raft共识算法,leader1会trim掉与leader2发生冲突的log,并append来自leader2的log。 只要append的没有trim掉的多,也就说明leader1的log长度减小了。leader1在term3
重新成为leader,则会出现commandIndex
相同的情况。 这种情况一出现,就说明先前客户端的命令不可能commit;这时,只需要回传一个result(回传的term必定大于前面等待term)即可。
func (kv *KVServer) submit(op Op) (*ApplyResult, pb.ErrCode) {
commandIndex, commandTerm, isLeader := kv.rf.Start(op)
if !isLeader {
return nil, pb.ErrCode_WRONG_LEADER
}
kv.mu.Lock()
if c, _ := kv.replyChan[commandIndex]; c != nil {
c <- ApplyResult{Term: commandTerm}
close(c)
}
ch := make(chan ApplyResult, 1)
kv.replyChan[commandIndex] = ch
kv.mu.Unlock()
res := <-ch
if res.Term == commandTerm {
return &res, pb.ErrCode_OK
} else {
return nil, pb.ErrCode_WRONG_LEADER
}
}