zookeeper(简称zk),顾名思义,为动物园管理员的意思,动物对应服务节点,zk是这些节点的管理者。在分布式场景中,zk的应用非常广泛,如:数据发布/订阅、命名服务、配置中心、分布式锁、集群管理、选主与服务发现等等。这不仅得益于zk类文件系统的数据模型和基于Watcher机制的分布式事件通知,也得益于zk特殊的高容错数据一致性协议。
这里的一致性,是指数据在多个副本之间保持一致的特性。分布式环境里,多个副本处于不同的节点上,如果对副本A的更新操作,未同步到副本B上,外界获取数据时,A与B的返回结果会不一样,这是典型的分布式数据不一致情况。而强一致性,是指分布式系统中,如果某个数据更新成功,则所有用户都能读取到最新的值。CAP定理告诉我们,在分布式系统设计中,P(分区容错性)是不可缺少的,因此只能在A(可用性)与C(一致性)间做取舍。本文主要探究zk在数据一致性方面的处理逻辑。
一、基本概念:
- 数据节点(dataNode):zk数据模型中的最小数据单元,数据模型是一棵树,由斜杠(/)分割的路径名唯一标识,数据节点可以存储数据内容及一系列属性信息,同时还可以挂载子节点,构成一个层次化的命名空间。
- 会话(Session):指zk客户端与zk服务器之间的会话,在zk中,会话是通过客户端和服务器之间的一个TCP长连接来实现的。通过这个长连接,客户端能够使用心跳检测与服务器保持有效的会话,也能向服务器发送请求并接收响应,还可接收服务器的Watcher事件通知。Session的sessionTimeout,是会话超时时间,如果这段时间内,客户端未与服务器发生任何沟通(心跳或请求),服务器端会清除该session数据,客户端的TCP长连接将不可用,这种情况下,客户端需要重新实例化一个Zookeeper对象。
- 事务及ZXID:事务是指能够改变Zookeeper服务器状态的操作,一般包括数据节点的创建与删除、数据节点内容更新和客户端会话创建与失效等操作。对于每个事务请求,zk都会为其分配一个全局唯一的事务ID,即ZXID,是一个64位的数字,高32位表示该事务发生的集群选举周期(集群每发生一次leader选举,值加1),低32位表示该事务在当前选择周期内的递增次序(leader每处理一个事务请求,值加1,发生一次leader选择,低32位要清0)。
- 事务日志:所有事务操作都是需要记录到日志文件中的,可通过 dataLogDir配置文件目录,文件是以写入的第一条事务zxid为后缀,方便后续的定位查找。zk会采取“磁盘空间预分配”的策略,来避免磁盘Seek频率,提升zk服务器对事务请求的影响能力。默认设置下,每次事务日志写入操作都会实时刷入磁盘,也可以设置成非实时(写到内存文件流,定时批量写入磁盘),但那样断电时会带来丢失数据的风险。
- 数据快照:数据快照是zk数据存储中另一个非常核心的运行机制。数据快照用来记录zk服务器上某一时刻的全量内存数据内容,并将其写入到指定的磁盘文件中,可通过dataDir配置文件目录。可配置参数snapCount,设置两次快照之间的事务操作个数,zk节点记录完事务日志时,会统计判断是否需要做数据快照(距离上次快照,事务操作次数等于snapCount/2~snapCount 中的某个值时,会触发快照生成操作,随机值是为了避免所有节点同时生成快照,导致集群影响缓慢)。
- 过半:所谓“过半”是指大于集群机器数量的一半,即大于或等于(n/2+1),此处的“集群机器数量”不包括observer角色节点。leader广播一个事务消息后,当收到半数以上的ack信息时,就认为集群中所有节点都收到了消息,然后leader就不需要再等待剩余节点的ack,直接广播commit消息,提交事务。选举中的投票提议及数据同步时,也是如此,leader不需要等到所有learner节点的反馈,只要收到过半的反馈就可进行下一步操作。
二、数据模型
zk维护的数据主要有:客户端的会话(session)状态及数据节点(dataNode)信息。zk在内存中构造了个DataTree的数据结构,维护着path到dataNode的映射以及dataNode间的树状层级关系。为了提高读取性能,集群中每个服务节点都是将数据全量存储在内存中。可见,zk最适于读多写少且轻量级数据(默认设置下单个dataNode限制为1MB大小)的应用场景。数据仅存储在内存是很不安全的,zk采用事务日志文件及快照文件的方案来落盘数据,保障数据在不丢失的情况下能快速恢复。
三、集群架构
zk集群由多个节点组成,其中有且仅有一个leader,处理所有事务请求;follower及observer统称learner。learner需要同步leader的数据。follower还参与选举及事务决策过程。zk客户端会打散配置文件中的serverAddress 顺序并随机组成新的list,然后循环按序取一个服务器地址进行连接,直到成功。follower及observer会将事务请求转交给leader处理。
要搭建一个高可用的zk集群,我们首先需要确定好集群规模。一般我们将节点(指leader及follower节点,不包括observer节点)个数设置为 2*n+1 ,n为可容忍宕机的个数。 zk使用“过半”设计原则,很好地解决了单点问题,提升了集群容灾能力。但是zk的集群伸缩不是很灵活,集群中所有机器ip及port都是事先配置在每个服务的zoo.cfg 文件里的。如果要往集群增加一个follower节点,首先需要更改所有机器的zoo.cfg,然后逐个重启。
集群模式下,单个zk服务节点启动时的工作流程大体如下:
- 统一由QuorumPeerMain作为启动类,加载解析zoo.cfg配置文件;
- 初始化核心类:ServerCnxnFactory(IO操作)、FileTxnSnapLog(事务日志及快照文件操作)、QuorumPeer实例(代表zk集群中的一台机器)、ZKDatabase(内存数据库)等;
- 加载本地快照文件及事务日志,恢复内存数据;
- 完成leader选举,节点间通过一系列投票,选举产生最合适的机器成为leader,同时其余机器成为follower或是observer。关于选举算法,就是集群中哪个机器处理的数据越新(通过ZXID来比较,ZXID越大,数据越新),其越有可能被选中;
- 完成leader与learner间的数据同步:集群中节点角色确定后,leader会重新加载本地快照及日志文件,以此作为基准数据,再结合各个learner的本地提交数据,leader再确定需要给具体learner回滚哪些数据及同步哪些数据;
- 当leader收到过半的learner完成数据同步的ACK,集群开始正常工作,可以接收并处理客户端请求,在此之前集群不可用。
四、zookeeper一致性协议
zookeeper实现数据一致性的核心是ZAB协议(Zookeeper原子消息广播协议)。该协议需要做到以下几点:
(1)集群在半数以下节点宕机的情况下,能正常对外提供服务;
(2)客户端的写请求全部转交给leader来处理,leader需确保写变更能实时同步给所有follower及observer;
(3)leader宕机或整个集群重启时,需要确保那些已经在leader服务器上提交的事务最终被所有服务器都提交,确保丢弃那些只在leader服务器上被提出的事务,并保证集群能快速恢复到故障前的状态。
Zab协议有两种模式, 崩溃恢复(选主+数据同步)和消息广播(事务操作)。任何时候都需要保证只有一个主进程负责进行事务操作,而如果主进程崩溃了,就需要迅速选举出一个新的主进程。主进程的选举机制与事务操作机制是紧密相关的。下面详细讲解这三个场景的协议规则,从细节去探索ZAB协议的数据一致性原理。
1、选主:leader选举是zk中最重要的技术之一,也是保证分布式数据一致性的关键所在。当集群中的一台服务器处于如下两种情况之一时,就会进入leader选举阶段——服务器初始化启动、服务器运行期间无法与leader保持连接。
选举阶段,集群间互传的消息称为投票,投票Vote主要包括二个维度的信息:ID、ZXID
- ID 被推举的leader的服务器ID,集群中的每个zk节点启动前就要配置好这个全局唯一的ID。
- ZXID 被推举的leader的事务ID ,该值是从机器DataTree内存中取的,即事务已经在机器上被commit过了。
节点进入选举阶段后的大体执行逻辑如下:
(1)设置状态为LOOKING,初始化内部投票Vote (id,zxid) 数据至内存,并将其广播到集群其它节点。节点首次投票都是选举自己作为leader,将自身的服务ID、处理的最近一个事务请求的ZXID(ZXID是从内存数据库里取的,即该节点最近一个完成commit的事务id)及当前状态广播出去。然后进入循环等待及处理其它节点的投票信息的流程中。
(2)循环等待流程中,节点每收到一个外部的Vote信息,都需要将其与自己内存Vote数据进行PK,规则为取ZXID大的,若ZXID相等,则取ID大的那个投票。若外部投票胜选,节点需要将该选票覆盖之前的内存Vote数据,并再次广播出去;同时还要统计是否有过半的赞同者与新的内存投票数据一致,无则继续循环等待新的投票,有则需要判断leader是否在赞同者之中,在则退出循环,选举结束,根据选举结果及各自角色切换状态,leader切换成LEADING、follower切换到FOLLOWING、observer切换到OBSERVING状态。
算法细节可参照FastLeaderElection.lookForLeader(),主要有三个线程在工作:选举线程(主动调用lookForLeader方法的线程,通过阻塞队列sendqueue及recvqueue与其它两个线程协作)、WorkerReceiver线程(选票接收器,不断获取其它服务器发来的选举消息,筛选后会保存到recvqueue队列中。zk服务器启动时,开始正常工作,不停止)以及WorkerSender线程(选票发送器,会不断地从sendqueue队列中获取待发送的选票,并广播至集群)。WorkerReceiver线程一直在工作,即使当前节点处于LEADING或者FOLLOWING状态,它起到了一个过滤的作用,当前节点为LOOKING时,才会将外部投票信息转交给选举线程处理;如果当前节点处于非LOOKING状态,收到了处于LOOKING状态的节点投票数据(外部节点重启或网络抖动情况下),说明发起投票的节点数据跟集群不一致,这时,当前节点需要向集群广播出最新的内存Vote(id,zxid),落后节点收到该Vote后,会及时注册到leader上,并完成数据同步,跟上集群节奏,提供正常服务。
2、选主后的数据同步:选主算法中的zxid是从内存数据库中取的最新事务id,事务操作是分两阶段的(提出阶段和提交阶段),leader生成提议并广播给followers,收到半数以上的ACK后,再广播commit消息,同时将事务操作应用到内存中。follower收到提议后先将事务写到本地事务日志,然后反馈ACK,等接到leader的commit消息时,才会将事务操作应用到内存中。可见,选主只是选出了内存数据是最新的节点,仅仅靠这个是无法保证已经在leader服务器上提交的事务最终被所有服务器都提交。比如leader发起提议P1,并收到半数以上follower关于P1的ACK后,在广播commit消息之前宕机了,选举产生的新leader之前是follower,未收到关于P1的commit消息,内存中是没有P1的数据。而ZAB协议的设计是需要保证选主后,P1是需要应用到集群中的。这块的逻辑是通过选主后的数据同步来弥补。
选主后,节点需要切换状态,leader切换成LEADING状态后的流程如下:
(1)重新加载本地磁盘上的数据快照至内存,并从日志文件中取出快照之后的所有事务操作,逐条应用至内存,并添加到已提交事务缓存commitedProposals。这样能保证日志文件中的事务操作,必定会应用到leader的内存数据库中。
(2)获取learner发送的FOLLOWERINFO/OBSERVERINFO信息,并与自身commitedProposals比对,确定采用哪种同步方式,不同的learner可能采用不同同步方式(DIFF同步、TRUNC+DIFF同步、SNAP同步)。这里是拿learner内存中的zxid与leader内存中的commitedProposals(min、max)比对,如果zxid介于min与max之间,但又不存在于commitedProposals中时,说明该zxid对应的事务需要TRUNC回滚;如果 zxid 介于min与max之间且存在于commitedProposals中,则leader需要将zxid+1~max 间所有事务同步给learner,这些内存缺失数据,很可能是因为leader切换过程中造成commit消息丢失,learner只完成了事务日志写入,未完成提交事务,未应用到内存。
(3)leader主动向所有learner发送同步数据消息,每个learner有自己的发送队列,互不干扰。同步结束时,leader会向learner发送NEWLEADER指令,同时learner会反馈一个ACK。当leader接收到来自learner的ACK消息后,就认为当前learner已经完成了数据同步,同时进入“过半策略”等待阶段。当leader统计到收到了一半已上的ACK时,会向所有已经完成数据同步的learner发送一个UPTODATE指令,用来通知learner集群已经完成了数据同步,可以对外服务了。
细节可参照Leader.lead() 、Follower.followLeader()及LearnerHandler类。
3、事务操作:ZAB协议对于事务操作的处理是一个类似于二阶段提交过程。针对客户端的事务请求,leader服务器会为其生成对应的事务proposal,并将其发送给集群中所有follower机器,然后收集各自的选票,最后进行事务提交。流程如下图。
ZAB协议的二阶段提交过程中,移除了中断逻辑(事务回滚),所有follower服务器要么正常反馈leader提出的事务proposal,要么就抛弃leader服务器。follower收到proposal后的处理很简单,将该proposal写入到事务日志,然后立马反馈ACK给leader,也就是说如果不是网络、内存或磁盘等问题,follower肯定会写入成功,并正常反馈ACK。leader收到过半follower的ACK后,会广播commit消息给所有learner,并将事务应用到内存;learner收到commit消息后会将事务应用到内存。
ZAB协议中多次用到“过半”设计策略 ,该策略是zk在A(可用性)与C(一致性)间做的取舍,也是zk具有高容错特性的本质。相较分布式事务中的2PC(二阶段提交协议)的“全量通过”,ZAB协议可用性更高(牺牲了部分一致性),能在集群半数以下服务宕机时正常对外提供服务。
五、参考:
《从paxos到Zookeeper分布式一致性原理与实践》、org.apache.zookeeper:zookeeper:3.4.0