分布式框架-Zookeeper
恁爹说:源码太复杂,有功夫再整理吧,害!
目录
一、前言
关于分布式系统的相关概念:
1)单个物理节点很容易达到性能,计算或者容量的瓶颈,所以这个时候就需要多个物理节点来共同完成某项任务,
2)一个分布式系统的本质是分布在不同网络或计算机上的程序组件,彼此通过信息传递来协同工作的系统,而Zookeeper正是一个分布式应用协调框架,在分布式系统架构中有广泛的应用场景。
二、Zookeeper基础知识
2.1 介绍
- 定义:zookeeper是一个分布式协调框架,是Apache Hadoop的一个子项目。
- 应用场景:主要是用来解决分布式应用中经常遇到的一些数据管理问题,如:
- 统一命名服务
- 状态同步服务
- 集群管理
- 分布式应用配置项的管理等
- 分布式下的应用场景:
- 分布式配置中心
- 分布式注册中心
- 分布式锁
- 分布式队列
- 集群选举
- 分布式屏障
- 发布/订阅
- 长连接和短连接:
- 没有本质区别,是长连接还是短连接是由业务决定的
- 使用长连接的目的:每次关闭连接后再次新增就会在三次握手环节产生网络开销,会有一定网络延迟,这样不够友好
- 使用心跳机制的作用:维持长连接
- 一边维持session的超时时间
- 一边看在集群环境下服务端是否依旧存活,不存活则重选服务器,在此过程中保持客户端和集群的连接,而不是让客户端发现服务端挂了、重试重发连接请求
- Zookeeper3.5.0版本新特性:不停机动态扩容、缩容
- 前提:开启超级管理员身份验证模式:
-Dzookeeper.skipACL=yes
2.2 Zookeeper运行机制
zookeeper是一种CS结构(
client-server
)
- 过程图:
连接处理支持多线程,具体请求处理则是单线程
- zookeeper中的session:
client
和server
交互,首先要有三次握手- 握手后,客户端会发送一个带连接超时时间的连接请求(
connect request
)给服务端,服务端接受请求后会维护sessionId的
过期时间,并返回一个sessionId
给到客户端
超时时间并不完全是客户端说了算,还需要服务端的协商
- 客户端后续的操作都跟此
sessionId
相关联,都会带上sessionId
(和web服务类似)
- 服务端的保活机制:客户端发送保活命令可以延长
sessionId
的有效时间,确保长连接不断开
- 保活命令:
ping
、pong
- 一旦客户端挂掉后,服务端心跳检测服务会发现有一个
session
在timeout
时间内没有收到命令了,那么zookeeper服务端会将该临时节点给删除掉
2.3 Zookeeper的文件系统数据结构
2.3.1 介绍
- 定义:
- Zookeeper维护一个类似文件系统的数据结构,每个子目录项都被称作为znode(目录节点)
- 在Zookeeper中所有数据都是以节点(znode)的形式组织的
- 和文件系统类似,我们能够自由的增加、删除znode,以及在一个znode下增加、删除子znode
- zookeeper中没有相对路径,所有节点都是以
/
打头的全路径,也没有cd
- 数据结构:
2.3.2 znode类型
PERSISTENT
:持久化目录节点: 一旦创建好后只要没删就永久存在PERSISTENT_SEQUENTIAL
:持久化顺序编号目录节点:
- 创建节点的同时还可以给节点加个编号,编号顺序递增(编号共10位)
- 顺序节点换了路径后,编号不会再累加
EPHEMERAL
:临时目录节点:
- 创建过程跟运行机制有关,即临时目录节点的生命周期和sessionId是绑定的
- 创建命令:
create -e /path some-value
- 一旦客户端挂掉后,服务端心跳检测服务会发现有一个
session
在timeout
时间内没有收到命令了,那么zookeeper服务端会将该临时节点给删除掉
想要保活可以通过发指定
sessionId
的请求给到服务端
- 临时节点不能有子节点,硬要创建会提示[
Ephemerals cannot have children: 具体的临时节点
]
EPHEMERAL_SEQUENTIAL
:临时顺序编号目录节点:
- 临时节点上加了编号,编号顺序递增(编号共10位)
- 临时节点不能有子节点,硬要创建会提示[
Ephemerals cannot have children: 具体的临时节点
] - 顺序节点换了路径后,编号不会再累加
同时,3.5.3版本新增以下类型:
- Container节点:
- 原理:
- 做容器用,一个容器节点(Container节点)创建时如果没有添加子节点,那么就当作持久化目录节点使用。
- 一旦容器节点添加了子节点,后期再删除项下全部子节点,Container节点在未来会被Zookeeper的定时任务自动清除(默认60s检查一次)
- 应用:分布式锁
- TTL节点:
- 和Redis的expire命令有些类似,可以给该节点单独设置超时时间,只不过该节点的失效时间不够准确
因为失效时间是通过后台轮询进行管理的
- 和临时节点的区别:可以给该节点单独设置超时时间,无需跟
sessionId
绑定,这是和临时节点的最大不同 - 开启方式:TTL节点默认是关闭的,只能通过系统配置
-Dzookeeper.extendedTypesEnabled=true
开启 - 如何配置:
- 定位
bin
目录,vim zkServer.sh
- 修改
ZOOMAIN
启动参数,在字符串最前方增加-Dzookeeper.extendedTypesEnabled=true
即可
- 定位
配置好后重启zookeeper生效
2.4 监听通知机制(事件监听机制)
2.4.1 介绍
- 概念:zookeeper可以对文件系统数据结构中的所有节点都进行监听
一般采用递归监听各个目录节点
- 监听的事件类型:
None
:连接建立事件NodeCreated
:节点创建NodeDeleted
:节点删除NodeDataChanged
:节点数据变化NodeChildrenChanged
:子节点列表变化DataWatchRemoved
:节点监听被移除ChildWatchRemoved
:子节点监听被移除
- 过程:
- 如果客户端注册的是对某个节点的监听,则当这个节点被删除,或者被修改时,对应的客户端将被服务端通知
- 如果注册的是对某个目录的监听,则当这个目录有子节点被创建,或者有子节点被删除,对应的客户端将被通知
- 如果注册的是对某个目录的递归子节点进行监听,则当这个目录下面的任意子节点有目录结构的变化(有子节点被创建,或被删除)或者根节点有数据变化时,对应的客户端将被通知。
- 特点:
- 所有的通知都是一次性的:即无论是对节点还是对目录进行的监听,一旦触发,对应的监听即被移除。
- 修改节点的内容是不会触发监听通知的
- 递归监听:递归监听所有子节点,每个子节点下面的事件同样只会被触发一次。
2.4.2 相关指令
- 对指定目录节点进行监听:
ls -w 节点名称
:例如:ls -w /test
- 返回当前节点项下的所有直接子节点,并对当前节点进行监听
- 后续只要影响到了其中某个节点或在其项下进行添加删除子节点操作,则按照已监听次数来判断是否触发监听
get -w 节点名称
:- 获取当前节点项下的数据,并对当前节点进行监听
- 后续只要影响到了其中某个节点或在其项下进行添加删除子节点操作,则按照已监听次数来判断是否触发监听
- 对当前时刻时指定节点及其项下子节点进行监听:
ls -R -w 节点名称
- 返回当前节点和其项下节点的树形结构,并对该节点及其项下所有子节点的修改操作进行监听
- 后续只要影响到了其中某个节点或在其项下进行添加删除子节点操作,则按照已监听次数来判断是否触发监听
- 示例:
- 移除目录节点及其项下子节点已有监听:
removewatches 节点名称
- 例如
removewatches /test
三、Zookeeper实战
3.1 程序安装
- 配置JAVA环境,检验环境:
java ‐version
- 下载解压zookeeper
wget https://mirror.bit.edu.cn/apache/zookeeper/zookeeper‐3.5.8/apache‐zookeepe
r‐3.5.8‐bin.tar.gz
tar ‐zxvf apache‐zookeeper‐3.5.8‐bin.tar.gz
cd apache‐zookeeper‐3.5.8‐bin
- 重命名配置文件
zoo_sample.cfg
:cp zoo_sample.cfg zoo.cfg
- 启动zookeeper:
bin/zkServer.sh start conf/zoo.cfg
可以通过
bin/zkServer.sh
来查看都支持哪些参数
- 检测是否启动成功:
echo stat | nc 192.168.109.200
前提是配置文件中将
stat
四字命令设置了了白名单,如:4lw.commands.whitelist=stat
- 连接服务器:
bin/zkCli.sh ‐server ip:port
3.2 核心文件
/bin
目录下核心文件:
zkServer.sh
:启动zookeeper服务的脚本zkCli.sh
:启动zookeeper客户端的脚本
/conf
目录:
log4j.properties
:日志框架zoo_sample.cfg
:zookeeper配置文件- 建议不直接修改
sample
文件,而是在拷贝后修改 - 参数说明:
tickTime=2000
:最小时间单位为2s,后面的参数会用到该参数initLimit=10
:zookeeper集群中进行数据同步的最长时间为10 * tickTime
,也就是20ssyncLimt=5
:zookeeper集群中Leader
和follower
之间进行心跳检测的超时时间为5 * tickTime
,也就是10sdataDir=/tmp/zookeeper
:存放zookeeper的事务日志对应配置文件以及快照clientPort=2181
:服务端开放给客户端的端口为2181
- 建议不直接修改
3.3 Zookeeper常用命令
3.3.1 启动、连接、日志相关命令
- zookeeper服务端:
- 启动命令:
./bin/zkServer.sh start conf/zoo.cfg
- 结束命令:
./bin/zkServer.sh stop conf/zoo.cfg
- 客户端与服务端进行连接:
- 客户端和服务端都在本地:
./bin/zkCli.sh
- 客户端和服务端不在本地:
./bin/zkCli.sh -server [服务端ip地址:clientPort]
- 连接成功后会返回[
SyncConnected type:noe
]的事件
- 查看日志:在安装目录
/bin/logs
目录下,执行tail -f zookeeper-root-server-..out
3.3.2 查看目录节点命令
ls
:和Linux命令功能一致,查看某一个节点下的子节点:
ls /
:查看根节点下的子节点,初始只会有[zookeeper]目录节点ls /zookeeper/
:返回[config, quota]两个节点ls -R /
:遍历查看根节点下的所有子节点
3.3.3 创建节点命令(create)
- 命令:
create [-s] [-e] [-c] [-t ttl] path [data] [acl]
方括号内的参数都是可选的
- 参数解释:
[-s]
:节点后面会跟一个序号,是依次递增的[-e]
:临时节点[-c]
:容器节点[-t ttl]
:ttl
节点path
:目录节点路径[data]
:目录节点是否包含数据[acl]
:赋权
- 创建持久化节点:
- 创建一个不带数据的持久化节点:
create /test
:未加任何参数,因此创建的是一个持久化节点,客户端断开连接服务端也能ls
查到该/test
节点 - 创建一个带数据的持久化节点:
create /test2 mazai
如何查看目录节点所带的数据?使用
get
命令
- 创建持久化顺序节点:
create /seq
:前置操作,/seq
节点下存放所有的持久化顺序节点
- 创建一个不带数据的有前缀的(
mazai-
)持久化顺序节点:create -s /seq/mazai-
- 原理:创建出的
mazai
节点后面带序号(二进制),序号从0
开始,最终基于/seq
下的已存在的节点而来(无论是否是顺序节点!),有多少个就在0上加1 - 序号一共10位,因此一共可以表示210个数字
- 应用:分布式锁为了避免羊群效应,通常是通过顺序节点来实现的
- 原理:创建出的
- 创建一个不带数据的无前缀的持久化节点:
create -s /seq/
- 创建临时节点:
create -e /seq/ephemeral xxxxx
过了sessionId的超时时间,该节点会被zookeeper后台回收
- 创建临时顺序节点:
- 创建一个不带前缀的临时顺序节点:
create -e -s /seq/
- 创建一个带前缀的临时顺序节点:
create -e -s /seq/ephemeral-
- 创建容器节点:
- 创建一个不带数据、不带子节点的容器节点:
create -c /container XXX
- 在当前容器节点下创建子节点:
create -c /container/sub0
- 创建TTL节点:
- 创建一个超时时间5000毫秒的带数据的TTL节点:
create -t 5000 /ttl-node ddd
3.3.4 查看节点数据命令(get)
- 查看持久化节点的数据:
get /test
:结果为null
get /test2
:结果为mazai
- 查看持久化节点的详细数据:
get -s /test2
- 查看临时节点的详细数据:
get -s /seq/ephemeral
- 结果示例:
- 结果参数说明:
参数 | 说明 | 备注 |
---|---|---|
cZxid | 创建该节点的事务id | 关于事务请求和事务见下文 |
cTime | 创建该节点的事件 | |
mZxid | 修改该节点的最新事务id | |
mtime | 修改该节点的最新时间 | |
pZxid | 导致当前节点的子节点列表发生变化的最新事务id | |
cversion | 当前节点的子节点结果集版本 | 一个节点的子节点增加、删除都会影响这个版本 |
dataversion | 节点项下数据版本id | 跟乐观锁相关 |
aclVersion | 权限控制版本id | 修改节点的权限控制时该值递增 |
ephemeralOwner | 存储sessionId | |
dataLength | 节点项下数据长度 | |
numChildren | 子节点个数 |
3.1 关于事务请求和事务
id
:
1)所有修改数据的请求叫事务请求(包含session
的建立),zookeeper会通过递增事务id
来维护事务请求的顺序
3.2 跟查看持久化节点的
get -s
指令的返回结果区别:
1)临时节点的ephemeralOwner
有具体的sessionId
,而持久化节点的ephemeralOwner是0x0
(0的意思),表示没有
- 查看容器节点的详细数据:同上,其结果参数没什么区别,同样
ephemeralOwner
为0
3.3.4 修改节点数据命令(set)
- 修改节点数据:
set [-s] [-v version] path data
- 示例:
- 修改持久化节点的数据:
set /test xxx
3.3.5 删除节点命令(delete)
- 删除节点命令:
delete [-v version] path
v
跟乐观锁相关
- 删除持久化节点:
- 删除持久化节点
/test
:delete /test
- 删除容器节点:容器节点下子节点被删除后60s左右zk准备删除容器节点
四、Zookeeper ACL权限控制
4.1 介绍
- 功能:Zookeeper的**ACL(Access Control List)**权限控制,可以控制节点的读写操作,保证数据的安全性
- 语法:
scheme:id:permission
- 查看节点授权情况:
getAcl 节点名称
:返回权限模式:授权对象
口令认证模式下,密码必定是密文
- 跳过权限检测:
- 方式:通过系统参数
-Dzookeeper.skipACL=yes
进行配置,默认是no
,可以配置为true
- 配置位置:定位
bin
目录,vim zkServer.sh
- 优势:提高zookeeper性能
4.2 ACL语法参数分析
4.2.1 Scheme:权限模式
- 两种功能:
- 进行范围验证:针对一个IP或者一段IP地址授予某种权限,例如
ip:192.168.0.1/24
这样的一段ip - 进行口令验证(用户名密码方式)
zookeeper中是Digest认证
- 口令验证的步骤:
- 客户端设定好
用户名:密码
后传送给服务端 - 服务端生成授权ID:服务端对密码部分使用
SHA-1
和BASE64
算法进行加密,具体有以下两种加密方式:(假定结果是X/NSthOB0fD/OT6iilJ55WJVado=
)- 代码生成加密密钥(对应密码的密文):
String sId = DigestAuthenticationProvider.generateDigest("gj:test");System.out.println(sId);// gj:X/NSthOB0fD/OT6iilJ55WJVado=
- 在shell终端使用命令生成加密密钥:
echo ‐n <user>:<password> | openssl dgst ‐binary ‐sha1 | openssl base64
- 代码生成加密密钥(对应密码的密文):
- 两种类型:
Super
权限模式:是一种特殊的Digest认证,具有Super
权限的客户端可以对ZooKeeper上的任意数据节点进行任意操作
密码忘了的话就不慌了
world
权限模式:是zookeeper缺省的权限模式,创建节点时未指明权限默认world
,可通过getAcl
查看到
4.2.2 ID:授权对象
- 定义:权限赋予的对象
- 如何确定具体的对象:取决于权限模式
- 采用范围验证的话,ID就是IP地址或IP地址段
- 采用
Digest
认证或Super
权限模式的话,ID就是用户名 - 采用
World
模式,ID是系统中的所有用户
4.2.3 Permission:权限信息
各种权限如下:
值 | 对应权限 | 说明 |
---|---|---|
c | 创建权限 | 授予权限的对象可以在数据节点下创建子节点 |
w | 更新权限 | 授予权限的对象可以更新该数据节点 |
r | 读取权限 | 授予权限的对象可以读取该节点的内容以及子节点的列表信息 |
d | 删除权限 | 授予权限的对象可以删除该数据节点的子节点 |
a | 管理者权限 | 授予权限的对象可以对该数据节点体进行ACL权限设置 |
4.3 设置ACL的方式
4.3.1 口令验证
存在两种方式,但无论是密文授权还是明文授权,zookeeper底层都是以密文的形式存储授权信息
- 密文授权:有两种方式:
- 节点创建的同时设置ACL:
create /zk‐node datatest digest:gj:X/NSthOB0fD/OT6iilJ55WJVado=:cdrwa
- 创建一个节点
/zk-node
,其用户为gj
- 若设置好后,未给当前用户授权则无法访问
/zk‐node
节点: - 需要给当前连接添加授权用户的权限后才能查看:
addauth digest gj:test
- 创建一个节点
上面密码
test
别再搞密文了,并且退出后就没法看了,得重新添加权限
参数里dataest这个数据可以省略
- 使用
setAcl
对已有节点进行授权:setAcl /zk‐node digest:gj:X/NSthOB0fD/OT6iilJ55WJVado=:cdrwa
- 明文授权:
- 创建节点,并以明文的方式赋权:
addauth digest u100:p100
不能再用空数据了,空数据的话会把授权信息当作数据
- 当前连接下正常访问
/zk-node
节点:get /zk-node
4.3.2 范围验证
- 创建新节点同时对指定范围ip的用户授权:
create /node‐ip data ip:192.168.109.128:cdwra
- 对已有节点授权指定范围的用户:
setAcl /node‐ip ip:192.168.109.128:cdwra
- 对多个ip的用户授权:
setAcl /node-ip ip:IP1:rw,ip:IP2:a
ip间使用逗号分隔
4.3.3 Super超级管理员模式
- 配置位置:在zk的服务端启动
sh
文件中进行配置,具体步骤:
- 定位
bin
目录,vim zkServer.sh
- 修改ZOOMAIN启动参数,在字符串最前方增加
‐Dzookeeper.DigestAuthenticationProvider.superDigest=super:<base64encoded(SHA1(password))
即可super
:具体的用户名<base64encoded(SHA1(password))
:密钥
- 配置好后重启zookeeper生效
4.3.4 world任意模式
- 语法:
setAcl /znode-2 world:anyone:cdwra
- 示例:授权所有用户都可以对
/znode-2
节点任意访问:
五、Zookeeper内存数据和持久化
5.1 内存数据结构
DataTree
:是内存中的数据组织形式:
public class DataTree {
private final ConcurrentHashMap<String, DataNode> nodes = new ConcurrentHashMap<String, DataNode>();// String类型对应path
private final WatchManager dataWatches = new WatchManager();
private final WatchManager childWatches = new WatchManager();
}
DataNode
:是Zookeeper存储节点数据的最小单位:
public class DataNode implements Record {
byte data[];// 节点存放的数据
Long acl;// 授权信息
public StatPersisted stat;// 节点状态信息,可用 get -s 节点名查看
private Set<String> children = null;
}
5.2 事务日志和磁盘预分配机制
- 事务日志的功能:针对每一次客户端的事务操作,Zookeeper都会将他们记录到事务日志中
当然,Zookeeper也会将数据变更应用到内存数据库中
- 如何配置事务日志存放位置:
- 使用默认存放位置:打开
/conf
目录下zk的配置文件.cfg
,找到dataDir
参数,其后路径便是:
- 指定存放位置:在
.cfg
中新增dataLogDir
参数
- 事务日志的特点:
- 事务日志文件名:
log.<当时最大事务ID>
都以log打头,且顺序编号
- 采用磁盘预分配机制,统一初始日志文件大小:默认65M
- 磁盘预分配机制:
- 定义:Zookeeper在创建事务日志文件的时候就进行文件空间的预分配
即在创建文件的时候,就向操作系统申请一块大一点的磁盘块
- 预分配的磁盘大小(初始日志文件大小)的配置:通过
-Dzookeeper.preAllocSize
系统参数:默认65M - 没有磁盘预分配将导致的问题:Zookeeper进行事务日志文件操作的时候会频繁进行磁盘IO操作,事务日志的不断追加写操作会触发底层磁盘IO为文件开辟新的磁盘块,即磁盘Seek。
- 查看事务日志:
- 工具:
java ‐classpath .:slf4j‐api‐1.7.25.jar:zookeeper-3.5.8.jar:zookeeper‐jute‐
3.5.8.jar org.apache.zookeeper.server.LogFormatter /usr/local/zookeeper/apache‐zookeeper‐3.5.8‐bin/data/version‐2/log.1
5.1 参数释义:
1)org.apache.zookeeper.server.LogFormatter
:格式化工具包,因为文件是二进制存储的
2)zookeeper‐jute‐3.5.8.jar
:序列化工具,数据最终是通过jute序列化后才存到事务日志中的
- 具体使用:
- zk命令和事务日志的一一对应:
末尾的
1
,2
代表数据版本
5.3 数据快照
- 功能:数据快照用于记录Zookeeper服务器上某一时刻的全量数据
- 数据快照文件的特点:快照恢复速度很快
- 快照文件名:
snapshot.<当时最大事务ID>
都以
snapshot
打头,且顺序编号
- 生成快照:
- 通过配置
snapCount
配置每间隔事务请求个数来生成快照 - 然而实际的快照生成时机为事务数达到
[snapCount/2 + 随机数(随机数范围为1 ~ snapCount/2 )]
个数时开始快照- 这样做的目的:因为快照会消耗服务器资源,所以要避免集群中所有机器在同一时间进行快照
- 存储位置:和事务日志相同:默认
dataDir
- 查看数据快照:
java ‐classpath .:slf4j‐api‐1.7.25.jar:zookeeper-3.5.8.jar:zookeeper‐jute‐3.5.8.jar org.apache.zookeeper.server.SnapshotFormatter /usr/local/zookeeper/apache‐zookeeper‐3.5.8‐bin/data‐dir/version‐2/snapshot.0
- 有了事务日志,为什么还要数据快照?
- 快照数据主要时为了快速恢复,事务日志文件是每次事务请求都会进行追加的操作,而快照是达到某种设定条件下的内存全量数据。
- 所以通常快照数据是反应当时内存数据的状态,事务日志是更全面的数据,在恢复数据的时候,可以先恢复快照数据,再通过增量恢复事务日志中的数据即可
六、Spring Boot整合Zookeeper实战
6.1 相关案例
此次以订单系统为例:
6.2 依赖配置
6.3 使用Zookeeper类创建Zookeeper客户端实例
直接在
main
方法里new
一个客户端!
- 初始版本:根据shell端客户端成功连接服务端时,服务端watcher的打印日志,在java里写一个watcher:
- 存在的问题:
- 上述程序启动后,Java会在
main
方法里开俩异步守护线程用于Zookeeper客户端实例的创建:
1)sendThread
:负责客户端向服务端发送请求的线程
2)eventThread
:负责接受服务端数据响应的线程,包括事件也在这里处理
main
方法一结束,主线程结束,俩守护线程也就跟着挂了
- 改进:
- 使用CountDownLatch解决主线程挂掉后守护线程不继续跑的问题:
mian
方法最后一行记得加一个睡眠一直等待,避免main
方法自动结束掉:TimeUnit.SECONDS.sleep(Integer.MAX_VALUE);
6.4 在服务端创建一个带数据的节点
- zk的
create()
方法是一个同步的阻塞的方法:
- 写数据活用Spring提供的
ObjectMapper
对象序列化工具:
- 将对象输出到字节数组中:
byte[] arr = ObjectMapper.writeValueAsBytes(对象实例);
- 从字节数组中取出数据并封装成一个指定类的实例:
类名 实例 = ObjectMapper.readValue(new String[arr], 类名.class);
6.5 读取节点下数据并对该节点的数据实现循环监听
- 相关代码:
- 循环监听下的动态感知:只要
/myconfig
节点的数据发生变化,当前main
方法里就会打印该节点的变化信息:
6.6 相关API
- 同步方法:
- 使用Stat和zk的
getData()
、setData()
方法实现乐观锁:
- 删除节点:
delete(节点路径, version)
version = -1
时:代表匹配所有版本,直接删除version > -1
时:代表删除指定数据版本的节点
- 异步方法:
getData(节点路径, boolean watch, DataCallback cb, Object ctx)
- 释义:开启异步事件线程,处理
getData()
对应的指定节点数据读取操作 - 参数说明:
watch
:是否采用监听器cb
:方法回调器ctx
:上下文对象,供cb
中process()
方法做入参使用(一般就是你想设置的节点默认数据值了)
- 代码示例:
七、Curator客户端实战
7.1 介绍
- 定义:Curator是阿帕奇开源的Zookeeper框架,是一个分布式框架,包括断开重连、事件监听、创建节点时循环创建父节点的功能
- 适用场景:很多支持链式编程的场景
7.2 关于僵尸节点
- 概念:
- 又叫幽灵节点,是指客户端向服务端发起创建节点请求过程中,由于网络抖动等通信问题导致未收到服务端成功创建的通知,而进行创建重试。
- 而只要客户端此次和服务端的连接一直存活,当前sessionID持续有效,服务端就不会清理掉最早创建的那个节点,因此该节点就成为了僵尸节点
- 如何解决:使用curator的安全模式(Protection模型)进行重试
7.3 依赖配置
<dependency>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
<version>3.5.8</version>
</dependency>
7.4 代码实现
- 创建客户端的方式:两种,后者使用更多,更推荐:
- 通过工厂类的
newClient()
静态方法初始话一个zookeeper实例:CuratorFrameworkFactory.newClient(zookeeperConnectionString, retryPolicy)
:zookeeperConnectionString
:待访问服务端的ip
以及端口(可以是nginx等负载均衡设备的ip
和端口)retryPolicy
:重试策略,可以使用RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3)
初始化一个策略,内部参数释义如下:1000
:距离上次失败后进行重试的间隔时间(毫秒)3
:重试最大次数
- 链式编程的方式:
更推荐链式编程的原因:
1)因为CuratorFramework
类的实现类CuratorFrameworkImpl
中静态成员很多,通过建造者模式可以避免连接信息在构造完成后随意修改的情况,更加安全
- 创建一个节点:
curator
的该方法并不能避免僵尸节点(幽灵节点)的问题
- 递归创建子节点:
curator
的该方法并不能避免僵尸节点(幽灵节点)的问题
- 获取子节点:
curatorFramework.getChildren()
- 删除节点:使用
guaranteed()
方法确保删除节点成功,并自动进行删除失败的重试,尽可能避免通信异常的影响:
- 后台处理:使用自定义线程池来作为守护线程进行getData等后台的任务处理:
后台处理任务不再在
eventThread
里执行,进一步提高了效率
- 使用安全模式实现通信异常下的重试机制:
- 原理:
curator
客户端创建节点时将UUID
缓存到本地,重试前需判断缓存中是否有UUID
,有的话就看有没有UUID
前缀的临时节点呢,有,就不重试了 - 优点:可以有效避免因为网络抖动等通信异常情况,导致客户端未收到服务端创建成功的通知,而遗留僵尸节点(幽灵节点)的情况
- 相关代码:
7.5 使用回调处理的优势
可以将耗时长的处理放到回调方法里,在监听到指定事件后由线程池统一处理,提高了性能
八、Zookeeper集群环境搭建
8.1 架构示例
8.2 zk节点的两种类型、三种角色、四种状态
- 相关源码:
- 选举出Leader之前想看节点状态:对应源码中的
QuorumPeer.LearnerType
枚举类 - 选举出Leader之后想看节点类型:对应源码中
ServerState
枚举类
- 不同阶段涉及的节点类型、角色、状态:
阶段 | 节点类型 | 扮演角色 | 所处状态 | 职责描述 | 备注 |
---|---|---|---|---|---|
选举出Leader之前 | OBSERVER类型 | observer | Observing状态 | 观察者,负责读数据,减轻服务端写数据的压力 | 不可参与选举 |
PARTICIPANT类型 | 无明确角色 | 初始Looking状态 | 选举参与者,并不是明确的角色 | 此时集群中尚未选举出Leader,待参与选举的节点默认是该状态 | |
选举出Leader之后 | OBSERVER类型 | observer | Observing状态 | 观察者,负责读数据,减轻服务端写数据的压力 | 不可参与选举 |
PARTICIPANT类型 | leader | Leading状态 | 负责写请求、读数据 | 可参与选举 | |
PARTICIPANT类型 | follower | Following状态 | 负责读数据,一旦leader失效,follower可以参与选举,有可能成为新的Leader |
8.3 集群下写操作过程
- leader接收到客户端写请求后,会将事务日志同步给其余的follower节点。
- follower节点会将操作结果返回给leader,只有当连同leader在内的超过半数的节点(不包含observe)写操作成功后才会将数据保存到内存中
8.4 搭建过程
集群中可参与选举的最少3个节点!!
- 为每一个服务分配一个唯一的服务
id
:
- 在
cfg
文件中新增当前服务实例的ip
地址和端口:
- 端口要定义两个,第一个端口用来对外提供服务,第二个端口用于leader选举:
注意:
1)图片里写错了,不是供外部访问的端口,而是集群内部访问的端口
2)clientPort=2181
才算是供外部访问的端口,也就是供客户端访问的端口
3)server.1
中的这个1
是myid
为1的节点供其他节点访问的sid
(sid
即sendID
的意思)
- 配好一个再复制到其他服务实例的
cfg
文件中,并修改对应端口、角色、数据存储路径
- 重启所有服务节点
8.5 进行连接
一共两种方式:
- 可以连接集群中的单一节点:
zkCli.sh -server 服务实例ip:供客户端连接的端口
注意是客户端的连接端口,不是供外部访问的端口或选举端口!!
- 也可以配置上所有节点的ip和端口,由集群随机选择一个节点供客户端连接:
- 好处:一旦集群中主节点挂掉后,只要存活可选举节点个数超过半数,就能正常供客户端访问,大大提高了可用性
可参与选举的节点不包含observe节点
8.6 代码实现
- 使用zk官方自带集群客户端:
缺陷:没有相应的异常处理,需人工加
- 使用curator的zk集群客户端:推荐
九、Zookeeper实现分布式锁的原理
9.1 前言
- 锁:基于单进程的锁:synchronized、lock等
- 锁的特性:独占性
- 使用Zookeeper实现分布式锁的优势:
- Zookeeper的节点是唯一的,具备锁的特性(独占性)
- Zookeeper提供了对节点的监听机制,一旦有线程删除掉节点,相关监听器就会被触发
通过
ls -s
、ls -w
、get -w
等命令实现监听
9.2 实现方案
9.2.1 非公平锁(独占锁、互斥锁)
注意,zk不会去实现非公平锁,最起码是公平锁!此处仅作展示!!
- 原理图:
- 缺陷:
- 无法控制多线程竞争锁的先后顺序,做不到先来后到(
FIFO
)
这也是被称为非公平锁的原因
- 会导致羊群效应(惊群效应):所有的连接都在对同一个节点进行监听,当服务器检测到删除事件时,要通知所有的连接,所有的连接同时收到事件,再次并发竞争,zk压力大,性能较差
关于羊群效应:羊盲目从众,都在争抢着做一件事情
9.2.2 公平锁(独占锁)
- 原理图:
- 原理:
- a.请求进来,直接在
/lock
节点下创建一个临时顺序节点 - b.获取
/lock
节点下所有临时顺序节点,并判断当前临时顺序节点是不是/lock
节点下的编号最小的节点:- 是最小,获得锁
- 不是最小,对紧挨着的前一个节点进行监听
- c.如果当前临时顺序节点获得锁,则对应请求执行完业务操作,释放锁:删除当前最小节点
- d.如果当前临时顺序节点未获得锁,则进行等待,直到监听到前一个节点的删除事件,则重复步骤b
- e.当
/lock
节点下没有子节点时,删除/lock
节点- 实现方式:可以考虑将
/lock
节点使用容器节点来实现 - 相比于使用持久化节点的优势:容器节点的删除可以交给zk服务端的定时任务完成,无需另写逻辑去删除
/lock
节点
- 实现方式:可以考虑将
/lock
下的所有临时顺序节点的编号一定是连续的么?如果不是,是否还能保证锁的公平性?还能保证锁机制?
- 不一定连续,zk允许出现
/lock
节点下某个临时顺序子节点被意外删除/失效的情况 - 依旧可以保证是公平锁。因为当某个节点因为意外删除/失效时,会触发对它进行监听的后面一个节点的监听事件。根据zk公平锁流程,后面一个节点监听到前一个节点发生变化,那么就会
get
/lock
节点下的最小节点,如果是自己,那就意味着获得了锁,如果不是,就监听此时前面的一个节点即可。
- 优势:
- 可以控制多线程竞争锁的先后顺序,即便是临时节点顺序出现不一致的情况,也能确保后一个节点始终监听前一个节点,从而做到先来后到
- 依靠节点的唯一性,可以使用安全模式(或者是配合
UUID
)消灭掉僵尸节点
僵尸节点见上文
9.2.3 读写锁(共享锁)
- 定义:是zk公平锁的升级版本,既保证了公平,又保证了并发读的性能
- 实现的首要难题:要解决高并发下读写不一致的问题:
- 写完后删缓存,并发读写下的不一致问题:
- 线程1先写数据库,然后删除缓存对应值,线程3此时有个读请求进来,发现缓存没值则将请求打到数据库,访问到数据库数据后,可能由于要执行其他业务或卡住等,没能及时更新缓存;在此期间,线程2来了个写操作并删除缓存(删了寂寞),完成后恰好线程2恢复过来进行更新缓存操作了,这就导致缓存和数据库并发读写下的不一致问题:
可参考【Redis缓存设计与性能优化】
- 原理图:
- 优势:读读无锁,读写互斥,高并发读请求下性能强劲
9.3 使用Redis实现分布式独占锁(互斥锁、非公平锁)和使用zk实现分布式独占锁(互斥锁、非公平锁)的区别、优缺点
9.3.1 在写数据方面
- redis的集群部署存在锁失效的问题:
- 问题描述:redis无论是哨兵架构还是cluster,最小的组成单位都是小主从集群。当客户端在master加锁成功,并由master返回加锁成功给客户端时,如果此时master尚未同步从节点且自身由于网络抖动等通信故障而使得小主从集群重新选举出新master(无论在此过程中是否出现了脑裂),那么之前的锁数据就丢失了,之前客户端线程的锁即失效
- 解决方案:为了提升锁的可靠性,我们可以在每个节点的配置文件
.conf
中添加min‐replicas‐to‐write n
的配置来预防脑裂的问题,进而规避锁失效的问题
- zk集群在写数据时,通常可靠性更高:
- zk集群在写数据时只有集群中超过一半的节点写成功,此次操作才成功;对于未写成功的节点,后续也会同步写成功,从而达到最终一致性
- 如果某一时刻集群中已经有线程获取到了独占锁,且某一个节点和别的节点数据不一致,此时恰好有另一个请求进来该节点想获取独占锁,那么仅有该节点写成功、其余节点因为zk node的唯一性而写失败的话,后来的请求是无法获取到锁的,从而保证了锁的可靠性
9.3.2 在性能方面
- redis集群中客户端每个请求只会和一个小主从集群中的master交互,且通常不配置最小同步从节点个数,写完即返回;而zk leader-follower 集群遵从ZAB协议,必须要写超过整个集群一半节点才算成功,同样大集群的个数下redis明显有优势
- 而且即便要求redis小主从集群写的节点数超过小主从集群节点的半数,其性能也远比leader-follower集群模式下高(毕竟redis写的少呀。。)
综合上,无论是redis实现还是zk实现,毕竟是独占锁,对并发读取数据而言性能很差,因此还是建议使用读写锁(共享锁)
十、Spring Boot + Zookeeper + Curator客户端实现分布式锁的实战
以下使用负载均衡集群来实现秒杀场景
10.1 环境准备
- 部署nginx+zk集群(zk一主一从,使用nginx实现负载均衡):nginx配置如下:
- 压力测试:
- 工具:JMeter
- 再讲使用方式:
- TestPlan → add → Threads (Users) → Thread Group新建线程组
- 模拟10个用户发起一次并发的访问:
Number of threads 10
10.2 代码实现
10.2.1 初始版本
没有任何并发控制,没加任何锁:
10.2.2 升级版本V1.0
1)使用InterProcessMutex实现分布式公平锁
- 创建Curator实例:
CuratorFrameworkFactory.newClient(zookeeperConnectionString, retryPolicy)
zookeeperConnectionString
:待访问服务端的ip
以及端口(可以是nginx等负载均衡设备的ip
和端口)retryPolicy
:重试策略,可以通过如下方式初始化一个实例:RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3)
,具体参数解释:1000
:距离上次失败后进行重试的间隔时间(毫秒)3
:重试最大次数
- 使用Curator实例进行库存扣减:
2)InterProcessMutex源码解析
InterProcessMutex
构造方法:核心是new StandardLockInternalsDriver()
中的StandardLockInternalsDriver
InterProcessMutex.acquire()
方法是加锁的主逻辑:
- 进入
InterProcessMutex
的internalLock()
方法内部:
- 进入
attemptLock()
方法内部:
- 创建的最外层父亲节点(类似
/lock
)是一个容器节点 - 期间调用了
withProtection()
方法,使用安全模式,避免了幽灵节点的产生 - 末尾会调用一个
forPath()
方法,内部会有具体的幽灵节点如何避免的逻辑,具体如下:- 进入
protectedPathInForeground()
方法,再进入pathInForeground()
方法:- 内部逻辑过于复杂,郭嘉老师没讲。。。
- 总之,客户端在安全模式下,创建节点时会给节点加上UUID的前缀,并将UUID缓存到客户端
- 客户端因为网络抖动等通信异常情况进行重试时,会在再次创建节点前会先看父节点下是否已经有执行UUID前缀的节点,如果有了,就不重复创建了,从而规避了幽灵节点
- 服务端查看当前锁节点的名称:
- 创建的最外层父亲节点(类似
- 从
attemptLock()
方法内部,进入internalLockLoop()
方法内部: - 进入
getSortedChildren()
方法:
问题:为什么已经是顺序节点了,拿的时候还要排序?
1)因为curator访问的zk集群不能保证节点顺序返回给客户端,所以要人工排序下
- 获取子节点名称:
- 判断当前子节点是否是所有节点中编号最小的:
maxLeases
可以理解为同一时间集群中可以存在的锁的个数,这样更好理解点
- 获取到锁或是监听、等待:
- watcher具体实现:
10.2.2 升级版本V2.0
1)使用InterProcessReadWriteLock实现分布式读写锁
- 构造方法:
InterProcessReadWriteLock(Client client, String basePath, byte[] lockData)
:
client
:zk的客户端,此处用Curator实例basePath
:加锁路径,也就是父亲节点(/lock
节点)的路径lockData
:锁节点对应的数据,可有可无
2)InterProcessReadWriteLock底层源码
- 内部定义了两个锁对象:读锁和写锁:
- 构造方法内部对两个锁对象进行实例化:
- 区别在于
driver
中判断加锁成功的逻辑getsTheLock()
方法:
具体代码定位:从
acquire()
方法定位到internalLockLoop()
方法内部,直到【判断当前子节点是否是所有节点中编号最小的】getsTheLock()
方法
- 写锁就用的父类逻辑,也就是公平锁逻辑:
- 读锁用的子类特有逻辑:
十一、Leader选举在分布式场景中的应用
以下主要围绕
LeaderSelectorListener
实现zk选主来说明
11.1 测试方法
11.1.1 代码实现
11.1.2 说明
autoRequeue()
可以确保能够在集群中主节点挂掉后重新选举出主节点- 最好在监听器内加个睡眠时间(
TimeUinit.SECONDS.sleep(5)
),这样休眠期间Leader权限就会释放掉,便于观察集群中Leader选举效果 selector.start()
是在另一个线程中执行的,最好用CountDownLatch
加个锁,main
方法执行到最后进行等待:
- 声明CountDownLatch成员:
- 执行到最后加锁等待:
- 记得启动Curator客户端哇:
11.2 源码解析
- 进入
selector
的start()
方法,进入其中requeue
()方法,再定位到internalRequeue()
:
- 接着进入
doWorkLoop()
方法,定位doWork()
内部:
- 先尝试获取锁:
- 获取到锁后,执行之前监听器中的
takeLeaderShip()
方法:
autoRequeue.get()
初始设置为true
,所以会循环执行internalReque()
方法
十二、Spring Cloud Zookeeper注册中心实战
- zk实现注册中心:
- 原理图:
- 需引入依赖:
- Apache Zookeeper Discovery
- Spring Web
- 版本:JDK版本 1.8
- 负载均衡:使用Spring Cloud LoadBalancer
如果不使用这个,默认会去用Ribborn
-
搭建环境:
-
Spring Cloud Zookeeper的服务默认会在zk下创建一个
/services
节点:
不想记了,这tm要命啊学的。。。。