Raft 幂等性接口

幂等性接口

Service提供Get、Put、Append、Delete四种操作,其中,Append操作不具备幂等性。 由于分布式系统固有的不可靠性,客户端可能会重试请求,因此实验要求我们能够检测到重复的请求,并确保一个请求只会被执行一次。为了对请求进行重复性检测,就需要为每个请求生成一个唯一标识。一个客户端会执行发起多次请求,因此可以采用全局唯一客户端标识+序列号来标识每个请求。 具体来说,客户端在发出请求前先分配一个全局唯一标识,在随后的请求中,客户端同时使用自身递增的序列号标识每个请求。这样就只需要在请求的一开始生成一个标识即可,后续的序列号由客户端生成。

在生成全局唯一标识方面,主要有以下几种方式:

  1. 使用UUID+时间戳/随机数;
  2. 使用雪花算法;
  3. 分布式ID生成器;

第一种方式生成的表示不能确保全局唯一,尤其是在分布式系统中。第二种方式比较常用,在这里不考虑。可以利用Raft来构建一个具备容错能力的分布式ID生成器。客户端首先向ID生成器发出请求, ID生成器利用Raft共识算法对请求进行commit后,对ID进行递增后,将ID返回给客户端。ID是int64类型时,可以生成2^63-1个不同的ID。 如此一来,就解决了为每个客户端生成一个全局唯一标识的问题。由于客户端的序列号也是递增的,因此 服务端可以通过客户端ID+请求序列号来判断请求是否重复,如果序列号不大于 记录的序列号,则说明请求重复。但是,当客户端同时发送多个不同序列号的请求时(比如序列号1-5),问题会更复杂。

  1. 无论请求何时到达,这些请求都会被执行。优点:允许客户端同时发送多个请求。缺点:服务端需要记录客户端所有的请求序列号,内存开销会很大。
  2. 服务端只能按递增顺序处理请求,后面的请求只能等待前面的请求完成后才能执行。优点:对于每个客户端,只需要记录上次执行的请求的序列号,内存开销小。缺点:只允许客户端一次发送一个请求。

因此,lab3给出了"客户端一次只会发送一个请求,如果请求失败,将会一直重试"这样的假设。

Server的安全性

起因:我们不希望自己的KVServer被随意访问,因此想要给KVServer加上一层认证措施,只有符合条件的客户端的请求才能被处理。 这里当然会想到给KVServer设置密码,只有客户端提供正确的密码后,才是通过认证的客户端。 在通过认证后,分配给客户端一个唯一的标识;客户端后续请求时就携带上这个标识,表明客户端已经通过认证了(因为不希望每次请求都要携带上密码)。 参照Redis这样的设置,将密码保存在一个配置文件中,KVServer启动时就读取配置文件里保存的密码,并将其与客户端提供的密码进行核对。

**需求:**标识应当无规律,难以通过暴力尝试手段得到正确的标识,还需要确保生成的客户端标识的是分布式唯一的。

思路:B/S架构下的SessionId是一个参考,可以考虑给每个通过认证的Client生成一个唯一的SessionId(随机串,比如uuid),根据客户端提供的SessionId参数来验证会话的有效性。 但是在分布式情境下,即使是基于时间戳并在同一台机器上生成uuid,也是有重复的可能。一般采用的策略是是选择雪花算法(SnowFlake),亦或者利用分布式锁来生成。 基于Raft提供的强一致性保证,我们可以对标识达成共识,但标识是有可能重复的。可以选择对后续重复的标识回传一个结果,以指示该标识重复,需要重新生成,但这样做会浪费一次 共识所需的时间。不妨换个思路,利用共识在集群中生成一个唯一的int64整数。我们可以将这个整数作为SessionId的前缀,uuid作为SessionId的后缀。 而者通过非数字字符相连组成SessionId。 这样生成的uuid发生重复也是没关系的,因为前缀必定是不同的。

细节问题:记录SessionId的数据需要持久化吗(写入到快照)?

可以不持久化,也就是说,Server对SessionId的记录是可以丢失的,下面是我的理解:

首先考虑单Server的情况: 当Server崩溃重启时,会读取log并重放,因此Client与Server通信会出现以下几种情况:

  1. Client无法与Server通信,则Client将当前SessionId作废。
  2. Client发送请求给Server,Server重放日志后,Server仍然有SessionId的记录,那么Server是可以处理Client的请求的(就好像Server没有崩溃一样)。
  3. 否则此时SessionId是无效的,Server会拒绝Client的请求,因此Client也会将当前SessionId作废。

即使持久化了,还是会出现上面三种情况(假如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调用可能出现以下情况:

  1. KVServer宕机或Client无法连接到KVServer时,RPC调用无响应
  2. KVServer不是Leader
  3. KVServer是Leader并提交了客户端的命令,但可能由于网络延迟等原因,导致命令在很长一段时间内都没有执行完成(也就是没有达成共识)。
  4. 客户端的请求执行成功

当有多个KVServer时,对于第一、二种情况来说,Client应尝试调用其他KVServer,只有调用过其他KVServer也无法找到Leader时,才应当认定服务器出现故障。 而对于第三种情况来说,客户端的请求可能会执行也可能不会执行;对客户端而言,命令没有达成共识和网络延迟是一样的情况,请求是否执行对于客户端来说也是不确定的。

有哪些地方是可以改进的?

从整个系统层面来看,raft和上层的service就是一个Producer-Consumer关系。raft负责对日志进行一致性复制,并生产committed log给service;service负责消费committed log并执行相应的操作。 另外,raft还负责持久化log和snapshot,某种层面上说,raft也相当于一个存储引擎。因此优化可以分别从两方面入手:生产消息和持久化消息

raft层
  1. commit log效率低下:根据raft共识算法,log从提交到commit至少需要两轮RPC。如果依赖于leader是100ms发送一次AppendEntriesRPC,可能需要花费200ms左右;而如果在收到新的log就启动一次 AppendEntriesRPC的话,当提交的log比较频繁时,RPC开销会很大。因此在RPC方面,可以考虑批量发送log(batch),比如在收到一定量的log后再启动RPC。某种程度上,类似于OS的缓冲区思想另外,如kafka等消息队列的生产者也做了这个优化系统之间的设计相通+1
  2. log内存复用:go语言的内存也是自动管理的,所以也会进行垃圾回收。因此在快照时截断log的操作可以对log的内存进行复用,而无需释放内存。
RPC层

可以把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的indexterm;根据Raft共识算法,index和term确定了log的唯一性。

  1. 为什么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
	}
}
  1. 从applyCh接收命令,根据命令类型执行相应的操作。
  2. 会判断对应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
	}
}

有两个问题:

  1. 超时时间不应该由KVServer来决定,而应该由Client来决定。
  2. 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
	}
}

你可能感兴趣的:(分布式,raft)