一、前言
大家好,我是你们的Boom弟弟,天不怕,地不怕,就怕你不进来。
本篇文章记录了ZK的学习过程,ZK真的是一款挺强大的中间件,它是JAVA开发的,多级队列的设计简直绝绝子,学源码学思想。不过还是要吐槽一下,ZK的源码里面的命名是真的....我就不说了,相比Spring源码,真是难多了。
二、ZK简介
在了解Zookeeper之前,需要对分布式相关知识有一定了解,那么什么是分布式系统呢?
通常情况下,单个物理节点很容易达到性能,计算或者容量的瓶颈,所以这个时候就需要多个物理节点来共同完成某项任务,一个分布式系统的本质是分布在不同网络或计算机上的程序组件,彼此通过信息传递来协同工作的系统,而Zookeeper正是一个分布式应用协调框架,在分布式系统架构中有广泛的应用场景。
什么是Zookeeper?
官方文档上这么解释zookeeper,它是一个分布式协调框架,是Apache Hadoop生态的一个子项目,它主要是用来解决分布式应用中经常遇到的一些数据管理问题,如:统一命名服务、状态同步服务、集群管理、分布式应用配置项的管理等。
Zookeeper核心概念
文件系统数据结构+监听通知机制。
1. 文件系统数据结构
Zookeeper维护一个类似文件系统的数据结构:
每个子目录项都被称作为Znode(目录节点),和文件系统类似,我们能够自由的增加、删除znode,在一个znode下增加、删除子znode。
有四种类型的znode:
1、PERSISTENT持久化结点: 目录节点客户端与zookeeper断开连接后,该节点依旧存在,只要不手动删除该节点,他将永远存在。
2、PERSISTENT_SEQUENTIAL持久化顺序节点: 客户端与zookeeper断开连接后,该节点依旧存在,只是Zookeeper给该节点名称进行顺序编号。
3、EPHEMERAL临时节点: 客户端与zookeeper断开连接后,该节点被删除。且临时结点不能创建子结点。
4、EPHEMERAL_SEQUENTIAL临时顺序节点: 客户端与zookeeper断开连接后,该节点被删除,只是Zookeeper给该节点名称进行顺序编号。
5、 Container节点(3.5.3版本新增,如果Container节点下面没有子节点,则Container节点在未来会被Zookeeper自动清除,定时任务默认60s检查一次)。
6、 TTL节点(默认禁用,只能通过系统配置zookeeper.extendedTypesEnabled=true开启,不稳定)。
2. 监听通知机制
客户端可以注册监听它关心的任意节点,包括目录节点以及递归子目录节点
-
如果注册的是对某个节点的监听,则当这个节点被删除,或者被修改时,对应的客户端将被通知
-
如果注册的是对某个目录的监听,则当这个目录有子节点被创建,或者有子节点被删除,对应的客户端将被通知
-
如果注册的是对某个目录的递归子节点进行监听,则当这个目录下面的任意子节点有目录结构的变化(有子节点被创建,或被删除)或者根节点有数据变化时,对应的客户端将被通知。
注意:所有的通知都是一次性的,及无论是对节点还是对目录进行的监听,一旦触发,对应的监听即被移除。递归子节点,监听是对所有子节点的,所以,每个子节点下面的事件同样只会被触发一次。
Zookeeper的应用场景
大概说一下思路
-
分布式队列:通过ZK的持久化循序结点即可实现。
-
集群选举: 通过ZK的Leader选举功能即可实现。
-
发布订阅: 即ZK的注册监听功能。
-
分布式配置中心、注册中心
- 创建一个PERSISTENT持久化结点,
create /config/项目名 配置文件的JSON格式
- 客户端监听该结点,
get -w /config/项目名
,当该结点数据发生变化时,也就意味着配置文件JSON发生了变化,客户端能第一时间感知到。由于监听是一次性的,循环监听即可。
- 创建一个PERSISTENT持久化结点,
-
分布式锁(这里重点说一下)
ZK的分布式锁可以分为公平锁和非公平锁
非公平锁:
- 请求进来,创建一个临时结点/lock,
create -s /lock
, 如果该结点存在,ZK服务端会告知你该结点已经存在,不能重复创建,此时监听该结点,get -w /lock/
- 获得锁的请求,处理完后释放锁,即
delete /lock/
当前获取锁的结点,此时,会通知监听该结点的所有连接,在高并发的情况下,相当是一个灾难。
如上实现方式在并发问题比较严重的情况下,性能会下降的比较厉害,主要原因是,所有的连接都在对同一个节点进行监听,当服务器检测到删除事件时,要通知所有的连接,所有的连接同时收到事件,再次并发竞争,这就是羊群效应。如何避免呢,我们看下面这种方式。
公平锁:
- 请求进来,直接在/lock结点下创建一个临时顺序结点。
- 判断自己是不是/lock结点下最小的结点。如果是最小的,即获得锁。如果不是,对前面一个结点进行监听
get -w /lock/
前面一个结点 - 获得锁的请求,处理完后释放锁,即
delete /lock/
当前获取锁的结点,然后它后面的一个结点会收到通知,重复第2步判断。
- 请求进来,创建一个临时结点/lock,
四、ZK和Redis两种分布式锁对比
Redis分布式锁
1. setnx + lua脚本
优点:redis基于内存,读写性能很高,因此基于redis的分布式锁效率比较高
缺点:分布式环境下可能会有节点数据同步问题,可靠性有一定的影响。比如现在有一个3主3丛的Redis集群, 客户端发生的命令写入了机器1的master
节点,数据正准备主丛同步的时候,master
结点挂了,slave
结点没有接收到最新的数据,此时 slave
结点竞选为master
, 导致之前加的分布式锁失效。
2. Redission
优点:解决了Redis集群的同步可用性问题
缺点:网上是说:发布时间短,稳定性和可靠性有待验证。个人觉得,目前市面上已经稳定了,算是比较成熟的比较完善的分布式锁了。
ZK分布式锁
优点:不存在redis的数据同步(zookeeper是同步完以后才返回)、主从切换(zookeeper主从切换的过程中服务是不可用的)的问题,可靠性很高
缺点:保证了可靠性的同时牺牲了一部分效率(但是依然很高)。性能不如redis。
五、ZK集群的Leader选举
Zookeeper集群模式一共有3种类型的角色
- Leader: 处理所有的事务请求(写请求),可以处理读请求,集群中只能有一个Leader。
- Follower:只能处理读请求,同时作为Leader的候选节点,即如果Leader宕机,Follower节点要参与到新的Leader选举中,有可能成为新的Leader节点。
- Observer:只能处理读请求。不能参与选举。
对于来自客户端的每个更新请求,ZooKeeper 都会分配一个全局唯一的递增编号。这个编号反映了所有事务操作的先后顺序,这个编号就是Zxid(ZooKeeper Transaction Id)。
Leader选举流程
- ZK内部采用一种快速选举算法,主要取决于两个因素(myid,Zxid), myid是配置文件中维护的集群序列号,Zxid为ZK的事务id。
- 假设现在启动两台ZK集群,对应的myid分别为1和2,那么在启动的过程中,就会发生Leader的选举,选举过程如下
- 由于项目刚启动,是没有任何事务的。所以,myid=1的机器,投出去的票为(1,0),收到的票是(2,0),将收到的票跟自己投出去的票对比,优先原则Zxid大的为Leader,因为Zxid越大则说明数据越新。如果Zxid一样,默认选择myid大的为Leader, 则推荐(2,0)成为Leader。
- myid=2的机器,投出去的票为(2,0), 收到的票是(1,0),按照上面的规则,选择(2,0)作为Leader。
- 由于ZK的过半原则,当达到(集群个数/2+1)台机器选择同一个机器作为Leader时,改机器会从Looking状态切换到Leader状态,而其他的机器则会切换到Follower的状态。
为什么推荐ZK集群个数为奇数个?
- 假设集群数量为4个,有一台Leader挂了,还剩3台Follower, 由于Leader选举的过半原则,需要(3/2+1)=2台Follower投票同一台机器才能成为Leader。
- 假设集群数量为3个,有一台Leader挂了,还剩2台Follower, 由于Leader选举的过半原则,需要(2/2+1)=2台Follower投票同一台机器才能成为Leader。
- 既然3台机器和4台机器的集群在Leader挂掉之后,都需要2台Follower投票同一台机器才能成为Leader,那为什么不节省一台机器的成本呢?
所以,总得来说,就是为了节省成本。
Leader选举多层队列架构
整个ZK选举底层可以分为选举应用层和消息传输层
PS: 这里可能会看得有点晕, 先建立一个概念,ZK在应用层和传输层都维护了队列和线程,我们下面会以应用层队列 和 传输层队列,应用层线程 和 传输层线程 区分开来。
- 应用层设计了一个统一发送投票的队列(应用层SendQueue队列)和接受投票的队列(应用层ReceiveQueue队列), 然后开启了一个应用层WorkerSender线程去扫描应用层SendQueue队列,开启一个应用层WorkerReceiver线程扫描传输层的传输层ReceiveQueue队列
- 传输层对ZK集群中除当前机器以外的机器都维护了一个发送队列,即每台机器对应一个发送队列,同时每个发送队列对应一个发送线程,这些线程不断的扫描属于自己的队列。
- 由于ZK集群的每台机器已经建立了Soket长链接,所以当发送线程扫描到新的投票消息时,会通过Socket发送给对应机器的传输层ReceiveQueue线程,然后传输层ReceiveQueue线程会把消息转存到统一的传输层ReceiveQueue队列中,当应用层WorkerReceiver线程扫描到传输层ReceiveQueue队列中的消息时,会把消息转发到应用层ReceiveQueue队列
- 最后统计投票,选举Leader。
宝,是否有点疑问,ZK为什么要这么做呢?
- 异步提升性能。
- 按发送的机器分了队列,避免给每台机器发送消息时相互影响,比如某台机器如果出问题发送不成功则不会影响对正常机器的消息发送。
五、ZK的脑裂问题
脑裂通常会出现在集群环境中,比如ElasticSearch、Zookeeper集群,而这些集群环境有一个统一的特点,就是它们有一个大脑,比如ElasticSearch集群中有Master节点,Zookeeper集群中有Leader节点。
什么是脑裂?
简单点来说,在正常的ZK集群中,只会有一个Leader, 而这个Leader就是整个集群的大脑,脑裂,顾名思义,大脑分裂,即产生了多个Leader。
ZK中脑裂的场景说明
对于一个集群,想要提高这个集群的可用性,通常会采用多机房部署,比如现在有一个由6台 ZK 所组成的一个集群,部署在了两个机房:
正常情况下,此集群只会有一个Leader,那么如果机房之间的网络断了之后,两个机房内的 ZK 还是可以相互通信的,如果不考虑过半机制,那么就会出现每个机房内部都将选出一个Leader。
这就相当于原本一个集群,被分成了两个集群,出现了两个"大脑",这就是所谓的"脑裂"现象。对于这种情况,其实也可以看出来,原本应该是统一的一个集群对外提供服务的,现在变成了两个集群同时对外提供服务,如果过了一会,断了的网络突然联通了,那么此时就会出现问题了,两个集群刚刚都对外提供服务了,数据该怎么合并,数据冲突怎么解决等等问题。
刚刚在说明脑裂场景时有一个前提条件就是没有考虑过半机制,所以实际上Zookeeper集群中是不会轻易出现脑裂问题的,原因在于过半机制。
ZK过半机制为什么是大于,而不是大于等于?
这就跟脑裂问题有关系了,比如回到上文出现脑裂问题的场景 :当机房中间的网络断掉之后,机房1内的三台服务器会进行领导者选举,但是此时过半机制的条件是 "节点数 > 3",也就是说至少要4台zkServer才能选出来一个Leader,所以对于机房1来说它不能选出一个Leader,同样机房2也不能选出一个Leader,这种情况下整个集群当机房间的网络断掉后,整个集群将没有Leader。而如果过半机制的条件是 "节点数 >= 3",那么机房1和机房2都会选出一个Leader,这样就出现了脑裂。这就可以解释为什么过半机制中是大于而不是大于等于,目的就是为了防止脑裂。
如果假设我们现在只有5台机器,也部署在两个机房:
此时过半机制的条件是 "节点数 > 2",也就是至少要3台服务器才能选出一个Leader,此时机房件的网络断开了,对于机房1来说是没有影响的,Leader依然还是Leader,对于机房2来说是选不出来Leader的,此时整个集群中只有一个Leader。因此总结得出,有了过半机制,对于一个 ZK 集群来说,要么没有Leader,要么只有1个Leader,这样 ZK 也就能避免了脑裂问题。
六、大名鼎鼎的ZAB协议
什么是ZAB协议?
ZAB 协议是为 ZK 专门设计的一种支持崩溃恢复的一致性协议。基于该协议, ZK 实现了一种主从模式的系统架构来保持集群中各个副本之间的数据一致性。
在分布式系统中一般都要使用主从系统架构模型,指的是一台Leader服务器负责外部客户端的写请求。然后其他的都是Follower服务器。Leader服务器将客户端的写操作数据同步到所有的Follower节点中。
像这样,客户端发送来的写请求,全部给Leader,Leader写完数据之后,再转给Follower。这时候需要解决两个问题:
- leader服务器是如何把数据更新到所有的Follower的。
- Leader服务器突然间失效了,怎么办?
ZAB协议为了解决上面两个问题,设计了两种模式:
-
消息广播模式:把数据更新到所有的Follower
-
崩溃恢复模式:Leader发生崩溃时,如何恢复
消息广播模式
ZAB协议的消息广播过程使用的是一个原子广播协议,类似一个二阶段提交过程。对于客户端发送的写请求,全部由Leader接收,Leader将请求封装成一个事务Proposal,将其发送给所有Follwer,然后,根据所有Follwer的反馈,如果超过半数成功响应,则执行commit操作。
- Leader将客户端的Request转化成一个Proposal
- Leader为每一个Follower和Observer准备了一个FIFO队列,并把Proposal发送到队列上。
- Follower和Observer取出队头的Proposal, 并返回ACK给Leader。
- Leader若收到半数以上的ACK反馈, Leader执行commit操作,并向所有的Follower和Observer发送commit。
- Follower和Observer执行commit操作。
这就是整个消息广播模式。下面我们开始看一下,如果消息广播过程中,这个Leader节点崩溃了,怎么办?还能保证数据一致吗?如果Leader先本地提交了,然后commit请求没有发送出去,怎么办?也就是第二种模式:崩溃回复模式。
崩溃恢复模式
崩溃:即 Leader失去与过半Follwer的联系,那么又要开始新一轮的选举,首先选出一个新的Leader。
既然要恢复,有些场景是不能恢复的,ZAB协议崩溃恢复要求满足如下2个要求:
- 确保丢弃那些只在Leader提交,但没有在 Follower 提交的Proposal。
- 确保那些已经在Leader提交的事务最终会被所有的 Follower 提交。
好了,现在开始进行恢复。
- 选取的新Leader拥有最大的Zxid,代表当前的事务是最新的。
- 新Leader把这个事件Proposal提交给其他的Follower节点
- Follower节点会根据Leader的消息进行回退或者是数据同步操作。最终目的要保证集群中所有节点的数据副本保持一致。
这就是整个恢复的过程,其实就是相当于有个日志一样的东西,记录每一次操作,然后把出事前的最新操作恢复,然后进行同步即可。