一、客户端会话的秘密
会话,即 session,这个词语或者说概念很多地方都有用到,在 ZK 中会话指的是两个不同的机器建立了网络连接后,就可以说他们之间创建了一个会话。 ZK 的会话是有超时的概念的,当会话超时后,会由服务端主动关闭,当然客户端也可以主动请求服务端想要关闭会话。你可能会问,为什么要搞这个麻烦,直接两边连上一直用不就好了吗?有了会话这个概念就是为了防止,在建立连接后,有些客户端不常使用,早点关闭连接可以节省资源。
1.1 鸡太美的一天
我发现我好久没有 cue 鸡太美了,这次就让他再 C 位出道一次吧。
我们的鸡太美每天起床后,日常发微博、直播、跳舞、打篮球,很多事务都需要去办事处办理。
所以第一件事情就是去办事处找马果果(现在就假设马果果一个办事处)申请使用办事处(建立连接,创建会话)
而马果果会为鸡太美创建一个 ID,就是会话 ID,这个 ID (我这里假设是 19980802) 和鸡太美会进行绑定,而鸡太美在申请的同时还需要告诉马果果自己最长的超时时间是多久,我这里假设是 6000 毫秒。
而马果果这边会记录下来:
在马果果开张的时候自己本身也有一个会话的检查间隔,就是配置在 zoo.cfg
中的 tickTime
选项,我这里假设是 3000 毫秒。马果果在开张的时候会计算出一个时间轴,这个时间轴的间隔是固定的,并且不会改变。
然后马果果会通过鸡太美的 6000 以及当前的时间戳结合时间轴,计算出一个鸡太美会话超时时间点
然后会记录下来:
记录完,就算鸡太美会话创建成功了。
而马果果这边会遵循这个时间轴的节点定期对会话进行检查,假设现在的时间进行到鸡太美的时间点了
马果果会把在这个时间点的会话全部取出(记得我们上面说过,可以是多个吗?)
然后会根据 ID 信息找到对应的村民,一个个通知他们会话关闭了。
你可能会问现在因为鸡太美超时时间是 6000,而马果果超时检查是 3000,正好是整数倍,如果超时时间不是整数倍呢?要不说我们的马果果同志好学上进呢,他早就想到啦,所以设计了一个算法,无论村民的超时时间是怎么样,都会向下取整找到马果果设置的检查点。
假设鸡太美的超时间是 5900
再比如鸡太美的超时时间是 6500
所以看到了吧,以马果果的 3000 为例,只要小于 3000 的都按照 0 来算,小于 6000 的按照 3000 来算,小于 9000 的按照 6000 来算,以此类推,所以只要马果果自己的检查时间间隔确定了之后,无论是哪个村民设置了什么样的超时时间都能被向下取整至最近的统一检查点。这样马果果检查的时候就不会有太大的负担,可以统一对村民的超时时间进行检查。
但是这么做一定会造成客户端的超时时间是有误差的(通常是比设置的要短一点),减少这个误差的方式就是减小马果果的检查间隔,也就是 tickTime
参数(默认是 2000,已经够用了我觉得)。
而马果果的会话管理不会只有鸡太美一个人,我们来看看有多个村民的会话管理页长什么样吧
可以看到使用了三个哈希表去记录这些映射关系,画到时间轴是这样的
所以当时间进行到 25317000 的时候,对应三个村民就超时了,25320000 时另外两个村民就超时了。
这里我还得说下其实会话 ID 在马果果这边办事处开张后就会根据当前时间戳和 myid 初始化出一个基数,举个例子可能是 987434245 类似这种数字,之后每一个村民过来分配会话 ID 的时候,只是对这个数字不停的加 1,所以不会出现乱七八糟无序的数字,图中的数字举例仅仅是我个人的玩梗癖好,和实际情况不符~
但是这样的话,鸡太美岂不是每次 6000 毫秒就超时了吗?这当然不可能,因为村民的每一次任意的操作(增删改查)都会刷新该超时时间戳,具体怎么做的呢?我们一起来看下,假设红色箭头是会话刚创建时马果果替鸡太美计算出来的超时时间,假设在绿色箭头时间戳的地方,鸡太美执行了任意操作。
马果果会根据当前时间戳(绿色箭头处)加上鸡太美之前设置的超时时间(6000),重新计算出新的超时时间:
然后对会话管理页的数据进行修改,我仍然以多个村民的例子讲解
更新前:
更新后:
这个更新的过程可以被称为会话激活。
1.2 心跳检测
猿话一下,除了客户端每次的正常操作会刷新超时时间以外,客户端仍然需要一个机制去保持住这个会话,这个机制就是我们平时听到过的心跳检测,原理是每次客户端启动的时候也会设置一个心跳检测的间隔时间,在后台一直会去判断最后一次发送的时间戳和当前时间是否超过了该心跳检测的间隔,如果超过了就会发送一个名为 PING 的请求,由于刚刚我们说了客户端的任意操作都会刷新该超时时间,PING 也不例外,有了这个心跳机制就可以让客户端保持住和服务端的会话状态。而服务端收到 PING,除了刷新超时时间会简单的回复一个 PING 给客户端,而客户端收到服务端的 PING 会直接丢弃不需要任何其他操作。
我们以 Java 客户端为例
ZooKeeper client = new ZooKeeper("127.0.0.1:2181", 12000, null);
假设超时时间设置 12000 毫秒,那么客户端的心跳间隔就是 4000 毫秒,计算过程如下
12000 * 2 / 3 / 2 = 4000 // 这个公式是代码中的写死逻辑,其实就是 / 3
所以只要客户端空闲时间超过 4000 毫秒,就会发送一个 PING 给服务端,如果客户端的超时时间设置的非常大的话,比如半小时,那每隔 10 秒也会强制发送一个 PING(这个 10 秒是 Java 客户端写死的逻辑)。
客户端和服务端之间的会话先讲到这里,接下来我们聊聊服务端之间的会话。
二、服务端会话的秘密
如果村里是同时有多个办事处的时候(我这里先假设两个),情况就不太一样了。
假设鸡太美第一次连接的时候找到的作为 Follower 的马小云:
而 Follower 是不能独自处理非读请求的,所以此次马小云会为鸡太美分配好 ID 之后,将创建会话操作转发给马果果,这样就好像是鸡太美找到马果果一样,流程和上面是一样的,在会话管理页中记录下来。
而马小云自己也会简单的维护一个会话 ID 和超时时间的映射关系,以多个村民为例,每次收到请求都会对其进行记录
现在鸡太美是连接的马小云办事处(包括每次心跳发送),但是全局的会话管理数据在马果果这里,这样是怎么维持住会话状态的呢?
这里我们就得先聊聊服务端之间是怎么进行心跳的。
服务端有一个重要的配置 tickTime
(默认是 2000),还有另一个重要的配置 syncLimit
(默认是 5),我就以这两个默认值来举例:
- 首先 Leader 会以 1000 (
tickTime / 2
) 毫秒的频率去对各个 Follower 发起 PING 的请求 - 每次检查 Follower 返回的 PING 的超时时间是否超过 10000 (
tickTime * syncLimit
),超过这个时间没有收到该 Follower 的 ACK 响应就关闭和该 Follower 的 socket 连接
那 Follower 收到 PING 的消息后会回复一个 PING 给 Leader 并且会把自己记录的会话映射关系一起发过去
还会立即清空自己本地的映射关系!
然后 Leader 收到 Follower 的这个 PING 响应后,因为之前所有客户端的会话管理数据其实都在 Leader 这里,所以 Leader 可以对发过来的会话 ID 和超时时间进行会话激活,具体方法和之前的例子中是一样的,通过服务端之间的 PING,既可以完成服务端之间的心跳检测,又可以对客户端的会话进行激活,又是一次一鱼两吃。
小结一下:
- 会话是 ZK 中的重要概念,会话的状态会影响,服务端对客户端请求的处理
- 客户端的每次操作都会延长会话的超时时间,并且客户端会主动发起 PING 请求来保持住会话,以免在空闲时会话超时被服务端关闭
- 客户端的会话数据是保存在 Leader 端的,Follower 只是在每次操作的时候简单的记录下会话 ID 和超时时间的映射关系
- 服务端之间的心跳 PING 是由 Leader 主动向 Follower 发起的
- Follower 收到 PING 后会将自己保存的会话映射数据发送给 Leader
- Leader 收到 Follower 的 PING 响应后会对发送过来的会话数据进行激活
我们现在已经知道了会话的概念,就可以聊聊临时节点了。
三、临时节点
我们先来看下临时节点的创建代码
client.create("/HelloZooKeeper/niubi", null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
这次的创建操作和其他的持久节点创建并无区别,需要在小红本上写下记录,而这个记录中有一个字段是 ephemeralOwner
当节点是持久节点这个字段值是 0,但当节点是临时节点时这个字段记录的就是持有该节点的会话 ID。
除了在小红本上创建记录以外,由于是临时节点,还需要额外在一个专门的地方也记录一下,假设还是鸡太美创建了 3 个临时节点:
19980802 => ["/鸡太美/我真美", "/鸡太美/我真帅", "/鸡太美/我真秀"]
在鸡太美会话超时的时候,可能是会话真超时了(由于有心跳机制,所以这个可能性其实不大),也可能是鸡太美主动关闭的会话。
马果果就会从这个记录临时节点的地方根据鸡太美的会话 ID 取出对应的临时节点的路径,然后根据路径删除即可,效果和鸡太美主动删除是一样的,这样就达到了,当客户端关闭之后,对应的临时节点会自动清除的特点。这个临时节点的特性就会被用在 ZK 实现分布式锁的时候,防止了客户端因意外退出没法执行释放锁的逻辑!
四、协议
还有一个东西我一直就没提过,就是 ZK 的协议。
众所周知,ZK 是一个 CS 架构的应用,有客户端和服务端之分,那既然这样就免不了需要进行网络通信,而且不光是客户端和服务端之间,服务端和服务端之间也需要通信,有了网络通信就离不开协议,但是协议既是最重要的东西,也是最不重要的东西。
- 最重要是因为,ZK 本身就是基于该协议去通信的,无论是客户端还是服务端之间,我之前提到的各种暗号,如:REQUEST、ACK、COMMIT、PING 等。都属于协议中的一个字段,用来区分不同的消息。协议构成了整个 ZK 通信的基础,能够通信了才能完成整个组件的功能。
- 最不重要是因为,除非你想开发 ZK 的客户端,主动去请求 ZK 服务端,不然即使你完全不知道协议的具体格式,也不会影响你理解整个 ZK 的原理,而且协议的介绍非常的枯燥和无用,容易劝退。
所以我把这个概念留到了最后才提起,并且我也不打算去讲解 ZK 中不同请求的协议具体长什么样。这次我就换一个角度简单的介绍下协议。
首先,我介绍的 ZK 都是 Java 程序,无论客户端还是服务端,所以协议的本质是规定如何把 Java 对象转成字节流,方便在网络中传输,以及拿到字节流的那一方,如何再把这个字节流转换回 Java 对象,这其实就是序列化和反序列化的过程。而为了方便序列化,ZK 中定义的各种对象,如 XxxRequest 、 XxxResponse、XxxPacket 等,它们的字段类型通常就几种:int
、long
、String
、byte[]
、List
、boolean
以及其他嵌套的类型。
4.1 int、long、boolean
对于这三种类型来说最简单,直接用输出流写即可,区别就是一个是 4 字节,一个是 8 字节,一个是 1 字节
4.2 String、byte[]
这两种是类似,如果字段为空,则就写入一个 -1,不为空就先写一个 int
表示长度,之后紧跟 byte[]
表示具体数据即可
4.3 嵌套类型、List
碰到 List
和 4.2 是一样,如果为空就写 -1,不为空就先写 List
长度,之后遍历 List
根据泛型(也只可能是上面这几种)决定如何继续写入,嵌套对象的话就把这个写入操作委托给它就行了,因为它的字段也只可能是上面这几种。
4.4 小结
ZK 的序列化协议采用的紧凑书写的方式,根据不同的字段类型依次写入最终的字节流即可。
五、总结
今天我们介绍了 ZK 会话相关的知识:会话是什么,客户端和服务端的会话如何保持,服务端和服务端的会话如何保持,以及介绍了临时节点是如何利用会话机制在会话结束后被自动删除的,最后再用很短的篇幅带大家了解了下 ZK 的协议