You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
可能大多数人都有个误解,一个分布式系统,比如ZK、etcd等,如果正确的实现了共识算法(Raft/Paxos),那么它就能够提供强一致,这个理解其实是不正确的,Raft只能够保证不同节点对于raft日志达成一致,但对于库的使用者来说,实际上对外提供服务的是底层的状态机,比如说一个KV存储,每个raft日志记录的是实际的操作,比如set a 1 set a 2等,而如果只有日志的话,我们怎么查询呢?轮训一遍日志?显然不现实,因此必须将这个状态存起来,比如RocksDB,那么底层raft日志的一致性由raft本身去保证,而上层业务方状态机的一致性该如何去保证呢?
背景
当我们讨论分布式系统时,通常会说到CAP理论,而这里的C一致性一般来说指的就是线性一致性(Linearizability),而对于开发者来说,可能更多的会去关注自己平常使用的一些中间件、类库,比如Etcd、Zookeeper等能够提供怎么的一致性,这两个类库我们知道是在Raft、Paxos(ZAB)的基础之上实现的,因此这篇文章我们就来看一下如果线性一致性实际是如何实现的。
实现
可能大多数人都有个误解,一个分布式系统,比如ZK、etcd等,如果正确的实现了共识算法(Raft/Paxos),那么它就能够提供强一致,这个理解其实是不正确的,Raft只能够保证不同节点对于raft日志达成一致,但对于库的使用者来说,实际上对外提供服务的是底层的状态机,比如说一个KV存储,每个raft日志记录的是实际的操作,比如
set a 1 set a 2
等,而如果只有日志的话,我们怎么查询呢?轮训一遍日志?显然不现实,因此必须将这个状态存起来,比如RocksDB,那么底层raft日志的一致性由raft本身去保证,而上层业务方状态机的一致性该如何去保证呢?Raft是由leader驱动的共识算法,所有的写入请求都由leader来处理,并将日志同步到follower,然后再将日志依次应用的自身的状态机,比如RocksDB,但由于网络延迟、机器负载等原因,每个节点不可能同时将日志应用到RocksDB,因此对于不同的节点来说,RocksDB的数据快照肯定不是实时一致的,并且这里会涉及到很多的corner case,比如leader切换,也就是说leader的数据也不一定是最新的,因此实际实现的时候需要考虑好这些case。
这里暂不考虑异常情况,对于raft来说,写请求都是由leader处理并同步到follower,因此leader的数据通常是最新的,但如果用户发来一个读取请求,我们直接从状态机读取的话,这里其实是会读到过期数据的,因此这里分为两步,已提交的日志 -> 已经应用到状态机的日志,因此如果不做特殊处理的话,由于还有部分日志没有应用到状态机,直接处理的话必然会造成不一致。优化的方法大致有几种:
读取也走raft log
我们很容易想到,让读取的请求也走一遍raft流程,由于raft日志是全局严格有序的,读写也必然是有序的,因此当处理到读取的日志的时候,能够保证之前的写入请求都已经处理完成并落到状态机,因此这个时候处理读取请求是安全的,不会造成过期读的问题,也能够满足我们说的线性一致性,但这个方法有个很明显的缺点: 性能非常低,每次读取都走一遍raft流程,涉及到网络、磁盘IO等资源,而对于大部分场景来说,都是写少读多,因此如果不对读取进行优化的话,整个类库的性能会非常低效。
Read Index
Read index读的流程这里先简单说一下,当leader接收到一个读取请求时:
将当前日志的commit index记录到一个本地变量readIndex中,封装到消息体中
首先需要确认自己是否仍然是leader,因此需要向其他节点都发起一次心跳
如果收到了大多数节点的心跳响应,那么说明该server仍然是leader身份
在状态机执行的地方,判断下apply index是否超过了readIndex,如果超过了,那么就表明发起该读取请求时,所有之前的日志都已经处理完成,也就是说能够满足线性一致性读的要求
从状态机去读取结果,返回给客户端
我们可以看到,和刚刚的方法相比,read index采用心跳的方式首先确认自己仍然是leader,然后等待状态机执行到了发起读取时所有日志,就可以安全的处理客户端请求了,这里虽然还有一次心跳的网络开销,但一方面心跳包本身非常小,另外处理心跳的逻辑非常简单,比如不需要日志落盘等,因此性能相对之前的方法会高非常多。
Follower向leader查询最新的readIndex
leader会按照上面说的,走一遍流程,但会在确认了自己leader的身份之后,直接将readIndex返回给follower
Follower等待自己的状态机执行到了readIndex的位置之后,就可以安全的处理客户端的读请求
接下来我们看看sofa-jraft是如何实现Read Index的
通过
Node#readIndex(byte [] requestContext, ReadIndexClosure done)
发起一次线性一致性读请求:典型的生产者、消费者模型,底层队列采用disruptor的
RingBuffer
, 这里主要是会合并ReadIndex请求,这里提下性能优化非常常用的一个手段:batch合并:可以看到,实际处理ReadIndex请求的是
Node#handleReadIndexRequest
, 这里注意,会在上层进行合并,另外这里传入了一个ReadIndexResponseClosure
回调,这个回调会在节点确认了自己leader的身份之后执行在确认了leader的身份之后,需要等待状态机执行到readIndex:
Lease Read
虽然 ReadIndex Read 比第一种方法快很多,但我们发现还是有一次心跳包的网络开销,raft论文里提到了一种更激进的方式,也就是leader其实都存在一个'在为期', 也就是说leader会向follower发送心跳包,当收到大多数节点的回复后即确认了leader身份,并记录下时间戳t1,如果读过raft论文我们会知道,follower会在Election Timeout超时后,才会认为leader挂掉,并发起一次新的选举,也就是说在下一次leader选举肯定发生在t1 + Election Timeout +/- 时钟偏移量之后,因此第二种方法的中,通过发送心跳包确认leader身份这一步就可以省略掉了,在Election Timeout内只需要确认一次即可,相对一种方式,不仅仅不需要写raft日志,连心跳包都省略掉了,可想而知,性能会大大提升,但这里存在一个潜在的问题是,服务器的时间可能会偏移,存在一定的误差,如果偏移过大的话,这种机制就不够安全。
Zookeeper的一致性保证
Zookeeper是基于ZAB(类Paxos)协议实现的,我们可能常常有一个误解,就是ZK能够提供强一致,但其实默认情况下ZK并不能提供全局一致的数据视图,或者说线性一致性,比如说有两个客户端A和B,如果客户端将一个节点a的值从0改成1,并告诉客户端B去读取a,由于客户端连接的zk服务器不同,可能读取到0,也可能读到1。但如果你需要更强的一致性,期望让A和B读取到同样的值,可以通过执行
Zookeeper#sync()
方法,并在回调中读取a的值。也就是说默认情况下,zk并不能保证线性一致性读,也就是由于网络延迟、gc等原因,不同的节点接受到日志的时间不一致、应用到本地内存数据库的时间也有差异,但对于读取请求来说,每一个zk服务器都会对外提供服务,即使是follower也是一样,也就是说follower有一层本地缓存,这也是zk读取性能很高的一个原因,如果需要更高的读取性能,我们可以增加更多的follower节点,均衡读取的压力,但要注意增加更多的节点,也意味着写请求需要同步到更多的节点,也就是写入的性能会下降,这个需要业务方自己去权衡。但如果业务方有线性一致性读的要求,可以通过sync调用,基本原理其实和read index差不多,来客户端连接的zk服务器本地的内存数据库追上最新的日志。
和读取不同,写入请求(
set/delete
)是满足线性一致性写的,也就是写入必须要走一次ZAB流程,但读取,由于每个客户端连接的zk服务节点不一致,而每个zk服务节点都会对外提供写服务,才会导致可能读到过期值。总结
本文围绕raft以及zk的线性一致性实现细节以及相关的原理, 这里其实还涉及到很多的细节,后面会在单独的博客中补充。
The text was updated successfully, but these errors were encountered: