什么是ZooKeeper?
Zookeeper是一个分布式协调服务的开源框架。主要用来解决分布式集群中应用系统的一致性问题,例如怎样避免同时操作同一数据造成脏读的问题。
可以用ZooKeeper来做:
- 统一配置管理
- 统一命名服务
- 分布式锁
- 集群管理
使用分布式系统就无法避免对节点管理的问题(需要实时感知节点的状态、对节点进行统一管理等等),而由于这些问题处理起来可能相对麻烦和提高了系统的复杂性,ZooKeeper作为一个能够通用解决这些问题的中间件就应运而生了。
ZooKeeper的数据结构
ZooKeeper本质上是一个分布式的小文件存储系统。提供基于类似于文件系统的目录树方式的数据存储,并且可以对树中的节点进行有效管理。从而用来维护和监控你存储的数据的状态变化。通过监控这些数据状态的变化,从而可以达到基于数据的集群管理。
ZooKeeper nodes store their data in a hierarchical name space, much like a file system or a tree data structure
ZooKeeper的数据结构,跟Unix文件系统非常类似,可以看做是一颗树,每个节点叫做ZNode。每一个节点可以通过路径来标识,结构图如下:(其实就是字典树)
ZooKeeper的节点我们称之为Znode,Znode分为两种类型:
- **短暂/临时(Ephemeral)**:当客户端和服务端断开连接后,所创建的Znode(节点)会自动删除
- **持久(Persistent)**:当客户端和服务端断开连接后,所创建的Znode(节点)不会删除
ZooKeeper和Redis一样,也是C/S结构:
监听器
ZooKeeper常见的监听场景有以下两项:
- 监听Znode节点的数据变化
- 监听子节点的增减变化
ZooKeeper工作机制
Zookeeper从设计模式角度来理解:是一个基于观察者模式设计的分布式服务管理框架。
它负责存储和管理大家都关心的数据,然后接受观察者的注册,一旦这些数据的状态发生变化,Zookeeper就将负责通知已经在 Zookeeper上注册的那些观察者做出相应的反应。
统一配置管理
比如我们现在有三个系统A、B、C,他们有三份配置,分别是ASystem.yml、BSystem.yml、CSystem.yml
,然后,这三份配置又非常类似,很多的配置项几乎都一样。
此时,如果我们要改变其中一份配置项的信息,很可能其他两份都要改。并且,改变了配置项的信息很可能就要重启系统
于是,我们希望把ASystem.yml、BSystem.yml、CSystem.yml
相同的配置项抽取出来成一份公用的配置common.yml
,系统A、B、C都使用着这份配置,并且即便common.yml
改了,也不需要系统A、B、C重启。
做法:我们可以将common.yml
这份配置放在ZooKeeper的Znode节点中,系统A、B、C监听着这个Znode节点有无变更,如果变更了,及时响应。
统一命名服务
统一命名服务的理解其实跟域名一样,是我们为这某一部分的资源给它取一个名字,别人通过这个名字就可以拿到对应的资源。
通过名称去访问旗下的IP。比如说,现在我有一个域名www.java3y.com
,但我这个域名下有多台机器:
- 192.168.1.1
- 192.168.1.2
- 192.168.1.3
- 192.168.1.4
别人访问www.java3y.com
即可访问到我的机器,而不是通过IP去访问。
分布式锁
系统A、B、C都去访问/locks
节点
访问的时候会创建带顺序号的临时/短暂(EPHEMERAL_SEQUENTIAL
)节点,比如,系统A创建了id_000000
节点,系统B创建了id_000002
节点,系统C创建了id_000001
节点。
接着,拿到/locks
节点下的所有子节点(id_000000,id_000001,id_000002),判断自己创建的是不是最小的那个节点
如果是,则拿到锁。释放锁:执行完操作后,把创建的节点给删掉
如果不是,则监听比自己要小1的节点变化
举个例子:
- 系统A拿到
/locks
节点下的所有子节点,经过比较,发现自己(id_000000
),是所有子节点最小的。所以得到锁 - 系统B拿到
/locks
节点下的所有子节点,经过比较,发现自己(id_000002
),不是所有子节点最小的。所以监听比自己小1的节点id_000001
的状态 - 系统C拿到
/locks
节点下的所有子节点,经过比较,发现自己(id_000001
),不是所有子节点最小的。所以监听比自己小1的节点id_000000
的状态 - ……
- 等到系统A执行完操作以后,将自己创建的节点删除(
id_000000
)。通过监听,系统C发现id_000000
节点已经删除了,发现自己已经是最小的节点了,于是顺利拿到锁 - ….系统B如上
集群状态
以三个系统A、B、C为例,在ZooKeeper中各维护一个临时节点:
只要系统A挂了,那/groupMember/A
这个节点就会删除,通过监听groupMember
下的子节点,系统B和C就能够感知到系统A已经挂了。(新增也是同理)
除了能够感知节点的上下线变化,ZooKeeper还可以实现动态选举Master的功能(如果集群是主从架构模式下)。原理也很简单,如果想要实现动态选举Master的功能,Znode节点的类型是带顺序号的临时节点(EPHEMERAL_SEQUENTIAL
)就好了。Zookeeper会每次选举最小编号的作为Master,如果Master挂了,自然对应的Znode节点就会删除。然后让新的最小编号作为Master,这样就可以实现动态选举的功能了。
ZooKeeper集群角色
Leader
:
- Zookeeper集群工作的核心
- 事务请求(写操作)的唯一调度和处理者,保证集群事务处理的顺序性
- 集群内部各个服务器的调度者。
对于create,setData,delete等有写操作的请求,则需要统一转发给leader处理,leader需要决定编号、执行操作,这个过程称为一个事务
Follower
:
- 处理客户端非事务(读操作)请求,转发事务请求给 Leader
- 参与集群 Leader选举投票。
Observer
:针对访问量比较大的zookeeper集群,还可新增观察者角色。
- 观察者角色,观察zookeeper集群的最新状态变化并将这些状态同步过来,其对于非事务请求可以进行独立处理,对于事务请求,则会转发给Leader服务器进行处理
- 不会参与任何形式的投票,只提供非事务服务,通常用于在不影响集群事务处理能力的前提下提升集群的非事务处理能力。
ZooKeepe特点
- 一个领导者,多个跟随者组成的集群
- 集群中只要有半数以上节点存活,Zookeeper集群就能正常服务。
全局数据一致
: 每个 server 都保存相同的数据副本,client无论连接到哪个server,展示的数据都是一致的,这是最重要的特征可靠性
:如果消息被其中一台服务器接受,那么将被所有的服务器接受顺序性
:包括全局有序和偏序两种:- 全局有序:是指如果在一台服务器上消息a在消息b前发布,则在所有Server 上消息a都将在消息b前被发布
- 偏序:是指如果一个消息b在消息a后被同一个发送者发布,a必将排在b前面。
数据更新原子性
:一次数据更新要么成功(半数以上节点成功),要么失败,不存在中间状态实时性
:Zookeeper 保证客户端将在一个时间间隔范围内获得服务器的更新信息,或者服务器失效的信息。
监听器原理
- 首先要有一个main()线程
- 在main线程中创建Zookeeper客户端,这时就会创建两个线程,一个负责网络连接通信(connet),一个负责监听(listener)
- 通过connect线程将注册的监听事件发送给Zookeeper。
- 在 Zookeeper的注册监听列表中将注册的监听事件添到列表中。
- Zookeeper监听到有数据或路径变化,就会将这个消息发送给listener线程
- listener线程内部调用了process()方法。
常见的监听
- 监听节点数据的变化
1 | get path [watch] |
- 监听子节点增减的变化
1 | ls pathh [watch] |
Zookeeper 写数据全流程
Zookeeper 深入数据模型
数据结构
Zookeeper树中的每个节点被称为Znode
。和文件系统的目录树一样,ZooKeeper树中的每个节点可以拥有子节点。但也有不同之处:
- Znode兼具文件和目录两种特点。既像文件一样维护着数据、元信息、ACL、时间戳等数据结构,又像目录一样可以作为路径标识的一部分,并可以具有子Znode。用户对 znode具有增、删、改、查等操作(权限允许的情况下)。
- znode具有原子性操作,读操作将获取与节点相关的所有数据,写操作也将替换掉节点的所有数据。另外,每一个节点都拥有自己的ACL(访问控制列表),这个列表规定了用户的权限,即限定了特定用户对目标节点可以执行的操作。
- znode存储数据大小有限制。ZooKeeper虽然可以关联一些数据,但并没有被设计为常规的数据库或者大数据存储,相反的是,它用来管理调度数据比如分布式应用中的配置文件信息、状态信息、汇集位置等等。这些数据的共同特性就是它们都是很小的数据,通常以KB为大小单位。ZooKeeper的服务器和客户端都被设计为严格检查并限制每个 Znode的数据大小至多1M,当时常规使用中应该远小于此值。
- Znode过路径引用,如同Uinx中的文件路径。路径必须是绝对的,因此他们必须由斜杠字符来开头。除此以外,他们必须是唯一的,也就是说每一个路径只有一个表示,因此这些路径不能改变。在 Zookeeper中,路径由Unicode字符串组成,并且有一些限制。字符串“/zookeeper”用以保存管理信息,比如关键配额信息。
每个Znode由3部分组成:
- stat:此为状态信息,描述该 Znode的版本,权限等信息
- data:与该 Znode关联的数据
- children:该Znode下的子节点
节点类型
Znode有两种,分别为
临时节点
和永久节点
, 节点的类型在创建时即被确定,并且不能改变。Znode
序列化
的特性:如果创建的时候开启此特性的话,该Znode的名字后面会自动追加一个不断增加的序列号。序列号对于此节点的父节点来说是唯一的,这样便会记录每个子节点创建的先后顺序。它的格式为“%10d”(10位数字,没有数值的数位用0补充,例如00000001
)
这样便会存在四种类型的 Znode节点,分别对应:
- PERSISTENT:永久节点
- EPHEMERAL:临时节点
- PERSISTENT_SEQUENTIAL:永久节点、序列化
- EPHEMERAL_SEQUENTIAL:临时节点、序列化
节点属性
每个Znode都包含了一系列的属性,通过命令get,可以获得节点的属性。
dataVersion:数据版本号,每次对节点进行set操作,dataversion的值都会增加1(即使设置的是相同的数据),可有效避免了数据更新时出现的先后顺序问题
cversion:子节点的版本号。当znode的子节点有变化时,cversion的值就会增加1
aclVersion:ACL的版本号
cZxid:Znode创建的事务id
mZxid:Znode被修改的事务id,即每次对znode的修改都会更新mZxid
- 对于zk来说,每次的变化都会产生一个唯一的事务id,zxid(Zookeeper Transaction Id)。
- 通过zxid,可以确定更新操作的先后顺序。例如,如果 zxid1小于zxid2,说明
zxid1操作先于zxid2发生, - zxid对于整个zk都是唯一的 , 即使操作的是不同的 znode
ctime:节点创建时的时间戳
mtime:节点最新一次更新发生时的时间戳
ephemeralOwner:如果该节点为临时节点,ephemeralOwner值表示与该节点绑定的 session id。如果不是,ephemeralOwner值为0。
在 client和 server通信之前,首先需要建立连接,该连接称为 session,连接建立后,如果发生连接超时、授权失败,或者显式关闭连接,连接便处于 CLOSED状态,此时 session结束。
配置参数
Zookeeper中的配置文件zoo.cfg中参数含义解读如下:
tickTime =2000:通信心跳数,Zookeeper服务器与客户端心跳时间,单位毫秒Zookeeper使用的基本时间,服务器之间或客户端与服务器之间维持心跳的时间间隔,也就是每个tickTime时间就会发送一个心跳,时间单位为毫秒。
它用于心跳机制,并且设置最小的session超时时间为两倍心跳时间。(session的最小超时时间是2*tickTime)
initLimit =10:LF初始通信时限
集群中的Follower跟随者服务器与Leader领导者服务器之间初始连接时能容忍的最多心跳数(tickTime的数量),用它来限定集群中的Zookeeper服务器连接到Leader的时限。
syncLimit =5:LF同步通信时限
集群中Leader与Follower之间的最大响应时间单位,假如响应超过syncLimit * tickTime,Leader认为Follwer死掉,从服务器列表中删除Follwer。
dataDir:数据文件目录+数据持久化路径
主要用于保存Zookeeper中的数据。
clientPort =2181:客户端连接端口
监听客户端连接的端口。
Zookeeper Shell
客户端连接
运行 zkCli.sh - server ip
进入命令行工具 , 输入help,输出 zk shell提示:
Shell 基本操作
创建节点
1 | create [-s] [-e] path data acl |
- -s : 顺序(默认非序列化)
- -e : 临时节点(默认永久)
- acl : 用户权限控制
1 | # 创建顺序节点 |
读取节点
相关命令 : ls
和 get
- ls : 列出指定节点下面的所有第一级子节点 ,
- get : 获取指定节点的数据内容和属性信息
- ls2 : 比ls显示更全面的信息
1 | ls path [watch] |
1 | ls /heyingliang |
更新节点
1 | set path data [version] |
- data : 要更新的新内容
- version : 数据版本 , 必须和data的数据版本一致,否则不允许更新
1 | # 将/heyingliang的数值修改为123 |
删除节点
1 | delete path [version] |
若删除的节点拥有子节点 , 那么将无法删除 , 必须先删除子节点才能删除这个节点
quota
对节点增加限制
1 | setquota -n|-b val path |
- n : 节点的最大个数(子节点+自己)
- b : 数据值的最大长度
- val : 子节点最大个数 或 数据值的最大长度
- path : 节点路径
1 | setquota -n 2 /hyl |
注意 : 这种限制是
假的
, 就算超出了限制 , 也不会报错 , 只会在日志里写入一个WRANNIG日志
listquota:列出节点的quota
1 | listquota /hyl |
delquota:删除quota
1 | delquota [-n|-b] path |
history:列出历史命令
1 | history |
redo:重新执行指定编号的历史命令
1 | # 编号可以通过history命令查看 |
Zookeeper Watcher
- ZooKeeper提供了分布式数据发布/订阅功能,一个典型的发布/订阅模型系统定义了一对多的订阅关系,能让多个订阅者同时监听某一个主题对象,当这个主题对象自身状态变化时,会通知所有订阅者,使他们能够做出相应的处理。
- ZooKeeper中,引入了Watcher机制来实现这种分布式的通知功能。
- Zookeeper允许客户端向服务端注册一个 Watcher监听,当服务端的一些事件触发了这个Watcher,那么就会向指定客户端发送一个事件通知来实现分布式的通知功能。
- 触发事件种类很多,如:节点创建,节点删除,节点改变,子节点改变等。
总的来说可以概括 Watcher为以下三个过程:
- 客户端向服务端注册 Watcher、
- 服务端事件发生触发 Watcher、
- 客户端回 Watcher 得到触发事件情况
watch机制特点
一次性触发 : 事件发生触发监听,一个 watcher event就会被发送到设置监听的客户端这种效果是一次性的,后续再次发生同样的事件,不会再次触发。
事件封装 : ZooKeeper使用 WatchedEvent对象来封装服务端事件并传递。
WatchedEvent包含了每一个事件的三个基本属性:
通知状态
(keeperstate),事件类型
(EventType)和节点路径
(path)event异步发送 : watcher的通知事件从服务端发送到客户端是异步的。
先注册再触发 : Zookeeper中的 watch机制,必须客户端先去服务端注册监听,这样事件发才会触发监听,通知给客户端
通知状态和事件类型
同一个事件类型在不同的通知状态中代表的含义有所不同,下表列举了常见的通知状态和事件类型。
其中连接状态事件
(type=None,path=nul1)不需要客户端注册,客户端只要有需要直接处理就行了。
Shell 客户端设置Watcher
设置节点数据变动监听 :
1 | get /hyl watch |
通过另一个客户端更改节点数据 :
1 | set /hyl 123 |
此时设置监听的节点收到通知 :
1 | WATCHER: |
Zookeeper的选举机制
zookeeper默认的算法是Fast Leader Election
,采用投票数大于半数则胜出的逻辑
概念
服务器ID :
- 比如有三台服务器,编号分别是1,2,3
- 编号越大在选择算法中的权重越大。
选举状态 :
- LOOKING,竞选状态。
- FOLLOWING,随从状态,同步leader状态,参与投票。
- OBSERVING,观察状态,同步leader状态,不参与投票
- LEADING,领导者状态,
数据ID :
- 服务器中存放的最新数据version
- 值越大说明数据越新,在选举算法中数据越新权重越大
逻辑时钟 :
- 也叫投票的次数,同一轮投票过程中的逻辑时钟值是相同的。每投完一次票这个数据就会增加,然后与接收到的其它服务器返回的投票信息中的数值相比,根据不同的值做出不同的判断。
- 比如说某台服务器在投票过程中掉线了 , 导致逻辑时钟比别人小 , 那么参与下次投票时 , 这台服务器的权重就会降低 (这是自然的,你都经常掉线了,还怎么当leader)
全新集群选举
- 每个机器都给自己投票
- 按票数过半则选举法束
假设目前有5台服务器,每台服务器均没有数据,它们的编号分别是1,2,3,4,5,按编号依次启动,它们的选择举过程如下:
- 服务器1启动,给自己投票,然后发投票信息,由于其它机器还没有启动所以它收不到馈信息,服务器1的状态一直属于 Looking
- 服务器2启动,给自己投票,同时与之前启动的服务器1交换结果,由于服务器2的编号大,所以服务器2胜出,但此时投票数没有大于半数所以两个服务器的状态依然是LO0KING
- 服务器3启动,给自己投票,同时与之前启动的服务器1,2交换信息,由于服务器3的编号最大所以服务器3胜出,此时投票数正好大于半数,所以服务器3成为领导者,服务器1,2成为小弟。
- 服务器4启动,给自己投票,同时与之前启动的服务器1,2,3交换信息,尽管服务器4的编号大,但之前服务器3已经胜出,所以服务器4只能成为小弟。
- 服务器5启动,后面的逻辑同服务器4成为小弟
非全新集群选举
对于运行正常的 zookeeper集群,中途有机器down掉,需要重新选举时,选举过程就需要加入数据ID、服务器ID和逻辑时钟
- 数据ID:数据越新,其versIon就越大,数据每次更新都会更新version . 也就是说 , 数据ID越大 , 说明该服务器保存的数据就越新
- 服务器ID:就是我们配置的myid中的值,每个机器一个。
- 逻辑时钟:这个值从0开始递增,每次选举对应一个值。如果在同一次选举中,这个值是一致的。
这样选举的标准就变成:
- 逻辑时钟小的选举结果被忽略,重新投票;
- 统一逻辑时钟后,数据id大的胜出
- 数据id相同的情况下,服务器id大的胜出
Zookeeper的典型应用
数据发布与订阅(配置中心)
- 发布与订阅模型,即所谓的配置中心,顾名思义就是发布者将数据发布到ZK节点上,供订阅者动态获取数据,实现配置信息的集中式管理和动态更新。
- 应用在启动的时候会主动来获取一次配置,同时,在节点上注册一个 Watcher,这样一来,以后每次配置有更新的时候,都会实时通知到订阅的客户端,从来达到获取最新配置信息的目的。比如:
- 分布式搜索服务中,索引的元信息和服务器集群机器的节点状态存放在ZK的一些指定节点,供各个客户端订阅使用。
注意:适合数据量很小的场景,这样数据更新可能会比较快
命名服务(Naming Service)
- 在分布式系统中,通过使用命名服务,客户端应用能够根据指定名字来获取资源或服务的地址,提供者等信息。
- 被命名的实体通常可以是集群中的机器,提供的服务地址,远程对象等等一一这些我们都可以统称他们为名字(Name)。其中较为常见的就是一些分布式服务框架中的服务地址列表。通过调用ZK提供的创建节点的API,能够很容易创建一个全局唯一的path,这个path就可以作为一个名称
- 阿里巴巴集团开源的分布式服务框架 Dubbo中使用 Zookeeper来作为其命名服务,维护全局的服务地址列表
分布式锁
分布式锁,这个主要得益于 Zookeeper保证了数据的强一致性。锁服务可以分为两类,一个是保持独占,另一个是控制时序。
所谓保持独占,就是所有试图来获取这个锁的客户端,最终只有一个可以成功获得这把锁。通常的做法是把zk上的一个znode看作是一把锁,通过 create znode的方式来实现。所有客户端都去创建/distribute_lock节点,最终成功创建的那个客户端也即拥有了这把锁。
控制时序,就是所有试图来获取这个锁的客户端,最终都是会被安排执行,只是有个全局时序了。做法和上面基本类似,只是这里/distribute_lock已经预先存在,客户端在它下面创建临时有序节点(这个可以通过节点的属性控制:CreateMode。EPHEMERAL_SEQUENTIAL来指定),Zk的父节点(/distribute_lock)维持一份 sequence,保证子节点创建的时序性,从而也形成了每个客户端的全局时序。