数据模型znodeZookeeper技术内幕
zk 数据存储结构与标准的 Unix 文件系统非常相似,都是在根节点下挂很多子节点。zk中没有引入传统文件系统中目录与文件的概念,而是使用了称为 znode 的数据节点概念。znode 是 zk 中数据的最小单元,每个 znode 上都可以保存数据,同时还可以挂载子节点,形成一个树形化命名空间。
节点类型
- 持久节点:zk 中最常见的节点,节点一旦被创建,只要不删除,其就会一直存在于 zk中。
- 持久顺序节点:一个父节点可以为其直接子节点维护一份顺序,用于记录子节点创建的先后顺序。在创建子节点时,会自动在指定的节点名称后添加数字后辍,用为该子节点的完整名称。序号由 10 位数字组成,从 0 开始计数。
- 临时节点:临时节点的生命周期与客户端的会话绑定在一起,会话消失则该节点也就会消失。临时节点只能做叶子节点,不能创建子节点。
- 临时顺序节点:添加了创建序号的临时节点
节点状态
- cZxid:Created Zxid,表示当前 znode 被创建时的事务 ID。
- ctime:Created Time,表示当前 znode 被创建的时间。
- mZxid:Modified Zxid,表示当前 znode 最后一次被修改时的事务 ID。
- mtime:Modified Time,表示当前 znode 最后一次被修改时的时间。
- pZxid:表示当前 znode 的子节点列表最后一次被修改时的事务 ID。注意,只能是其子节点列表变更了才会引起 pZxid 的变更,子节点内容的修改不会影响 pZxid。
- cversion:Children Version,表示子节点的版本号。该版本号用于充当乐观锁。
- dataVersion:表示当前 znode 数据的版本号。该版本号用于充当乐观锁。
- aclVersion:表示当前 znode 的权限 ACL 的版本号。该版本号用于充当乐观锁。
- ephemeralOwner:如果节点是持久节点值为0,如果是临时节点值就为临时节点的会话sessionid。当会话消失后,会根据sessionid来删除这个临时节点。
- dataLength:当前 znode 中存放的数据的长度。
- numChildren:当前 znode 所包含的子节点的个数。
会话
会话是 zk 中最重要的概念之一,客户端与服务端之间的任何交互操作都与会话相关。ZooKeeper 客户端启动时,首先会与 zk 服务器建立一个 TCP 长连接。连接一旦建立,客户端会话的生命周期也就开始了。
会话状态
常见的会话状态有三种:
CONNECTING:连接中。Client 要创建一个连接,其首先会在本地创建一个 zk 对象,用于表示其所连接上的Server。从zk对象被创建开始,会话状态就进入到了CONNECTING,同时 Client 会从 Server 服务列表中通过轮询方式逐个尝试连接,直到连接成功。注意,在轮询之前,其首先会将列表进行随机打散,然后再在打散的列表基础上进行轮询。
CONNECTED:已连接。连接成功后,该连接的各种临时性数据会被初始化到 zk 对象中。
CLOSED:已关闭。连接关闭后,这个代表 Server 的 zk 对象会被删除。
会话连接超时管理—客户端维护
我们这里的会话连接超时管理指的是,客户端所发起的服务端连接时间记录,是从客户端当前会话第一次发起服务端连接的时间开始计时。
会话空闲超时管理—服务端维护
服务器为每一个客户端的会话都记录着上一次交互后空闲的时长,及从上一次交互结束开始会话空闲超时的时间点。一旦空闲时长超时,服务端就会将该会话的 SessionId 从服务端清除。这也就是为什么客户端在空闲时需要定时向服务端发送心跳,就是为了维护这个会话长连接的。服务器是通过空闲超时管理来判断会话是否发生中断的。
服务端对于会话空闲超时管理,采用了一种特殊的方式——分桶策略。
- 分桶策略
分桶策略是指,将空闲超时时间相近的会话放到同一个桶中来进行管理,以减少管理的复杂度。在检查超时时,只需要检查桶中剩下的会话即可,因为没有超时的会话已经被移出了桶,而桶中存在的会话就是超时的会话。
zk 对于会话空闲的超时管理并非是精确的管理,即并非是一超时马上就执行相关的超时操作。
- 分桶依据
java //(当前时间 / 会话超时默认时间 + 1) * 会话默认超时时间 BucketTime = (ExpirationTime/ExpirationInterval + 1) * ExpirationInterval
- touchSession()*调用关系*
源码阅读
客户端连接ZK服务端时
处理客户端连接请求
如果不是重连情况,那就创建新会话
删除会话
会话连接事件
客户端与服务端的长连接失效后,客户端将进行重连。在重连过程中客户端会产生三种会话连接事件:
CONNECTION_LOSS:连接丢失。因为网络抖动等原因导致客户端长时间收不到服务端的心跳回复,客户端就会引发“连接丢失事件”。该事件会触发当前客 户端重连服务端,直到重连成功,或重连超时。
SESSION_MOVED:会话转移。当发生“连接丢失事件”后,若客户端在连接超时时限内重连服务端成功,此时当前会话的 id 是没有发生变化的。若服务器检 测到同一个SessionId 的会话,两次连接到的不是同一个 zk 主机,那么服务端就会引发“会话转移异常”,客户端会引发“会话转移事 件”。该事件会触发当前客户端使用第二次连接上的主机的 IP 来与 Server 进行交互。
SESSION_EXPIRED:会话失效。若服务端发现某客户端的会话空闲时间超时,那么服务器就会将该客户端会话进行清除。对于客户端来说,其长时间没有 收到服务端的心跳回复,则会引发“连接丢失事件”,然后进行重连,直到连接成功或超时。但在会话已经从服务端清除完毕,而重连又 未超时的一个很短暂的时间缝隙中,客户端与服务连接成功了。此时客户端就会引发“会话失效事件”。该事件会触发客户端取消该连 接,并使客户端重新实例化 zk 对象,即重新使用新的 SessionId 进行重连。
ACL
ACL 简介
ACL 全称为 Access Control List(访问控制列表),是一种细粒度的权限管理策略,可以针对任意用户与组进行细粒度的权限控制。zk 利用 ACL 控制 znode 节点的访问权限,如节点数据读写、节点创建、节点删除、读取子节点列表、设置节点权限等。
UGO,User、Group、Others,是一种粗粒度的权限管理策略。
zk 的 ACL 维度
Zookeeper 的 ACL 分为三个维度:
- 授权策略 scheme(采用何种方式授权)
- world:默认方式,相当于全部都能访问
- auth:代表已经认证通过的用户(cli中可以通过addauth digest user:pwd 来添加当前上下文中的授权用户)
- digest:即用户名:密码这种方式认证,这也是业务系统中最常用的。用 username:password 字符串来产生一个MD5串,然后该串被用来作为ACL ID。认证是通过明文发送username:password 来进行的,当用在ACL时,表达式为username:base64
- ip:使用客户端的主机IP作为ACL ID 。这个ACL表达式的格式为addr/bits
- 授权对象 ID(给谁授予权限)
- 授权对象ID是指,权限赋予的用户或者一个实体,例如:IP 地址或者机器。授权模式 schema 与 授权对象 ID 之间
- 用户权限 permission(授予什么权限)
CREATE、READ、WRITE、DELETE、ADMIN 也就是 增、删、改、查、管理权限,这5种权限简写为crwda
- CREATE: c 可以创建子节点
- DELETE: d 可以删除子节点(仅下一级节点)
- READ:
- WRITE:
- ADMIN:
子 znode 不会继承父 znode 的权限。
Watcher 机制
zk 通过 Watcher 机制实现了发布/订阅模式
watcher 工作原理
watcher 事件
对于同一个事件类型,在不同的通知状态中代表的含义是不同的。
watcher 特性
zk 的 watcher 机制具有以下几个特性
- 一次性:一旦一个 watcher 被触发,zk 就会将其从客户端的 WatcherManager 中删除,服务端中也会删除该 watcher。zk 的 watcher 机制不适合监听变化非常频繁的场景。
- 串行性:对同一个节点的相同事件类型的 watcher 回调方法的执行是串行的。
- 轻量级:真正传递给 Server 的是一个简易版的 watcher。回调逻辑存放在客户端,没有在服务端。
客户端操作
下载zk客户端工具
https://issues.apache.org/jira/secure/attachment/12436620/ZooInspector.zip
连接上zk服务
新建ZK节点
新建节点默认为持久节点
ZKClient 客户端
ZkClient 是一个开源客户端,在 Zookeeper 原生 API 接口的基础上进行了包装,更便于开发人员使用。内部实现了 Session 超时重连,Watcher 反复注册等功能。像 dubbo 等框架对其也进行了集成使用。
API 介绍
创建会话
查看这些方法的源码可以看到具体的参数名称,这些参数的意义为:
| 参数名 | 作用 | | :-------------------: | :----------------------------------------------------------: | | zkServers | 指定 zk 服务器列表,由英文状态逗号分开的 host:port 字符串组成 | | connectionTimeout | 设置连接创建超时时间,单位毫秒。在此时间内无法创建与 zk 的连接,则直接放弃连接,并抛出异常 | | sessionTimeout | 设置会话超时时间,单位毫秒 | | zkSerializer | 为会话指定序列化器。zk 节点内容仅支持字节数组(byte[])类型,且 zk 不负责序列化。在创建 zkClient 时需要指定所要使用的序列化器,例如 Hessian 或 Kryo。默认使用 Java 自带的序列化方式进行对象的序列化。当为会话指定了序列化器后,客户端在进行读写操作时就会自动进行序列化与反序列化。 | | connection | IZkConnection 接口对象,是对 zk 原生 API 的最直接包装,是和 zk最直接的交互层,包含了增删改查等一系列方法。该接口最常用的实现类是 zkClient 默认的实现类 ZkConnection,其可以完成绝大部分的业务需求。 | | operationRetryTimeout | 设置重试超时时间,单位毫秒 |
创建节点
| 参数名 | 作用 | | :-----------: | :----------------------------------------------------------: | | path | 要创建的节点完整路径 | | data | 节点的初始数据内容,可以传入 Object 类型及 null。zk 原生 API中只允许向节点传入 byte[]数据作为数据内容,但 zkClient 中具有自定义序列化器,所以可以传入各种类型对象。 | | mode | 节点类型,CreateMode 枚举常量,常用的有四种类型。PERSISTENT:持久型。PERSISTENT_SEQUENTIAL:持久顺序型。EPHEMERAL:临时型。EPHEMERAL_SEQUENTIAL:临时顺序型 | | acl | 节点的 ACL 策略 | | callback | 回调接口 | | context | 执行回调时可以使用的上下文对象 | | createParents | 是否级递归创建节点。zk 原生 API 中要创建的节点路径必须存在,即要创建子节点,父节点必须存在。但 zkClient 解决了这个问题,可以做递归节点创建。没有父节点,可以先自动创建了父节点,然后再在其下创建子节点。 |
删除节点
| 参数名 | 作用 | | :-----: | :--------------------------: | | path | 要删除的节点的完整路径 | | version | 要删除的节点中包含的数据版本 |
更新数据
| 参数名 | 意义 | | :-------------: | :--------------------------: | | path | 要更新的节点的完整路径 | | data | 要采用的新的数据值 | | expectedVersion | 数据更新后要采用的数据版本号 |
检测节点是否存在
| 参数名 | 作用 | | :----: | :-----------------------------------------------: | | path | 要判断存在性节点的完整路径 | | watch | 要判断存在性节点及其子孙节点是否具有 watcher 监听 |
获取节点数据内容
| 参数名 | 作用 | | :-----------------------: | :----------------------------------------------------------: | | path | 要读取数据内容的节点的完整路径 | | watch | 指定节点及其子孙节点是否具有 watcher 监听 | | returnNullIfPathNotExists | 这是个 boolean 值。默认情况下若指定的节点不存在,则会抛出 KeeperException$NoNodeException为 true,若指定节点不存在,则直接返回null 而不再抛出异常 | | stat | 指定当前节点的状态信息。不过,执行过后该 stat 值会被最新获取到的 stat 值给替换。 |
获取子节点列表
| 参数名 | 意义 | | :----: | :-----------------------------------------------------: | | path | 要获取子节点列表的节点的完整路径 | | watch | 要获取子节点列表的节点及其子孙节点是否具有 watcher 监听 |
watcher 注册
ZkClient 采用 Listener 来实现 Watcher 监听。客户端可以通过注册相关监听器来实现对zk 服务端事件的订阅。
可以通过 subscribeXxx()方法实现 watcher 注册,即相关事件订阅;通过 unsubscribeXxx()方法取消相关事件的订阅。
| 参数名 | 意义 | | :--------------: | :----------------------------------------------------------: | | path | 要操作节点的完整路径 | | IZkChildListener | 子节点数量变化监听器 | | IZkDataListener | 数据内容变化监听器 | | IZkStateListener | 客户端与zk的会话连接状态变化监听器,可以监听新会话的创建、会话创建出错、连接状态改变。连接状态是系统定义好的枚举类型 Event.KeeperState 的常量 |
代码演示
依赖
<!--zkClient依赖-->
<dependency>
<groupId>com.101tec</groupId>
<artifactId>zkclient</artifactId>
<version>0.10</version>
</dependency>
代码
import org.I0Itec.zkclient.IZkDataListener;
import org.I0Itec.zkclient.ZkClient;
import org.I0Itec.zkclient.serialize.SerializableSerializer;
import org.apache.zookeeper.CreateMode;
public class ZKClientTest {
// 指定zk集群
private static final String CLUSTER = "zkOS1:2181,zkOS2:2181,zkOS3:2181,zkOS4:2181";
// private static final String CLUSTER = "zkOS:2181";
// 指定节点名称
private static final String PATH = "/mylogtest";
public static void main(String[] args) {
// ---------------- 创建会话 -----------
// 创建zkClient
ZkClient zkClient = new ZkClient(CLUSTER);
// 为zkClient指定序列化器
zkClient.setZkSerializer(new SerializableSerializer());
// ---------------- 创建节点 -----------
// 指定创建持久节点
CreateMode mode = CreateMode.PERSISTENT;
// 指定节点数据内容
String data = "first log";
// 创建节点
String nodeName = zkClient.create(PATH, data, mode);
System.out.println("新创建的节点名称为:" + nodeName);
// ---------------- 获取数据内容 -----------
Object readData = zkClient.readData(PATH);
System.out.println("节点的数据内容为:" + readData);
// ---------------- 注册watcher -----------
zkClient.subscribeDataChanges(PATH, new IZkDataListener() {
@Override
public void handleDataChange(String dataPath, Object data) throws Exception {
System.out.print("节点" + dataPath);
System.out.println("的数据已经更新为了" + data);
}
@Override
public void handleDataDeleted(String dataPath) throws Exception {
System.out.println(dataPath + "的数据内容被删除");
}
});
// ---------------- 更新数据内容 -----------
zkClient.writeData(PATH, "second log");
String updatedData = zkClient.readData(PATH);
System.out.println("更新过的数据内容为:" + updatedData);
// ---------------- 删除节点 -----------
zkClient.delete(PATH);
// ---------------- 判断节点存在性 -----------
boolean isExists = zkClient.exists(PATH);
System.out.println(PATH + "节点仍存在吗?" + isExists);
}
ZK连接是的shuffle源码阅读
总结
- 【Q-01】一个 zk 客户端到底连接的是 zk 列表中的哪台 Server?请谈一下你的看法。
答:
- 首先,在连接服务的时候会把连接zk的地址解析成一个ArrayList列表。
- 解析成列表后会使用Collections.shuffle(resultList)将解除好的列表打撒(随机排序里面的数据)-》此时的数据是:主机名+端口
- 然后再把列表里面的数据依次解析成IP+端口,解析完后会返回一个列表,然后再次将这个列表打散一次。
- 服务节点连接zk的时候就拿第二次打散列表里面的第一条数据。
- 【Q-02】(追问)为什么要打散?请谈一下你的看法。
答:
如果不打散,所有的服务节点都总是会连接到第一台zk上,这会导致第一台部署zk的服务器压力过大。
- 【Q-03】(再追问,看来是打破砂锅了 )zk 客户端指定的要连接的 zk 集群地址,会被 shuffle几次?请谈一下你的看法。
答:
如果连接字符串是主机名:会被shuffle一次,如果是ip:会再次被shuffle
第一次shuffle的是主机名+端口,第二次shuffle是解析主机名后获取到主机的IP,IP+端口。
第一次shuffle:所有的服务节点都总是会连接到第一台zk上,这会导致第一台部署zk的服务器压力过大。
第二次shuffle:解析完主机名的IP+端口。一个主机可以映射出多个虚拟IP。所以再次打散,避免单台服务器压力过大。
- 【Q-04】第一个客户端将 zk 列表打散后,在打散的列表上采取轮询方式尝试连接。那么,第二个客户端又来连接 zk 集群,其是在前面打散的基础上采用轮询方 式选择 Server,还是又重新打散后再进行轮询连接尝试?
答:
肯定是又重新打散,因为不同的客户端去连接zk时,会重新执行连接zk的代码,所以只要代码被执行一次,那么集群的主机又被再一次被打散。
- 【Q-09】zk 客户端维护着会话超时管理,请谈一下你对此的认识。
答:
在服务节点连接上zk后,zk会初始化一个会话超时时间,该时间是从jvm中获取的(即使改了系统时间也不会受影响),该时间的开始时间就是服务节点连上zk的时间。
- 【Q-10】zk 时 CP 的,zk 集群在数据同步或 leader 选举时是不对外提供服务的,那岂不是用户体验非常不好?请谈一下你对此的看法
答:
其实也不完全是用户体验不好,在分布式系统中,CAP定理只能遵循CP或者AP。zk遵守CP的优点就在于虽然服务是短暂性的不可用,但是最终的数据是一定正确的。当然其实在执行leader选举也是非常快的。在leader选举的过程中,客户端几乎是感知不到的。
像Eureka的话就是遵循的AP,当集群里面的部分节点挂了之后,是有缓存的,仍然可以提供服务。但是数据正确性就不能保证了。
- 【Q-11】zk Client 在连接 zk 时会发生连接丢失事件,什么是连接丢失?请谈一下你的认识。
答:
这个会发生在网络不稳定情况下,就是Clinet给Server发送心跳。其实Server是活着的,只不过是短暂的网络不稳定情况导致Server没有接收到Clinet的心跳。而此时Clinet没有收到Server的心跳,则会认为Server端挂了。就导致了连接丢失。
- 【Q-12】zk Client 在连接 zk 时会发生会话转移事件,什么是会话转移?请谈一下你的认识。
答:
会话转移就是再会话过期时间内。从当时时间计算超时时间,如果算出来的超时时候在另外一个桶,那么当前会话就会从一个桶换到另外一个新桶。
- 【Q-13】zk Client 在连接 zk 时会发生会话失效事件,什么是会话失效?请谈一下你的认识。
答:
会话失效将会被删除,指的是在指定时间内没有通信过了,然后桶的过时时间也达到边界值,此时客户端还未与客户端成功通信,那么此时桶将会被删除,随之而然的桶中的会话也会被删除。
- 【Q-14】zk 中的会话空闲超时管理采用的是分桶策略。请谈一下你对分桶策略的认识。
答:
- 【Q-15】zk 中的会话空闲超时管理采用的是分桶策略。什么是会话桶?里面存放的是什么?从源码角度请谈一下你的认识。
答:
简单说就是一个Map集合,将会话的sesssion作为key。会话的超时时间作为value。每次发生会话后,计算出session的超时时间,如果超时时间没有超出当前所在桶的边界值,那么就不需要换桶,如果超过了就需要将该session移动到另外一个过期时间边界值的桶里面。
- 【Q-16】zk 中的会话空闲超时管理采用的是分桶策略。会话桶中的会话会发生换桶,什么时候会进行换桶?如何换桶呢?从源码角度请谈一下你的认识
答:
- 当桶的边界值不然容忍当前会话过期时间的时候会进行换桶。
- 会话进行换桶的时候首先会对session进行校验,看session是否存在,session的过期时间是否小于当前桶(如果当前会话的过期时间超过了桶的边界值,那明显就是错误的。)
- 校验通过后
- 把会话从原来的桶中删除
- 获取新的会话桶ID,如果不存在则创建桶
- 创建后将会话放入桶中
- 【Q-17】zk 中的会话空闲超时管理采用的是分桶策略。该分桶策略中会话空闲超时判断发生在哪里?超时发生后的处理发生在哪里?都做了哪些处理呢?请谈一 下你的认识。
答:
其实就是有一个异步线程,run方法里面是个while循环。只要服务端是运行状态,它就是一直循环,它里监听的是桶的边界值是否大于当前时间,如果没有大于那么就会等待(使用了object的wait方法),直到这个桶过期然后把它删除。