会话 (Session) 是 ZooKeeper 中最重要的概念之一,客户端与服务端之间的任何交互操 作都与会话息息相关,这其中就包括临时节点的生命周期、客户端请求的顺序执行以及 Watcher 通知机制等。
我们已经讲解了 ZooKeeper 客户端与服务端之间一次会话创建的大体过 程。以Java 语言为例,简单地说, ZooKeeper 的连接与会话就是客户端通过实例化 ZooKeeper 对象来实现客户端与服务器创建并保持 TCP 连接的过程。在本节中,我们将 从会话状态、会话创建和会话管理等方面来讲解 ZooKeeper 连接与会话的技术内幕。
1 会话状态
在 ZooKeeper 客户端与服务端成功完成连接创建后,就建立了一个会话。 ZooKeeper 会 话在整个运行期间的生命周期中,会在不同的会话状态之间进行切换,这些状态一般可 以分为 CONNECTING 、 CONNECTED, RECONNECTING, RECONNECTED 和 CLOSE 等。
如果客户端需要与服务端创建一个会话,那么客户端必须提供一 个使用字符串表示的服务器地址列表:“ host 1: port,host2:port,host3 :port 例如, " 192.168.0.1:2181 ” 或是 “192.168.0.1:2181,192.168.0.2:2181,192.168.0.3:2181” 一旦客 户端开始创建 ZooKeeper 对象,那么客户端状态就会变成 CONNECTING, 同时客户端开始从上述服务器地址列表中逐个选取 IP 地址来尝试进行网络连接,直到成功连接上 服务器,然后将客户端状态变更为 CONNECTED 。
通常情况下,伴随着网络闪断或是其他原因,客户端与服务器之间的连接会出现断开情 况。一旦碰到这种情况, ZooKeeper 客户端会自动进行重连操作,同时客户端的状态再 次变为CONNECTING, 直 到重新 连接 上 ZooKeeper 服 务器后, 客户 端状态 又会再 次转 变成CONNECTED 。因此,通常情况下,在 ZooKeeper 运行期间,客户端的状态总是介 于CONNECTING 和 CONNECTED 两者之一。
另外,如果出现诸如会话超时、权限检查失败或是客户端主动退出程序等情况,那么客 户端的状态就会直接变更为 CLOSE 。
2 会话创建
我们曾经介绍了会话创建过程中 ZooKeeper 客户端的大体工作流程。我们再一起来看看会话创建过程中 ZooKeeper 服务端的工作原理。
Session
Session 是 ZooKeeper 中的会话实体,代表了一个客户端会话。其包含以下 4 个基本属性。
- sessionlD: 会话 ID, 用来唯一标识一个会话,每次客户端创建新会话的时候, ZooKeeper 都会为其分配一个全局唯一的 sessionlD 。
- TimeOut : 会 话 超 时 时 间 。 客 户 端 在 构 造 ZooKeeper 实 例 的 时 候 , 会 配 置 一 个sessionTimeout 参数用于指定会话的超时时间。 ZooKeeper 客户端向服务器 发送这个超时时间后,服务器会根据自己的超时时间限制最终确定会话的超时 时间。
- TickTime: 下次会话超时时间点。为了便于 ZooKeeper 对会话实行“分桶策略.管 理,同时也是为了高效低耗地实现会话的超时检查与清理, ZooKeeper 会为每个会 话标记一个下次会话超时时间点。 TickTime 是一个 13 位的 long 型数据,其值接 近于当前时间加上 TimeOut,但不完全相等。
- isClosing: 该属性用于标记一个会话是否已经被关闭。通常当服务端检测到一个会 话已经超时失效的时候,会将该会话的 isClosing 属性标记为“已关闭”,这样就 能确保不再处理来自该会话的新请求了。
sessionlD
在上面我们也已经提到了, sessionlD 用来唯一标识一个会话,因此 ZooKeeper 必须保证 sessionlD的全局唯一性。在每次客户端向服务端发起“会话创建”请求时,服务端都会 为其分配一个sessionlD, 现在我们就来看看 sessionlD 究竟是如何生成的。
在 SessionTracker 初始化的时候,会调用 initializeNextSession 方法来生成一个 初始化的 sessionlD,之后在 ZooKeeper 的正常运行过程中,会在该 sessionlD 的基础上 为每个会话进行分配,其初始化算法如下:
public static long initializeNextSession(long id) {
long nextSid = 0;
nextSid = (System.currentTimeMillis()<< 24)>> 8;
nextSid = nextSid | (id << 56);
return nextSid;
}
上面这个方法就是 ZooKeeper 初始化 sessionlD 的算法,我们一起来深入地探究下其实 现内幕。从上面的代码片段中,可以看出 sessionlD 的生成大体可以分为以下 5 个步骤。
- 获取当前时间的毫秒表示。
我们假设 System.currentTimeMillis() 取出的值是 1380895182327, 其 64 位二进制表示是: - 其中阴影部分表示高 24 位,下划线部分表示低 40 位。
- 左移 24 位。
将步骤 1 中的数值左移 24 位,得到如下二进制表示的数值: - 从上面这个数值中,我们可以看到,之前的高 24 位已经被移出,同时低 24 位全 部使用 0进行了补齐。
- 右移 8 位。
再将步骤 2 中的数值右移 8 位,得到如下二进制表示的数值: - 从上面这个数值中,我们可以看到,高位添加了 8 个 0 。
- 添加机器标识: SID 。
在 initializeNextSession 方法中,出现了一个 id 变量,该变量就是当前 ZooKeeper 服务器的 SID 值。相信读者还记得,SID 就是当时配置在 myid 文件中的值,该值通常是一个整数,例如 1 、 2 或 3, 这里我们为了便于表述,假设该值为 2 。整数 2 的 64 位二进制表示如下: - 可以发现其高 56 位都是 0, 将其左移 56 位后,可以得到如下二进制表示的数值:
- 将步骤 3 和步骤 4 得到的两个 64 位表示的数值进行 “|” 操作
- 可以得到如下数值:
通过以上 5 步,就完成了一个 sessionlD 的初始化。因为 ID 是一个机器编号,比如 1 、 2 或 3, 因此经过上述算法计算之后,我们就可以得到一个单机唯一的序列号。简单地讲, 可以将上述算法概括为:高 8 位确定了所在机器,后 56 位使用当前时间的毫秒表示进 行随机
SessionTracker
SessionTracker 是 ZooKeeper 服务端的会话管理器,负责会话的创建、管理和清理等工 作。可以说,整个会话的生命周期都离不开 SessionTracker 的管理。每一个会话在 SessionTracker 内部都保留了三份,具体如下。
- sessionsByld: 这是一个 HashMapvLong, Sessionlmpl> 类型的数据结构, 用于根据sessionlD 来管理 Session 实体。
- sessionsWithTimeout: 这是一个 ConcurrentHashMap<Long, Integer> 类型的数据结构,用于根据 sessionlD 来管理会话的超时时间。该数据结构和 ZooKeeper 内存数据库相连通,会被定期持久化到快照文件中去。
- sessionSets: 这是一个 HashMapvLong, SessionSet> 类型的数据结构,用 于根据下次会话超时时间点来归档会话,便于进行会话管理和超时检查。在下文“分 桶策略”会话管理的介绍中,我们还会对该数据结构进行详细讲解。
创建连接
服务端对于客户端的"会话创建”请求的处理,大体可以分为四大步骤,分别是处理 ConnectRequest 请求、会话创建、处理器链路处理和会话响应。在 ZooKeeper 服务 端,首先将会由 NIOServerCnxn 来负责接收来自客户端的“会话创建”请求,并反序 列化出 ConnectRequest 请求,然后根据 ZooKeeper 服务端的配置完成会话超时时间 的协商。随后, SessionTracker 将会为该会话分配一个 sessionlD , 并将其注册到 sessionsByld 和 sessionsWithTimeout 中去,同时进行会话的激活。之后,该“会 话请求”还会在 ZooKeeper 服务端的各个请求处理器之间进行顺序流转,最终完成会话 的创建。
3 会话管理
在上一节中,我们已经讲解了 ZooKeeper 客户端和服务端之间创建一次会话的整个过程, 本节我们将开始讲解 ZooKeeper 服务端是如何管理这些会话的。
分桶策略
ZooKeeper 的会话管理主要是由 SessionTracker 负责的,其采用了一种特殊的会话管理 方式,我们称之为“分桶策略”。所谓分桶策略,是指将类似的会话放在同一区块中进 行管理,以便于ZooKeeper 对会话进行不同区块的隔离处理以及同一区块的统一处理
我们可以看到, ZooKeeper 将所有的会话都分配在了不同的区块之中,分 配的原则是每个会话的“下次超时时间点” (ExpirationTime) 。 ExpirationTime 是指该会 话最近一次可能超时的时间点,对于一个新创建的会话而言,其会话创建完毕后, ZooKeeper 就会为其计算ExpirationTime, 计算方式如下:
ExpirationTime = CurrentTime + SessionTimeout
其中 CuiTentTime 指当前时间,单位是毫秒; SessionTimeout 指该会话设置的超时时间, 单位也是毫秒。那么,图 中横坐标所标识的时间,是否就是通过上述公式计算出 来的呢?答案是否定的,在 ZooKeeper 的实际实现中,还做了一个处理。ZooKeeper 的 Leader 服务器在运行期间会定时地进行会话超时检查,其时间间隔是 Expirationinterval, 单位是毫秒,默认值是 tickTime 的值,即默认情况下,每隔 2000 毫秒进行一次会话超 时检查。为了方便对多个会话同时进行超时检查,完整的 ExpirationTime 的计算方式如 下:
ExpirationTime_ = CurrentTime + SessionTimeout
ExpirationTime = (ExpirationTime_ /Expirationinterval +1) x Expirationinterval
也就是说,图中横坐标的 ExpirationTime 值总是 Expirationintervai 的整数倍数。举 个实际例子,假设当前时间的毫秒表示是 1370907000000, 客户端会话设置的超时时间 是 15000 毫秒, ZooKeeper 服务器设置的 tickTime 为 2000 毫秒,那么 Expirationinterval 的值同样为 2000 毫秒,于是我们可以计算该会话的 ExpirationTime 值为 1370907016000, 计算过程如下:
Expi rationT ime_ = 1370907000000 + 15000 = 1370907015000
ExpirationTime =( 1370907015000 / 2000 + 1) x 2000 = 1370907016000
会话激活
为了保持客户端会话的有效性,在 ZooKeeper 的运行过程中,客户端会在会话超时时间 过期范围内向服务端发送 PING 请求来保持会话的有效性,我们俗称“心跳检测同 时,服务端需要不断地接收来自客户端的这个心跳检测,并且需要重新激活对应的客户 端会话,我们将这个重新激活的过程称为 TouchSession 。会话激活的过程,不仅能够使 服务端检测到对应客户端的存活性,同时也能让客户端自己保持连接状态。其主要流程 如图 7-25 所示。
- 检验该会话是否已经被关闭。
Leader 会检查该会话是否已经被关闭,如果该会话已经被关闭,那么不再继续激 活该会话。 - 计算该会话新的超时时间 ExpirationTime_New 0
如果该会话尚未关闭,那么就开始激活会话。首先需要计算出该会话下一次超时 时间点,使用的就是上面提到的计算公式。 - 定位该会话当前的区块。
获取该会话老的超时时间 ExpirationTime_Old, 并根据该超时时间来定位到其所在 的区块。 - 迁移会话
将该会话从老的区块中取出,放入 ExpirationTime New 对应的新区块中
通过以上 4 步,就基本完成会话激活的过程。在上面的会话激活过程中,我们可以看到, 只要客户端发来心跳检测,那么服务端就会进行一次会话激活。心跳检测由客户端主动 发起,以 PING请求的形式向服务端发送。但实际上,在 ZooKeeper 服务端的设计中, 只要客户端有请求发送到服务端,那么就会触发一次会话激活。因此,总的来讲,大体 会出现以下两种情况下的会话激
活。
- 只要客户端向服务端发送请求,包括读或写请求,那么就会触发一次会话激活。
- 如果客户端发现在 sessionTimeout / 3 时间内尚未和服务器进行过任何通信,即没 有向服务端发送任何请求,那么就会主动发起一个 PING 请求,服务端收到该请求 后,就会触发上述第一种情况下的会话激活。
会话超时检查
上面我们分别介绍了 ZooKeeper 会话的分桶管理策略和会话激活的过程,现在我们再来 看看ZooKeeper 是如何进行会话超时检查的。
在 ZooKeeper 中,会话超时检查同样是由 SessionTracker 负责的。 SessionTracker 中有一 个单独的线程专门进行会话超时检查,这里我们将其称为“超时检查线程”,其工作机 制的核心思路其实非常简单:逐个依次地对会话桶中剩下的会话进行清理。
我们可以看到,如果一个会话被激活,那么 ZooKeeper 会将其从上一个 会话桶迁移到下一个会话桶中,例如图中的 session.n 这个会话,由于触发了会话激活, 因此 ZooKeeper 会将其从 expirationTime 1 桶迁移到 expirationTime n 桶中去。于是, expirationTime 1 中留下的所有会话都是尚未被激活的。因此,超时检查线程的任务就是 定时检查出这个会话桶中所有剩下的未
被迁移的会话。
那么超时检查线程是如何做到定时检查的呢?这里就和 ZooKeeper 会话的分桶策略紧密 联系起来了。在会话分桶策略中,我们将 Expirationinterval 的倍数作为时间点来分布会 话,因此,超时检查线程只要在这些指定的时间点上进行检查即可,这样既提高了会话 检查的效率,而且由于是批量清理,因此性能非常好——这也是为什么 ZooKeeper 要通 过分桶策略来管理客户端会话的最主要的原因。因为在实际生产环境中,一个 ZooKeeper 集群的客户端会话数可能会非常多,逐个依次检查会话的方式会非常耗费时间。
4 会话清理
当 SessionTracker 的会话超时检查线程整理出一些已经过期的会话后,那么就要开始进 行会话清理了。会话清理的步骤大致可以分为以下 7 步。
- 标记会话状态为“已关闭”。
由于整个会话清理过程需要一段的时间,因此为了保证在此期间不再处理来自该 客户端的新请求 ,SessionTracker 会首先将该会话的 isClosing 属性标记为 true 。这 样,即使在会话清理期间接收到该客户端的新请求,也无法继续处理了。 - 发起“会话关闭”请求。
为了使对该会话的关闭操作在整个服务端集群中都生效, ZooKeeper 使用了提交 “会话关闭”请求的方式,并立即交付给 PrepRequestProcessor 处理器进行 处理。 - 收集需要清理的临时节点
在 ZooKeeper 中,一旦某个会话失效后,那么和该会话相关的临时( EPHEMERAL) 节点都需要被一并清除掉。因此,在清理临时节点之前,首先需要将服务器上所 有和该会话相关的临时节点都整理出来。
在 ZooKeeper 的内存数据库中,为每个会话都单独保存了一份由该会话维护的所 有临时节点集合,因此在会话清理阶段,只需要根据当前即将关闭的会话的 sessionlD 从内存数据库中获取到这份临时节点列表即可。
但是,在实际应用场景中,情况并没有那么简单,有如下的细节需要处理:在 ZooKeeper 处理会话关闭请求之前,正好有以下两类请求到达了服务端并正在处 理中。
- 节点删除请求,删除的目标节点正好是上述临时节点中的一个。
- 临时节点创建请求,创建的目标节点正好是上述临时节点中的一个。
对于这两类请求,其共同点都是事务处理尚未完成,因此还没有应用到内存数据 库中,所以上述获取到的临时节点列表在遇上这两类事务请求的时候,会存在不 一致的情况。
假定我们当前获取的临时节点列表是 ephemerals, 那么针对第一类请求,我们 需要将所有这些请求对应的数据节点路径从 ephemerals 中移除,以避免重复删 除。针对第二类请求,我们需要将所有这些请求对应的数据节点路径添加到 ephemerals 中去,以删除这些即将会被创建但是尚未保存到内存数据库中去的 临时节点。
- 添加“节点删除”事务变更。
完成该会话相关的临时节点收集后, ZooKeeper 会逐个将这些临时节点转换成“节 点删除请求,并放入事务变更队列 outstandingChanges 中去。 - 删除临时节点。
在上面的步骤中,我们已经收集了所有需要删除的临时节点,并创建了对应的“节 点删除”请求, FinalRequestProcessor 处理器会触发内存数据库,删除该 会话对应的所有临时节点。 - 移除会话。
完成节点删除后,需要将会话从 SessionTracker 中移除。主要就是从上面提到的 三个数据结构 (sessionsByld, sessionsWithTimeout 和 sessionSets) 中将该会话移除掉。 - 关闭 NIOServerCnxn
最后,从 NIOServerCnxnFactory 找到该会话对应的 NIOServerCnxn, 将其 关闭。
5 重连
我们已经讲过,当客户端和服务端之间的网络连接断开时, ZooKeeper 客户端会自动进行反复的重连,直到最终成功连接上 ZooKeeper 集群中的一台机器。在 这种情况下,再次连接上服务端的客户端有可能会处于以下两种状态之一。
- CONNECTED :如果在会话超时时间内重新连接上了 ZooKeeper 集群中任意一台 机器,那么被视为重连成功。
- EXPIRED :如果是在会话超时时间以外重新连接上,那么服务端其实已经对该会 话进行了会话清理操作,因此再次连接上的会话将被视为非法会话。
在前面几节关于会话生命周期的讲解中,我们已经了解到,在 ZooKeeper 中,客户 端与服务端之间维持的是一个长连接,在 sessionTimeout 时间内,服务端会不断地检测 该客户端是否还处于正常连接一服务端会将客户端的每次操作视为一次有效的心跳 检测来反复地进行会话激活。因此,在正常情况下,客户端会话是一直有效的。然而, 当客户端与服务端之间的连接断开后,用户在客户端可能主要会看到两类异常: CONNECTION_LOSS (连接断开)和 SESSION_EXPIRED(会话过期)。那么该如何正 确处理 CONNECTION LOSS 和 SESSION_EXPIRED 呢?
连接断开: CONNECTION_LOSS
有时会因为网络闪断导致客户端与服务器断开连接,或是因为客户端当前连接的服务器 出现问题导 致 连 接 断 开 , 我 们 统 称 这 类 问 题 为 “ 客 户 端 与 服 务 器 连 接 断 开 ” 现 象 , 即CONNECTION_LOSS„ 在这种情况下, ZooKeeper 客户端会自动从地址列表中重新逐个 选取新的地址并尝试进行重新连接,直到最终成功连接上服务器。
举个例子,假设某应用在使用 ZooKeeper 客户端进行 setData 操作的时候,正好出现了CONNECTION LOSS 现象,那么客户端会立即接收到事件 None-Disconnected 通知,同时会 抛出异常: org.apache.zookeeper.KeeperException$ConnectionLossExcep tion 。 在这种情况下,我们的湖需要做的事情就是捕获住 Connection LossException, 然后等待 ZooKeeper 的客户端自动完成重连。一旦客户端成功连接上一台 ZooKeeper 机器后,那么客户端就 会收 3 事件 None-SyncConnected 通知,之后就可以重试刚刚出错的 setData 操作。
会话失效: SESSION_EXPIRED
SESS1ON_EXPIRED 是指会话过期,通常发生在 CONNECTION_LOSS 期间。客户端和 服务器连接断开之后,由于重连期间耗时过长,超过了会话超时时间 (sessionTimeout) 限制后还没有成功连接上服务器,那么服务器认为这个会话已经结束了,就会开始进行 会话清理。但是另一方面,该客户端本身不知道会话已经失效,并且其客户端状态还是 DISCONNECTED 。之后,如果客户端 重 新 连 接 上 了 服 务 器 , 那 么 很 不 幸 , 服 务 器 会 告 诉 客 户 端 该 会 话 已 经 失 效(SESSION_EXPIRED) o 在这种情况下,用户就需要重新实例 化一个 ZooKeeper 对象,并且看应用的复杂情况,重新恢复临时数据。
会话转移: SESSION_MOVED
会话转移是指客户端会话从一台服务器机器转移到了另一台服务器机器上。正如上文中 提到,假设客户端和服务器 S1 之间的连接断开后,如果通过尝试重连后,成功连接上 了新的服务器 S2 并且延续了有效会话,那么就可以说会话从 S1 转移到了 S2 上。
会话转移现象其实在 ZooKeeper 中一直存在,但是在 3.2.0 版本之前,会话转移的概念 并没有被明确地提出来,于是就会出现如下所述的异常场景。假设我们的 ZooKeeper 服务器集群有三台机器: S1 、 S2 和 S3 。在开始的时候,客户端 C1服务器 S1 建立连接且维持着正常的会话,某一个时刻, C1 向服务器发 送了一个请求 R1:setData (’/$7_4_4/session_moved’, 1) 。但是在请求发 送到服务器之前,客户端和服务器恰好发生了连接断开,并且在很短的时间内重新 连接上了新的 ZooKeeper 服务器 S2 。之后, C1 又向服务器 S2 发送了一个请求 R2: setData (’/$7_4_4/session_moved ', 2) 。这个时候, S2 能够正确地处理 请求 R2, 但是很不幸的事情发生了,请求 R1 也最终到达了服务器 S1, 于是, S1 同样处理了请求 R1, 于是,对于客户端 C1 来说,它的第 2 次请求 R2 就被请求 R1 覆盖了。
当然,上面这个问题非常罕见,只有在 C1 和 S1 之间的网路非常慢的情况下才会发生, 读者也可以参见 ZooKeeper 的 ISSUE: ZOOKEEPER-417 了解更多相关的内容。但是,不得不说,一旦发生这个问题,将会产生非常严重的后果。因 此 , 在 3.2.0 版 本 之 后 , ZooKeeper 明 确 提 出 了 会 话 转 移 的 概 念 , 同 时 封 装 了SessionMovedException 异常。之后,在处理客户端请求的时候,会首先检查会话 的所有者(Owner): 如果客户端请求的会话 Owner 不是当前服务器的话,那么就会直 接抛出
SessionMovedException 异常。当然,由于客户端已经和这个服务器断开了 连接,因此无法收到这个异常的响应。只有多个客户端使用相同的 sessionld/ sessionPasswd 创建会话时,才会收到这样的异常。因为一旦有一个客户端会话创建 成功,那么 ZooKeeper 服务器就会认为该 sessionld 对应的那个会话已经发生了转移, 于是,等到第二个客户端连接上服务器后,就被认为是“会话转移”的情况了。