为分布式应用提供协调服务的 ZooKeeper
什么是 zookeeper
zookeeper 是 Apache 开源的一个顶级项目,目的是为分布式应用提供协调服务,当然 zookeeper 本身也是分布式的。
而从设计模式的角度来理解:zookeeper 是一个基于观察者模式设计的分布式服务管理框架,它负责存储和管理大家都关心的数据,然后接收观察者的注册。一旦数据的状态发生变化,zookeeper 就会通知那些已经注册的观察者,以便它们能够及时做出反应。
所以 zookeeper 可以看作是一个文件系统 + 通知机制。文件系统指的是 zookeeper 可以存储数据,尽管数据量比较少,但还是像文件一样可以存储的;而通知机制指的是当数据有变化,会立即通知观察者。
zookeeper 的特点
1)zookeeper 本身也是分布式的,可以组成集群。zookeeper 集群由一个领导者节点(Leader)和多个追随者节点(Follower)组成,Leader 负责接收写请求,Follower 负责和 Leader 之间进行数据同步并接收读请求。
2)集群中只要有半数以上的节点存活,zookeeper 集群就能正常服务,所以集群内部的节点数量最好是奇数个。
3)zookeeper 是 CP 模型,在一致性和可用性之间选择了一致性,因此集群里面的数据是全局一致的,每个 Server 都保存了一份相同的数据副本。客户端无论连接到哪一个 Server,数据都是一致的。这也意味着 Leader 只有将新数据同步给所有的 Follower 之后,整个 zookeeper 集群才能对外提供服务,否则客户端就有可能读到旧数据。因为根据 CAP 理论,在保证 P 的前提下,C 和 A 是不可兼顾的,至于选择哪一个则看是否对数据有强一致性的要求。而 zookeeper 存储的数据一般都不大,所以选择了一致性。
4)写请求顺序进行,来自同一个 client 的写请求按其发送顺序依次执行。
5)实时性,client 可以很快地读到最新数据。虽然 Leader 和 Follower 之间的数据同步需要一定时间,但 zookeeper 保存的数据量很小,因此同步速度非常快。
zookeeper 的数据结构
zookeeper 数据结构和 UNIX 文件系统很类似,整体上可以看做是一棵树,节点被称为 ZNode。每个 ZNode 默认能够存储 1MB 的数据,因为 zookeeper 是 CP 模型,所以它不适合存储大量的数据,只适合存储一些简单的配置信息。此外,每个节点都可以通过路径进行唯一标识,我们通过 ZNode 的路径即可获取某个 ZNode 存储的数据。
整体还是很好理解的,但是要明白,ZNode 能够存储的数据量比较少,不应该超过 1MB。
zookeeper 的应用场景
zookeeper 在生产上都能解决哪些问题呢?其实能解决的问题还蛮多的,比如统一命名服务、统一配置管理、统一集群管理、服务器节点动态上下线、软负载均衡等等。下面一个一个介绍。
统一命名服务
在分布式环境下,经常需要对应用 / 服务进行统一命名,便于识别。例如:IP 不容易记住,但是域名容易记住。
当访问域名的时候,会自动转发到某个服务器当中。
统一配置管理
分布式环境下,配置文件同步非常常见。一个集群中,所有节点的配置信息是一致的,对配置文件修改之后,希望能够快速同步到各个节点上。比如 kafka 集群,当然 kafka 自带 zookeeper,但是我们一般不用自带的。
配置管理可交由 zookeeper 实现,可将配置信息写入 zookeeper 的一个 ZNode,各个客户端监听这个 ZNode。一旦 ZNode 中的数据被修改,zookeeper 将通知各个客户端,这样一来每个客户端读到的配置信息都是一致的。
统一集群管理
分布式环境中,实时掌握每个节点的状态是必要的,这样便可根据节点的实时状态做出一些调整。而 zookeeper 可以实时监控节点的变化,通过将节点信息写入 zookeeper 的一个 ZNode,监听这个 ZNode 便可获取它的实时状态变化。
此外每一个客户端的状态也可以写到节点上面,只要状态发生变化,就会更新节点上客户端的数据。只要数据发生更新,会立刻同步到其它节点上,从而通知其它客户端。
服务器动态上下线
客户端能实时洞察到服务器上线的情况,还是最开始说的,如果某台服务器宕机,比如 server3。那么客户端就会被 zookeeper 通知,之后就不会再请求 server3 了。当然这只是宕机的情况,如果 server3 修好了重新上线,那么 zookeeper 也要通知客户端。客户端会再次重新注册监听,之后仍然可以访问 server3。
软负载均衡
在 zookeeper 中记录每台服务器的访问数,让访问数最少的服务器去处理最新的客户端请求。
当新的客户端来访问的时候,会自动分发到访问次数比较少的服务器上。也就是类似 Nginx 负载均衡的效果,让每一台服务器的压力都不会那么大。
安装 zookeeper 单机版
下面安装 zookeeper,由于它是 Apache 的一个顶级项目,所以域名是 zookeeper.apache.org,所有 Apache 的顶级项目的官网都是以 项目名.apache.org 来命名的。
点击 Download 即可下载,这里我们选择的版本是 3.5.10,下载之后扔到服务器上。由于 zookeeper 是基于 Java 语言编写的,所以还需要安装 JDK,这里我使用的是 JDK1.8,都已经已经安装好了,并配置了环境变量。
我们安装完毕之后不能直接用,还需要修改一下 zookeeper 的配置文件。在主目录的 conf 目录下,里面有一个 zoo_sample.cfg,我们将其重命名为 zoo.cfg,然后打开。
里面有一个 dataDir 参数,表示数据的存储目录,数据在持久化之后会存储在该目录中,以防止数据丢失。但该目录默认位于临时目录 /tmp 下面,这样当节点重启之后数据就没了,所以需要换一个目录(要提前创建好),至于目录名无所谓,我这里叫 zkData。
关于配置文件里的其他参数,我们之后会解读,下面先来启动服务。
bin 目录下有很多脚本,其中 .cmd 文件是在 Windows 上使用的,不用管。然后我们看到有一个 zkServer.sh,它就是负责启动 zookeeper 服务的。
启动成功,我们调用 jps 查看进程。
凡是基于 Java 语言编写的框架,在启动之后,都可以通过 jps 查看相应的进程。
要是看到输出了 QuorumPeerMain 就代表 zookeeper 启动成功了,如果想停止服务,可以通过 zkServer.sh stop,重启则是 zkServer.sh restart。
启动之后,我们也可以查看状态。
此时的模式是 standalone 模式,表示单机,当然后面我们也会搭建集群。
既然有了服务端,那么是不是也要有客户端呢,对的,类似于 Redis。下面启动客户端,直接 zkCli.sh 即可,不需要 start,出现如下表示启动成功。
关于 zookeeper 客户端的命令,一会儿详细介绍,我们先来解读一下 zookeeper 的配置文件。
配置项还是比较少的,解释一下它们的含义。
- tickTime:服务端与客户端的心跳时间,默认 2000 毫秒。表示客户端每隔 2 秒会向服务端发送一个心跳信息,表示自己还活着,服务端不要断开连接。当然,它还表示集群内多个节点之间的心跳时间;
- initLimit:领导者和追随者第一次建立连接时的最大通信时限,默认是 10 个 tickTime。如果超时,则表示连接建立失败;
- syncLimit:领导者和追随者之间响应的最大时限(连接建立之后),单位同样是 tickTime。如果领导者在超过 syncLimit * tickTime 之后没有收到追随者的响应,那么领导者会认为该追随者已经死掉,从而将其从服务器列表中删除;
- dataDir:数据文件目录+数据持久化路径,主要用于保存 zookeeper 中的数据,我们刚刚已经修改了;
- clientPort:客户端和服务端通信的端口;
- maxClientCnxns:服务端最多支持和多少个客户端建立连接,默认是 60;
- autopurge.snapRetainCount:在 dataDir 中要保存的快照数量,多余的要被清除;
- autopurge.purgeInterval:自动触发清除任务的时间间隔,单位小时;
- admin.serverPort:这个参数没有写在配置文件中,但有必要说一下。zookeeper 从 3.5 开始,启动之后会占用 8080 端口,因为内嵌了一个管理控制台。而 8080 端口很常见,如果启动时发现该端口被占用,那么 zookeeper 会启动失败,此时便可通过该参数将端口改成其它的,比如 8081;
配置文件还是非常简单的,以上我们就完成了 zookeeper 单机版的安装。
搭建 zookeeper 集群
我们之前说了,zookeeper 集群是由一个领导者和多个追随者组成,但这个领导者是怎么选出来的呢?我们貌似没有在配置文件中看到有关领导者和追随者的参数啊。在此之前先来看看 zookeeper 内部的一些机制:
- 半数机制:只要有半数以上的节点存活,则集群可用,所以 zookeeper 集群的节点数量适合为奇数;
- 虽然在配置文件中没有指定领导者和追随者,但 zookeeper 在工作时,有一个节点为 Leader,其它则为 Follower,而 Leader 是通过内部的选举机制临时产生的;
那么领导者到底是怎么选出来的呢?很简单,每台服务器都有一个 id(这里的 id 后面说),当启动的服务器超过半数的时候,就会选择 id 最大的 Server 成为领导者。比如有五台服务器,半数就是 2.5,因此当启动三台的时候就可以选出领导者。至于剩余的两台,启动之后只能成为追随者,因为领导者已经选出来了。关于这里的细节,一会儿再详细聊。
那么怎么指定服务器的 id 呢?还记得配置文件中的 dataDir 参数吗,在该参数指定的目录下创建一个 myid 文件(文件必须叫这个名字),然后在里面写上服务器的 id 即可。
[root@satori zkData]# echo 2 > myid
这里给 id 设置为 2,因为一会要搭建由三个节点组成的集群,而我希望当前节点成为 Leader,所以它的 id 应该为 2,其它的两个节点的 id 显然分别为 1 和 3。这样按着 id 从小到大的顺序启动时,该节点就会成为 Leader。
下面我们来搭建 zookeeper 集群,总共三个节点:
- IP:82.157.146.194,主机名:satori;
- IP:121.37.165.252,主机名:koishi;
- IP:123.60.7.226,主机名:marisa;
satori 节点就是当前一直在用的节点,剩余的两个节点的 zookeeper 也已经安装完毕。那么问题来了,我们要如何将这三个节点组成一个集群呢?显然还需要修改配置文件,先在 satori 节点进行修改。
# koishi 节点
server.1=121.37.165.252:2888:3888
# satori 节点
server.2=0.0.0.0:2888:3888
# marisa 节点
server.3=123.60.7.226:2888:3888
将集群中都有哪些节点写在 zoo.cfg 中,解释一下具体含义,首先两个冒号把等号右边分成了三部分,第一部分就不用说了,IP 地址或者主机名,用于定位节点;2888 是 Leader 和 Follower 交换信息的端口,因为副本要进行同步;3888 是交换选举信息的端口,因为要选出 Leader。
然后我们注意到 satori 节点的 IP 设置成了 0.0.0.0,这是因为当前的三个节点不在同一个网段,IP 用的都是公网 IP,而公网 IP 在绑定服务的时候会失败。所以在绑定的时候,其它节点的 IP 要写成公网 IP,自身节点的 IP 要写成 0.0.0.0。因此其它两个节点的 zoo.cfg 文件就应该这么改:
########## koishi 节点配置 ##########
# koishi 节点
server.1=0.0.0.0:2888:3888
# satori 节点
server.2=82.157.146.194:2888:3888
# marisa 节点
server.3=123.60.7.226:2888:3888
########## marisa 节点配置 ##########
# koishi 节点
server.1=121.37.165.252:2888:3888
# satori 节点
server.2=82.157.146.194:2888:3888
# marisa 节点
server.3=0.0.0.0:2888:3888
但是在生产中,一个集群内的节点应该都位于同一网段,然后将配置文件中的 IP 全部换成内网 IP 即可。这样彼此之间可以通过内网访问,而内网的访问速度要远远快于公网,并且还不需要走公网的流量。但我当前的三台云服务器不在同一个网段,所以只能用公网 IP,并且绑定的时候,将节点自身的 IP 换成 0.0.0.0。
至于等号左边的 server. 是固定的,后面的数字表示节点的 id,而节点 id 我们说了,通过在 myid 文件中进行指定。而节点 id 决定了,最终由谁担任领导者。其中 satori 节点的 id 为 2,刚刚已经改过了,然后再将 koishi 和 marisa 两个节点的 id 分别改为 1 和 3,就大功告成了。
接下来启动 zookeeper,由于 satori 节点的 zookeeper 已经启动了,我们在修改完配置文件之后,需要重新启动。
但是我们查看状态的时候,发现出错了,相信原因很好想。因为配置文件中指定了三个节点,而剩余两个节点的 zookeeper 还没启动。下面我们来启动一下,然后再次查看状态。
当剩余的两个节点启动之后,再次查看状态,发现 Mode 变成了 Leader。显然集群已经启动成功,至于剩余的两个节点,显然就是 Follower。
此时集群就启动成功了,但是关于领导者和追随者的选举问题,我们还得再说一说。
领导者是怎么选出来的
领导者选举分为两种情况:
- 集群第一次启动的时候,选举领导者;
- 运行过程中领导者挂了,从追随者当中选择一个作为领导者;
我们先来看第一种情况,假设集群当中有 5 个节点,id 分别为 1 到 5,来看看选举过程是怎样的?这里 5 个节点按照 id 从小到大顺序启动。
所以 5 个节点,启动 3 个之后就能选择出 Leader。然后 server4 又启动了,于是也发起一次选举,并把票投给自己。但 server1,2,3 已经不是 LOOKING 状态,所以它们不会更改自己的选票信息,最终结果 server3 仍有 3 票,server4 只有 1 票。少数服从多数,于是 server4 会再将自己的选票交给 server3,成为 Follower,状态改为 FOLLOWING。
同理,最后 server5 启动,结果就是 server3 有 4 票,自己只有 1 票。少数服从多数,于是将自己的选票交给 server3,成为 Follower。
所以整个过程,关键点有两个:
- 每个 server 启动之后都会发起选举,并将票投给自己。然后交换选票信息,并将票投给 id 最大的 server;
- 一旦选择出 Leader,其它节点自动成为 Follower。而后启动的 server,不论 id 多大,也只能成为 Follower;
以上就是集群第一次启动的时候,选举领导者。但如果在运行过程中,领导者挂了该怎么办呢?显然要再选举出一个新的领导者。所以当集群中的追随者发现自己连接不上领导者的时候,就会开始进入 Leader 选举,但此时是存在两种可能的。
- 领导者真的挂了;
- 领导者没有挂,只是追随者因为某些原因无法和领导者建立连接。比如 server5 发现连接不上 server3 了,于是它认为领导者挂了,便开启 Leader 选举。但事实上 server3 并没有挂,其它追随者都能正常连接,只是 server5 因为某些原因连接不上罢了;
先来解释第二种情况,server5 认为 server3 挂了之后,便会发起 Leader 选举,呼吁其它追随者进行投票。但是其它追随者发现领导者并没有挂,于是会拒绝 server5 的选举申请,并告知它当前已存在的领导者信息。对于 server5 而言,只需要和已存在的领导者重新建立连接,并进行数据同步即可。
server3:老子还没挂呢,莫要造反。
但如果是第一种情况,领导者真的挂了,该怎么办?比如这里的领导者 server3,在运行的时候,节点突然宕机了。要解释这个问题,我们需要引入一些新的概念。
- sid:就是我们一直说的服务器 id,用于唯一标识集群中的节点;
- zxid:事务 id,客户端在发起一次写请求的时候,都会带有 zxid,用于标识一次服务器状态的变更。所以 zookeeper 也是有事务的,保证每次写数据的时候,要么全部写完,要么不写,不会出现只写一半的情况。另外每个节点都有自己的 zxid,它们的值也不一定相同;
- epoch:Leader 任期的编号,就好比古代皇帝,每个皇帝在当政的时候都有自己的年号。并且每投完一次票,这个编号就会增加;
现在假设 server3 挂了,那么要重新选举 Leader,而选举规则如下。
- 先比较节点之间的 epoch,epoch 大的直接当选;
- epoch 相同,再比较 zxid,zxid 大的当选;
- epoch 和 zxid 都相同,则比较 sid,sid 大的当选;
关于这么做背后的原理,我们先暂且不表,等以后介绍 Paxos 协议的时候再细说。而且这里的 epoch 具体是干什么用的,估计也有人不太清楚,这些我们也留到以后再说。
客户端命令行操作
我们已经搭建好了 zookeeper 集群,接下来就是启动客户端,在里面输入增删改查相关的命令,然后发送给服务端执行,就类似于 Redis 一样。
# 输入 zkCli.sh 即可启动,会自动连接本地的 zookeeper 服务端
# 如果想连接其它节点的 zookeeper 服务端,那么需要加上 -server 参数
# 比如 zkCli.sh -server ip:2181
[root@satori ~]# zkCli.sh
回车之后,客户端便可连接至 Leader 节点。
然后来看看命令都有哪些?
1)ls:显示某个路径下的所有节点
2)ls2:显示某个路径下的所有节点,以及当前节点的详细信息。但是该参数已经废弃,推荐使用 ls -s
不但显示根节点下面的所有节点,还显示了当前根节点的详细信息,就是绿色框框内的部分。那么它们都代表啥含义呢,来解释一下。
cZxid:创建节点时的事务 ID,每次向 zookeeper 写入或者修改数据时都会产生一个事务 ID。它是 zookeeper 中所有修改的次序,如果 zxid1 小于 zxid2,那么 zxid1 对应的修改操作在 zxid2 之前发生;
ctime:当前节点的创建时间,时间戳形式,单位毫秒;
mZxid:当前节点最后一次更新的事务 ID;
mtime:当前节点最后一次更新的时间,时间戳形式,单位毫秒;
pZxid:当前节点的子节点最后一次更新的事务 ID;
cversion:当前节点的子节点变化了多少次;
dataVersion:当前节点的数据变化了多少次;
aclVersion:当前节点访问控制列表多少次;
ephemeralOwner:如果节点是临时节点,则表示节点拥有的 Session ID,如果不是则为 0。关于这里的临时节点,一会儿说;
dataLength:节点可以存储数据,所以它表示数据的长度;
numChildren:当前节点的子节点数量;
3)create:创建节点
比如 / 下面只有 zookeeper 这一个节点,我们再创建一个新的。
因为节点是用来存储数据的,所以创建节点的时候也应该指定相应的值,正如 Redis 在 set 一个 key 的时候也要指定 value 一样。当然不指定也可以,只不过不指定的话相当于值为 null。
通过 create 创建的节点默认是持久节点,那么什么是持久节点呢?首先 zookeeper 的节点是有类型的,可以分为持久节点和临时节点:
- 持久(persistent)节点:客户端和服务端断开连接之后,创建的节点不删除,也就意味着节点上的数据会保留;
- 临时(ephemeral)节点:客户端和服务端断开连接之后,创建的节点会自动删除,数据不会被保留;
此外节点还可以带编号和不带编号,如果带编号的话,zookeeper 会自动在节点的末尾加上一串数字。比如上面的 /ow,它默认是不带编号的,如果我们创建的是带编号的,那么节点创建之后就会变成 /ow001。
编号会依次递增,因此带编号的节点也叫做顺序节点。
因此组合起来,zookeeper 的节点类型总共有 4 种。其中使用 zookeeper 作为分布式锁,便是基于临时顺序节点实现的。多个客户端同时往 zookeeper 上面创建临时顺序节点,谁的编号最小,那么谁就先创建成功,我们就认为它拿到了分布式锁。
当客户端操作完共享数据需要释放锁的时候,只需要断开连接即可,这样该客户端创建的临时节点就会自动删除。一旦节点删除,那么它的下一个顺序节点就成了编号最小的节点,从而拿到分布式锁,因此这个机制就避免了因客户端挂掉而导致的死锁问题。
顺序节点非常有用,特别是在分布式系统中,编号可以用于为所有事件进行全局排序,这样客户端通过顺序号就能推断事件的顺序。
使用 create 创建的节点默认是持久非顺序节点,那么其它类型的节点怎么创建呢?
4)create -e:创建临时节点
临时节点创建完毕,如果此时客户端断开连接,临时节点就会被删除。
我们重启客户端,再次查看,发现临时节点已经被删除了。
5)create -s:创建顺序(带编号)节点
创建的时候,自动在结尾加上编号。我这里之前创建过几个,现在编号是从 11 开始,总之顺序节点的编号是递增的,只会增大,不会减小。所以参数 -e 表示临时节点,-s 表示顺序节点,那如果创建临时顺序节点呢?很简单,两个参数一块指定即可。
客户端退出之后,这个临时节点就会消失。
然后再次创建,发现编号从 14 开始,因为顺序节点的编号只会依次增加。
6)get:获取节点内容
如果加上 -s 参数,还可以获取节点的详细信息。
/china 节点存储的值是 beijing,/china/henan 节点存储的值是 zhengzhou。所以 zookeeper 的数据结构就类似一颗树,树上的每一个节点都可以存储具体的值,并且节点之间具有父子关系。
7)set:修改节点内容
create 表示创建一个新的节点,每个节点会存储一个值,get 表示获取节点存储的值,set 表示修改节点存储的值。需要注意的是,节点不能重复,所以我们不能这么做:
因为 /china/henan 这个节点已经存在了,我们不能重复 create,所以要修改节点的值的话,应该使用 set。
8)get -w:监听某个节点的值的变化
假设现在有两个客户端同时连接至 zookeeper 集群,客户端 A 执行 get -w /china 就表示监听 /china 这个节点。然后在客户端 B 上面对 /china 这个节点进行 set,那么 A 机器上就会收到提示,提示我们监听的节点被修改了。
注意:监听是一次性的,如果再次 set 的话,那么 A 机器就不会再提示了,除非再次 watch。另外除了节点的值被修改之外会提示,当节点被删除时也会提示。
那么这背后的原理是怎么实现的呢?首先监听的时候,客户端会创建两个子线程,一个负责网络通信(connector),另一个负责监听(listener)。通过 connector 将注册的监听事件发送给服务端,服务端将注册的监听事件添加进注册监听器列表中。
当服务端监听到有数据变化,就会将这个消息发送给 listener 线程,然后 listener 线程将消息输出出来。
9)ls -w:监听某个节点的子节点变化
当新建一个子节点、或者删除一个子节点的时候,就会收到提示,但是修改不会,所以这里监听的变化指的是子节点数量的变化。注意:这里只监听子节点的变化,子节点的子节点则不在范围之内。至于实现原理,和 get -w 相同,并且执行 ls -w 之后也只会监听一次。
10)delete:删除节点
注意:delete 只能删除叶子节点,而非叶子节点、比如这里的 /china 就无法删除。
在 3.5.0 之前删除非叶子节点使用的命令是 rmr,当然现在也可以使用,只不过废弃了。
11)stat:查看节点状态
这些字段的含义我们已经介绍过了,还可以通过 ls -s 或者 get -s 获取。
Python 连接 zookeeper
下面来看看如何用 Python 充当客户端,连接 zookeeper,之所以要介绍 Python 是因为笔者是 Python 系的。
Python 连接 zookeeper 的话,需要安装第三方模块,模块名叫 kazoo,直接 pip 安装即可。其实连接 zookeeper 还有一个模块,也叫 zookeeper,但是个人更推荐 kazoo,因为它是纯 Python 实现的,使用起来更方便。
from kazoo.client import KazooClient
hosts = [
"82.157.146.194:2181", # satori 节点
"121.37.165.252:2181", # koishi 节点
"123.60.7.226:2181", # marisa 节点
]
# 输入 ip:port,创建 zookeeper 客户端
# 多个节点之间,使用逗号分隔
client = KazooClient(",".join(hosts))
# 和 zookeeper 集群建立连接
client.start()
start 方法还可以接收一个 timeout 参数,默认是 15 秒,如果在这期间连接建立失败,那么会不断尝试,直到超时。并且连接一旦建立,无论是连接丢失、还是会话过期,KazooClient 都会不断地尝试重连。另外当客户端不需要再使用的时候,还可以调用 stop 方法,显式地中断连接。
下面来看看相关的 API。
# ls
print(client.get_children("/"))
"""
['xyz', 'zookeeper', 'china']
"""
# 查询某个节点是否存在
# 不存在返回 None,存在则返回节点的信息
# 相当于 stat
print(client.exists("/abc"))
"""
None
"""
print(client.exists("/china"))
"""
ZnodeStat(czxid=17179869187, mzxid=17179869193,
ctime=1664981696194, mtime=1664981963644,
version=4, cversion=1, aversion=0,
ephemeralOwner=0, dataLength=6, numChildren=1,
pzxid=17179869188)
"""
# 创建节点(可以递归创建)
client.ensure_path("/中国/四川/成都")
print(client.get_children("/"))
print(client.get_children("/中国"))
print(client.get_children("/中国/四川"))
"""
['xyz', '中国', 'zookeeper', 'china']
['四川']
['成都']
"""
# ensure_path 只能创建节点,不能添加数据
# 如果想添加数据,需要使用 set 修改
# 但是有一点需要注意,set 的值,必须是 bytes 类型
client.set("/中国", b"CHINA")
client.set("/中国/四川", b"SICHUAN")
client.set("/中国/四川/成都", b"CHENGDU")
# 使用 set 修改,get 获取
# 这个 zkCli.sh 的 API 是一致的
print(client.get("/中国"))
print(client.get("/中国/四川"))
print(client.get("/中国/四川/成都"))
"""
(b'CHINA', ZnodeStat(czxid=17179869208, ...))
(b'SICHUAN', ZnodeStat(czxid=17179869209, ...))
(b'CHENGDU', ZnodeStat(czxid=17179869210, ...))
"""
# 会将值和节点状态一起返回
# 创建节点除了使用 ensure_path,还可以使用 create
# ensure_path 在创建节点的时候,不要求上一级必须存在,会递归创建
# 但是 create 创建的时候,要求上一级必须存在
# 并且 ensure_path 创建节点时不可以指定数据,只能后续 set 修改
# 但是 create 在创建节点时可以指定数据
client.create("/中国/上海", b"shanghai",
# 是否是顺序节点,默认为 False
sequence=False,
# 是否是临时节点,默认为 False
ephemeral=False)
# 查看 /中国 的子节点,会多出一个 "上海"
print(client.get_children("/中国"))
"""
['四川', '上海']
"""
print(client.get("/中国/上海"))
"""
(b'shanghai', ZnodeStat(czxid=17179869233, ...))
"""
# 删除节点
print(client.get_children("/中国"))
"""
['四川', '上海']
"""
client.delete("/中国/上海")
print(client.get_children("/中国"))
"""
['四川']
"""
# delete 方法默认只能删除叶子节点
# 如果想递归删除非叶子节点,需要多指定一个参数
client.delete("/中国", recursive=True)
print(client.get_children("/"))
"""
['xyz', 'zookeeper', 'china']
"""
然后还有监听,监听的话,可以监听节点存储的数据的变化,也可以监听节点下面的子节点数量的变化。
def watch(event):
print("观察者发现变化了")
print(event)
# 相当于 get -w
client.get("/xyz", watch=watch)
# 相当于 ls -w
client.get_children("/xyz", watch=watch)
# 此时程序不会阻塞,如果在客户端退出之前
# 节点变化了,则触发 watch 函数的执行
# 或者还可以使用装饰器的方式
@client.DataWatch("/xyz") # get -w
def watch1(event):
pass
@client.ChildrenWatch("/xyz") # ls -w
def watch2(event):
pass
以上就是 Python 连接 zookeeper 的一些操作,还是很简单的。因为使用 zookeeper 本身也不会做太多复杂的操作,就是把它当成是一个配置中心,用于简单地数据存储以及数据监听。
数据是怎么写入的
无论是 zookeeper 自带的客户端 zkCli.sh,还是使用 Python(或者其它语言)实现的客户端,本质上都是连接至集群,然后往里面读写数据。那么问题来了,集群在收到来自客户端的写请求时,是怎么写入数据的呢?
首先客户端在访问集群的时候,本质上是访问集群内的某一个节点,而根据访问的节点是领导者还是追随者,写入数据的过程也会有所不同。
先来看看当访问的节点是领导者的情况:
这里面有一个关键的地方,就是 Leader 不会等到所有的 Follower 都写完,只要有一半的 Follower 写完,就会告知客户端。还是半数机制,一半的 Follower 加上 Leader 正好刚过半数。而这么做的原因也很简单,就是为了快速响应。
再来看另一种情况,如果客户端访问的节点是追随者,情况会怎么样呢?其实很简单,由于追随者没有写权限,那么会先将写请求转发给领导者,然后接下来的步骤和上面类似,只是最后一步不同。当 Leader 发现有半数的 Follower 写完,就认为写数据成功,于是返回 ack。但这个 ack 不会返回给客户端,因为客户端访问的不是领导者,最终领导者会将 ack 返回给客户端访问的追随者,再由这个追随者将 ack 返回给客户端,告知写请求已执行完毕。
基于 zookeeper 实现分布式锁
关于分布式锁,我之前介绍过如何基于 Redis 实现分布式锁,里面对分布式锁做了比较详细的解析。如果你还不清楚分布式锁的相关概念,可以先看这篇文章,下面来聊一聊如何基于 zookeeper 实现分布式锁。
先来说一下原理,当客户端需要操作共享资源时,需要先往 zookeeper 集群中创建一个临时顺序节点。然后查看对应的编号,如果没有比它小的,说明最先创建,我们就认为客户端拿到了分布式锁。如果客户端发现节点的编号不是最小的,说明已经有人先创建了,也就是锁已经被别的客户端拿走了。那么该客户端会对前一个节点进行监听,等待释放。
所以从概念上还是很好理解的,然后我们来编程实现一下。
from typing import List
import queue
from kazoo.client import KazooClient
class DistributedLock:
def __init__(self, hosts: List[str]):
"""
:param hosts: 'ip1:port1,...'
"""
self.client = KazooClient(",".join(hosts))
self.client.start()
# 要在 /lock 节点下面创建临时顺序节点
# 所以先保证 /lock 节点存在
if not self.client.exists("/lock"):
self.client.create("/lock")
# 要创建的临时顺序节点
self.cur_node = None
# 要监听的节点(也就是上一个节点)
self.prev_node = None
# 本地队列
self.q = queue.Queue()
def acquire(self):
"""
获取锁
:return:
"""
self.cur_node = self.client.create(
"/lock/seq-",
# 临时顺序节点
ephemeral=True,
sequence=True
)
# create 方法会返回创建的节点名称
# 需要判断编号是不是最小的
# 因此要拿到所有的节点
nodes = self.client.get_children("/lock")
# nodes: ["seq-000..0", "seq-000...1"]
nodes.sort()
if len(nodes) == 1:
return True
elif "/lock/" + nodes[0] == self.cur_node:
# 如果 nodes 里面的最小值和 node 相等
# 说明该客户端创建的节点的编号最小
# 于是我们就认为它拿到了分布式锁
return True
# 否则说明不是最小,因此要找到它的上一个节点
# 也就是要监听的节点
index = nodes.index(self.cur_node.split("/")[-1])
self.prev_node = "/lock/" + nodes[index - 1]
# 对上一个节点进行监听
self.client.get(self.prev_node, watch=self.watch)
# 这一步不是阻塞的,但程序必须要拿到锁之后才可以执行
# 所以我们要显式地让程序阻塞在这里
self.q.get()
return True
def release(self):
"""
释放锁
:return:
"""
self.client.delete(self.cur_node)
def watch(self, event):
"""
监听函数,参数 event 是一个 namedtuple
kazoo.protocol.states.WatchedEvent
里面有三个字段:type、state、path
监听节点的值被改变时,type 为 "CHANGED"
监听节点被删除时,type 为 "DELETED"
path 就是监听的节点本身
state 表示客户端和服务端之间的连接状态
建立连接时,状态为 LOST
连接建立成功,状态为 CONNECTED
如果在整个会话的生命周期里,伴随着网络闪断、服务端异常
或者其他什么原因导致客户端和服务端连接断开,状态为 SUSPENDED
与此同时,KazooClient 会不断尝试与服务端建立连接,直至超时
如果连接建立成功了,那么状态会再次切换到 CONNECTED
"""
if event.type == "DELETED" and self.prev_node == event.path:
# 往队列里面扔一个元素
# 让下一个节点解除阻塞
self.q.put(None)
# 测试函数
def test(lock, name):
lock.acquire()
print(f"{name}获得锁,其它人等着吧")
print(f"{name}处理业务······")
print(f"{name}处理完毕,释放锁")
lock.release()
if __name__ == '__main__':
import threading
hosts = [
"82.157.146.194:2181",
"121.37.165.252:2181",
"123.60.7.226:2181",
]
# 创建三把锁
lock1 = DistributedLock(hosts)
lock2 = DistributedLock(hosts)
lock3 = DistributedLock(hosts)
threading.Thread(
target=test, args=(lock1, "客户端1")
).start()
threading.Thread(
target=test, args=(lock2, "客户端2")
).start()
threading.Thread(
target=test, args=(lock3, "客户端3")
).start()
"""
客户端1获得锁,其它人等着吧
客户端1处理业务······
客户端1处理完毕,释放锁
客户端3获得锁,其它人等着吧
客户端3处理业务······
客户端3处理完毕,释放锁
客户端2获得锁,其它人等着吧
客户端2处理业务······
客户端2处理完毕,释放锁
"""
实现起来不是很难,并且使用 zookeeper 的好处就是,我们不需要担心死锁的问题。因为客户端宕掉之后,临时节点会自动删除,但缺点是性能没有 Redis 高。另外值得一提的是,kazoo 已经帮我们实现好了分布式锁,开箱即用,我们就不需要再手动实现了。
# 创建客户端
client = KazooClient(",".join(hosts))
client.start()
# 此时需要自己手动给一个唯一标识
lock = client.Lock("/lock", "unique-identifier")
# 获取锁
lock.acquire()
# 处理业务逻辑
...
# 释放锁
lock.release()
# 或者也可以使用上下文管理器
with lock:
...
显然这就优雅多了,借助于 kazoo 实现好的分布式锁,可以减轻我们的心智负担。此外 kazoo 还提供了读锁和写锁:
client.ReadLock
client.WriteLock
我们一般使用 client.Lock 就行,可以自己测试一下。
关于 zookeeper 的基础内容就介绍到这里,但伴随着 zookeeper 还有一系列的协议,比如 Paxos 协议、ZAB 协议、CAP 定理等等,这些可谓是分布式系统的重中之重。我们后续有机会再聊。
如果觉得文章对您有所帮助,可以请囊中羞涩的作者喝杯柠檬水,万分感谢,愿每一个来到这里的人都生活愉快,幸福美满。
微信赞赏
支付宝赞赏