第七章 ZooKeeper 技术内幕(二)

7.4 会话 (Session)

会话是 ZooKeeper 客户端与服务器之间的一个逻辑连接。它不仅仅代表一个 TCP 连接,更是一个有状态、有生命周期的实体。客户端的所有操作,包括创建临时节点、注册 Watcher 等,都与会话紧密绑定。可以说,ZooKeeper 的活性检测、临时节点、Watcher 等核心特性,都建立在会话机制之上。

7.4.1 会话状态 (Session State)

一个会话在其生命周期中,会经历不同的状态。理解这些状态对于排查问题至关重要。

  • CONNECTING (连接中):

    • 这是会话的初始状态,发生在客户端启动,尝试与服务器建立连接时。
    • 也可能发生在客户端与当前服务器断连,尝试连接到另一个服务器时。
  • CONNECTED (已连接):

    • 客户端已成功与一台服务器建立 TCP 连接,并完成了会话的创建或重连。
    • 在此状态下,客户端可以正常发送心跳和业务请求,并接收响应和 Watcher 通知。
  • RECONNECTED (已重连):

    • 这是一个短暂的过渡状态,在 ZooKeeper 3.2.0 版本后已不再使用,逻辑被合并到了 CONNECTED 状态中。其原意是表示客户端从一个服务器断开后,成功连接到另一个服务器。
  • CLOSED (已关闭):

    • 会话已明确终止。这通常发生在以下两种情况:
      1. 客户端代码主动调用 close() 方法。
      2. 会话因超时而被服务器判定为“过期”(Expired)。
    • 一旦进入 CLOSED 状态,会话就彻底结束,无法再恢复。客户端需要创建一个全新的会话才能继续与 ZooKeeper 交互。
  • EXPIRED_SESSION (会话已过期):

    • 这是一个在客户端侧的特殊状态。当客户端收到服务器发来的“会话已过期”通知时,会进入此状态。它本质上等同于 CLOSED,表示当前会话已失效。
  • AUTH_FAILED (认证失败):

    • 客户端使用错误的认证信息(如错误的 username:password)进行连接或操作时,会进入此状态。

7.4.2 会话创建 (Session Creation)

我们在 7.3.1 节已经从客户端视角描述了会话创建的过程。现在,我们从服务器视角来看这个过程。

  1. 接收连接请求: Leader 或 Follower 服务器的 NIOServerCnxn 接收到来自客户端的 ConnectRequest

  2. 会话 ID 分配:

    • 如果请求中的 sessionID 为 0,表示这是一个全新的会话创建请求。
    • 只有 Leader 服务器有权分配新的 sessionID。因此,如果请求被一个 Follower 接收,Follower 会将这个创建请求转发给 Leader。
    • Leader 调用 SessionTracker.createSession() 方法。SessionTracker 是 Leader 上专门负责管理所有会话的组件。
    • createSession() 会生成一个全局唯一的、单调递增的 64 位 sessionID。这个 ID 的高位包含了 Leader 的 epoch(选举代次),保证了其唯一性。
  3. 超时时间协商:

    • 服务器会根据客户端请求的 sessionTimeout 和自身配置的 minSessionTimeoutmaxSessionTimeout 来协商一个最终的超时时间。
    • 最终的 tickTime (超时时间) = clamp(clientTimeout, minTimeout, maxTimeout)
    • 这个协商后的超时时间会连同 sessionID 和一个新生成的会话密码(password)一起,封装在 ConnectResponse 中。
  4. 会话激活与同步:

    • Leader 在 SessionTracker 中记录下这个新会话的信息(ID、超时时间、密码等),并将其标记为已激活。
    • 这个会话创建操作会作为一个事务createSession 事务),被广播给所有 Follower,并写入事务日志。这样,整个集群的所有服务器就都知晓了这个新会话的存在。
  5. 响应客户端:

    • Leader(或通过转发的 Follower)将 ConnectResponse 发送回客户端。
    • 客户端收到响应后,会话建立成功,进入 CONNECTED 状态。

7.4.3 会话管理 (Session Management)

会话管理的核心是如何判断一个会话是否仍然存活。ZooKeeper 采用了一种被称为分桶策略 (Bucket Strategy) 的高效机制来管理和检查会话超时。

SessionTracker 的内部机制:

SessionTracker 是 Leader 服务器上会话管理的核心。它内部维护着一个类似于“时间轮”的数据结构,称为分桶

  1. 分桶结构:

    • 可以想象成一个 Map<Long, Set<Long>> 结构,其中 Key 是一个未来的时间戳Value 是一个包含多个 sessionID 的集合。
    • 这个未来的时间戳代表了桶内所有会话的下一次预期激活时间 (Next Expiration Time)
  2. 会话激活 (Touch):

    • 当服务器收到来自某个客户端的任何请求(包括心跳包)时,就认为这个会话被“激活”了一次。
    • SessionTracker 会执行 touchSession(sessionID) 操作。
    • 这个操作会计算该会话的新的下次激活时间newExpirationTime = currentTime + sessionTimeout
    • 然后,它会将这个 sessionID 从旧的桶中移除,并放入代表 newExpirationTime 的新桶中。如果新桶不存在,就创建一个。

分桶策略的优势:

  • 高效检查: 服务器不需要遍历所有会-话来检查是否超时。它只需要检查当前时间已经“越过”了哪些桶。例如,如果当前时间是 T,那么所有 ExpirationTime < T 的桶中的会话,都是可能超时的。
  • 批量处理: 激活操作(touch)非常高效。它不是实时更新每个会话的过期时间,而是将其移动到合适的桶中。一个桶可以容纳大量在同一时间段内需要被激活的会话。

7.4.4 会话清理 (Session Expiry)

会话清理是会话管理的另一半,即找出并处理那些已经死亡的会话。

  1. 超时检查线程:

    • SessionTracker 内部有一个专门的线程,它会定期(大约每 tickTime 的一半)运行。
    • 这个线程的任务就是检查哪些会话桶已经过期。它会找到所有 ExpirationTime < currentTime 的桶。
  2. 标记为过期:

    • 对于这些过期桶中的每一个 sessionIDSessionTracker 会将其标记为“已过期”。
  3. 关闭会话并清理临时节点:

    • SessionTracker 会提交一个 closeSession 事务。这个事务会被广播到整个集群。
    • 当服务器(Leader 和 Follower)处理这个 closeSession 事务时,它们会执行两个关键操作:
      a. 从各自的会话列表中彻底移除这个会话。
      b. 扫描整个数据树,删除所有由这个过期会话创建的临时节点 (Ephemeral Nodes)
    • 删除临时节点的操作同样会触发相应的 NodeDeleted Watcher 事件。

这个过程确保了旦会话被确认死亡,其在系统中的所有“痕迹”(主要是临时节点)都会被原子性地、一致性地清理掉,这是 ZooKeeper 实现许多分布式协调功能(如 Master 选举、成员管理)的基石。


7.4.5 重连 (Reconnection)

重连是指客户端在会话尚未过期的情况下,与集群断开连接后,重新建立连接的过程。

客户端行为:

  • 当客户端的 SendThread 检测到 TCP 连接断开时,它会立即将客户端状态置为 CONNECTING
  • 它会从服务器地址列表中选择下一个服务器,尝试建立新的 TCP 连接。
  • 连接成功后,它会发送一个 ConnectRequest,但这次会带上已有的 sessionIDpassword

服务器行为:

  • 服务器收到这个带有 sessionIDConnectRequest 后,会检查这个会-话是否存在且未过期。
  • 如果会话有效: 服务器会接受这个重连,更新该会话所连接的服务器信息,并向客户端返回成功的响应。客户端重新进入 CONNECTED 状态。
  • 如果会话已过期: 服务器会拒绝这个重连,并告知客户端会话已过期。客户端收到这个通知后,会进入 EXPIRED_SESSION 状态,所有与旧会话相关的 Watcher 和临时节点都已失效。此时,客户端必须创建一个全新的会-话。

重连的透明性:
对于上层应用来说,只要重连在 sessionTimeout 内完成,整个过程是基本透明的。应用代码不需要关心底层的服务器切换。但是,在断连和重连成功之间的这段时间里,所有 API 调用都会失败或阻塞。此外,由于 Watcher 是一次性的,断连期间可能发生的数据变更事件将会丢失。因此,重连成功后,客户端应用通常需要重新获取数据并注册 Watcher,以确保状态的最终一致性。


7.5 服务器启动 (Server Startup)

ZooKeeper 服务器的启动过程,无论是单机还是集群,其核心都是加载配置、恢复数据、初始化组件,并最终开始对外提供服务。集群模式的复杂性主要体现在服务器之间的相互发现和选举 Leader 的过程。

启动的入口通常是 org.apache.zookeeper.server.ZooKeeperServerMain 类,它会解析配置文件,并根据配置启动一个 QuorumPeerMain (集群模式) 或直接启动一个 ZooKeeperServer 实例 (单机模式)。

7.5.1 单机版服务器启动 (Standalone Server Startup)

单机版模式非常简单,因为不涉及选举和服务器间通信。它主要用于开发、测试或一些非常简单的应用场景。

启动命令通常是:
bin/zkServer.sh start-foreground zoo.cfg

其启动流程可以概括为以下几个步骤:

  1. 配置文件解析:

    • 启动脚本调用 ZooKeeperServerMain
    • ZooKeeperServerMain 读取 zoo.cfg 配置文件,解析出 dataDir, dataLogDir, clientPort, tickTime 等核心参数。
  2. 初始化核心组件:

    • 创建一个 FileTxnSnapLog 实例,这是负责管理事务日志 (log.*) 和数据快照 (snapshot.*) 的组件。
    • 创建一个 ZooKeeperServer 实例。ZooKeeperServer 是 ZooKeeper 服务器的核心,它包含了数据树 (DataTree)、会话管理器 (SessionTracker) 等。
  3. 数据恢复:

    • 这是启动过程中至关重要的一步。ZooKeeperServer 会调用 FileTxnSnapLog.restore() 方法来从磁盘加载数据,恢复服务器的内存状态。
    • 恢复过程
      a. 首先,它会去 dataDir(或 dataLogDir)指定的目录下,找到最新的一个数据快照 (snapshot) 文件并加载到内存中,构建起初步的 DataTree
      b. 然后,它会查找所有在快照生成时间点之后的事务日志文件。
      c. 它会逐条回放 (replay) 这些事务日志中的每一个事务,应用到内存的 DataTree 上。例如,如果日志是一条 create /node1 的事务,它就会在内存中创建这个节点。
    • 当所有相关的事务日志都回放完毕后,服务器的内存数据就恢复到了宕机或关闭前的最新状态。同时,它也会从快照和日志中恢复所有的会话信息。
  4. 启动网络服务:

    • 数据恢复完成后,ZooKeeperServer 会创建一个 NIOServerCnxnFactory (基于 Java NIO 的网络连接工厂)。
    • NIOServerCnxnFactory 会绑定到配置文件中指定的 clientPort (默认为 2181),并开始监听和接收来自客户端的连接请求。
  5. 服务就绪:

    • 此时,单机版的 ZooKeeper 服务器已经准备就绪,可以开始处理客户端的请求了。

7.5.2 集群版服务器启动 (Clustered Server Startup)

集群版的启动过程远比单机版复杂,因为它引入了 Leader 选举数据同步这两个分布式协调的关键环节。

启动命令通常是:
bin/zkServer.sh start zoo.cfg (在集群的每台机器上执行)

其启动流程如下:

  1. 配置文件解析:

    • 启动入口是 QuorumPeerMain。它首先解析 zoo.cfg 文件。
    • 除了单机版的参数,它还会重点解析集群相关的配置,如 server.X=host:port1:port2。这定义了集群中有哪些成员(QuorumPeer),以及它们各自的地址、选举端口(port2)和通信端口(port1)。
    • 同时,它会读取 dataDir 目录下的 myid 文件,获取当前服务器在集群中的唯一标识符(即 server.X 中的 X)。
  2. 初始化核心组件:

    • 创建一个 QuorumPeer 实例。QuorumPeer 是代表集群中一个成员的顶层对象,它内部封装了一个 ZooKeeperServer 实例,并额外管理着选举、通信等集群功能。
    • 和单机版一样,创建 FileTxnSnapLogZooKeeperServer
  3. 数据恢复:

    • 与单机版完全相同。每个 QuorumPeer 都会独立地从自己的磁盘快照和事务日志中恢复数据,将内存状态恢复到自己宕机前的最新状态。
    • 注意:此时,集群中不同服务器的内存数据可能是不一致的。例如,一个刚刚恢复的节点数据可能落后于其他已经运行了一段时间的节点。数据的最终一致性将在后续的 Leader 选举和同步阶段完成。
  4. 寻找 Leader (Leader Election):

    • 这是集群启动最核心的环节。数据恢复后,每个 QuorumPeer 的初始状态都是 LOOKING
    • QuorumPeer 会启动一个名为 FastLeaderElection 的选举算法实现。
    • 它会开始通过配置文件中指定的选举端口port2)与其他服务器进行投票通信。
    • 投票内容:每台服务器发出的投票中,最重要的信息是它自己最后一次更新数据的事务 ID,即 zxid
    • 投票规则
      a. 优先选择 zxid 最大的服务器作为 Leader(因为它拥有最新的数据)。
      b. 如果 zxid 相同,则选择 myid 最大的服务器作为 Leader。
    • 当某台服务器的提议获得了超过半数(Quorum)服务器的投票后,选举过程结束,Leader 产生。
  5. 角色确定与组件启动:

    • 选举结束后,服务器的角色确定下来:一台成为 LEADER,其余的成为 FOLLOWING
    • Leader:
      • 启动 Leader 组件,负责处理写请求和协调 Follower。
      • 初始化 Leader.LearnerCnxnHandler,准备接收来自 Follower 的连接。
    • Follower:
      • 启动 Follower 组件。
      • 最重要的一步是连接到 Leader,准备进行数据同步。
  6. 数据同步 (Synchronization):

    • 所有 Follower 连接上 Leader 后,会进入数据同步阶段,以确保整个集群的数据视图一致。
    • Follower 会将自己本地的最新 zxid 发送给 Leader。
    • Leader 会比较 Follower 的 zxid 和自己本地的 zxid
    • 同步策略:
      • 差异同步 (DIFF): 如果 Follower 的数据只是稍微落后,Leader 会将差异部分的事务日志发送给 Follower,让其回放。
      • 回滚同步 (TRUNC+DIFF): 如果 Follower 的 zxid 比 Leader 还大(这可能发生在老 Leader 宕机,新 Leader 被选举出来的情况下),Leader 会要求 Follower 回滚到某个共同的事务点,然后再进行差异同步。
      • 快照同步 (SNAP): 如果 Follower 的数据落后太多,发送差异日志的成本太高,Leader 会直接将自己的内存数据快照发送给 Follower,让其完全加载。
    • 当一个 Follower 完成数据同步后,Leader 会将其加入到“已同步”的 Follower 列表中。
  7. 服务就绪:

    • 当 Leader 发现超过半数的 Follower(包括 Leader 自己)都已经完成了数据同步,它就认为集群已经“过半可用”。
    • 此时,Leader 和所有已同步的 Follower 会一起启动 NIOServerCnxnFactory,开始对外监听 clientPort,接收客户端连接。
    • 至此,整个 ZooKeeper 集群启动完成,并可以对外提供服务。

这个复杂而严谨的启动流程,特别是 Leader 选举和数据同步,是 ZooKeeper 实现高可用性和数据一致性的根本保障。


7.6 Leader 选举 (Leader Election)

在 ZooKeeper 集群中,所有服务器节点都处于两种状态之一:要么是 Leader,要么是 Follower(还有一种是 Observer,但不参与选举)。Leader 负责处理所有改变系统状态的写请求,并将其同步给其他节点。如果 Leader 宕机,整个集群必须在短时间内选出一个新的 Leader 来继续提供服务。这个过程就是 Leader 选举。

7.6.1 Leader 选举概述

选举的触发时机:

  1. 服务器初始化启动: 这是最常见的场景。所有服务器启动时,它们都不知道谁是 Leader,因此会立即进入选举流程。
  2. Leader 宕机: 运行中的 Leader 节点因为故障、网络分区等原因与超过半数的 Follower失去联系,Follower 们会认为 Leader 已死,从而发起新一轮选举。
  3. 网络分区恢复: 如果一个网络分区将集群分割成两个子集,其中一个子集(少于半数)的服务器会因为无法与 Leader 通信而发起选举,但由于无法获得过半数选票,选举会失败。当网络恢复后,这些节点会重新加入集群,并可能触发选举或直接成为 Follower。

选举的核心目标:

选举过程必须确保最终选出的 Leader 拥有集群中最新的数据。这是保证数据一致性的关键。如果选了一个数据陈旧的节点作为 Leader,那么在它之后提交的事务就可能丢失,这是绝对不能接受的。

ZooKeeper 的选举算法:

ZooKeeper 在历史上使用过多种选举算法,包括 LeaderElection、AuthFastLeaderElection 等。从 3.4.0 版本开始,默认且唯一使用的算法是 FastLeaderElection。它在性能和收敛速度上都有很好的表现。


7.6.2 Leader 选举的算法分析 (FastLeaderElection)

FastLeaderElection 算法的核心思想是:集群中的每个节点都会向其他所有节点提议自己成为 Leader,并根据一套规则来更新自己的提议,直到某个提议获得超过半数(Quorum)的支持,该提议的节点就成为新的 Leader。

关键概念:

  • myid: 每个服务器在 myid 文件中配置的唯一 ID,是一个 1 到 255 之间的整数。
  • zxid (ZooKeeper Transaction ID): 64 位的事务 ID。这是 ZooKeeper 中最重要的一个值,全局单调递增。zxid 越大,代表其数据越新。
  • epoch: 选举的逻辑时钟或“代次”。每次进入新一轮选举,epoch 都会增加。这可以防止节点接收到来自旧选举轮次(旧代次)的投票。
  • 服务器状态 (Server State):
    • LOOKING: 正在寻找 Leader,处于选举状态。
    • FOLLOWING: 角色为 Follower,已同步于某个 Leader。
    • LEADING: 角色为 Leader。
  • 投票 (Vote): 一个投票是一个数据结构,包含了以下关键信息:
    • id: 被提议的 Leader 的 myid
    • zxid: 被提议的 Leader 的最新 zxid
    • epoch: 当前选举的代次。
    • state: 发起投票的服务器的当前状态(通常是 LOOKING)。

选举规则 (PK 规则):

当一个服务器收到来自其他服务器的投票时,它会用对方的投票与自己的当前投票进行 PK。胜出的投票将成为自己下一轮要广播出去的投票。PK 规则如下,按顺序比较:

  1. 比较 epoch: epoch 大的投票获胜。这确保了所有节点都在同一选举轮次中,旧的投票会被忽略。
  2. 比较 zxid: 如果 epoch 相同,则 zxid 大的投票获胜。这是选举的核心,保证了数据最新的节点会被优先推举为 Leader。
  3. 比较 myid: 如果 epochzxid 都相同(通常发生在所有节点数据一致的初始化启动阶段),则 myid 大的投票获胜。这提供了一个最终的决胜局规则,确保选举总能产生一个唯一的胜者。

7.6.3 Leader 选举的实现细节

下面我们把算法和实际的实现流程结合起来看:

  1. 进入 LOOKING 状态并发起投票:

    • 当一个 QuorumPeer 启动或发现 Leader 失联时,它会把自己的状态设置为 LOOKING
    • 它会从自己的 zxid 中解析出 epoch,并将其加一,作为新一轮选举的 epoch
    • 然后,它会立即给自己投一票。这个初始投票的内容是 (myid, zxid, epoch),即提议自己成为 Leader。
    • 它将这个投票放入自己的选票箱,并通过网络广播给集群中的所有其他服务器。
  2. 接收并处理外部投票:

    • 服务器会有一个专门的 UDP 端口(配置文件中的选举端口)来接收来自其他服务器的投票。
    • 当收到一个外部投票时,服务器会进行如下判断:
      • 检查 epoch:
        • 如果外部投票的 epoch 大于自己的当前 epoch,说明自己已经落后于当前选举进程。它会立即用外部投票的 epoch 更新自己的 epoch,清空自己的选票箱,然后用这个外部投票与自己的初始投票进行 PK,并将胜者作为自己的新投票广播出去。
        • 如果外部投票的 epoch 小于自己的当前 epoch,说明这是一个来自旧选举轮次的过时投票,直接忽略。
        • 如果 epoch 相等,则进入下一步。
  3. 进行投票 PK:

    • 服务器将收到的外部投票与自己当前的投票按照前面提到的 PK 规则(epoch -> zxid -> myid 进行比较。
    • 如果外部投票胜出,服务器会更新自己的投票为外部投票的内容,并再次向全集群广播这个新投票。
    • 如果自己的投票胜出,则保持自己的投票不变,无需做任何事。
  4. 统计选票并决定 Leader:

    • 每次接收到一个投票(无论是外部的还是自己更新后的),服务器都会将其存入一个选票箱(一个 HashMapkey 是服务器的 myidvalue 是它发来的最新投票)。
    • 存入后,服务器会立即检查自己当前的投票是否获得了超过半数的支持。
    • 它会遍历选票箱,统计有多少服务器的投票内容与自己当前的投票完全一致id, zxid, epoch 都相同)。
    • 如果发现某个提议(不一定是自己的提议)获得了超过半数的选票,并且这个提议的 id 就是自己,那么它就将自己的状态从 LOOKING 改为 LEADING,成为 Leader。
    • 如果发现某个提议获得了超过半数的选票,但提议的 id 不是自己,那么它就将自己的状态从 LOOKING 改为 FOLLOWING,成为 Follower,并准备连接这个新选出的 Leader。
  5. 选举结束:

    • 一旦服务器的角色确定为 LEADINGFOLLOWING,选举过程就结束了。
    • Leader 会开始等待 Follower 连接,而 Follower 则会去连接 Leader,进入我们之前讨论过的数据同步阶段。

举例说明:
假设有三台服务器 S1, S2, S3,myid 分别为 1, 2, 3。它们的 zxid 分别是 10, 10, 8。

  1. S1, S2, S3 启动,进入 LOOKING 状态,epoch 都为 1。
  2. 它们各自提议自己:S1 投 (1, 10, 1),S2 投 (2, 10, 1),S3 投 (3, 8, 1)
  3. S1 收到 S2 的投票 (2, 10, 1)。PK 结果:epoch 相同,zxid 相同,S2 的 myid (2) > S1 的 myid (1)。S2 胜出。S1 更新自己的投票为 (2, 10, 1) 并广播。
  4. S1 收到 S3 的投票 (3, 8, 1)。PK 结果:epoch 相同,S1 当前投票的 zxid (10) > S3 的 zxid (8)。S1 胜出,投票不变。
  5. S2 收到 S1 的投票 (1, 10, 1)。PK 结果:S2 的 myid (2) > S1 的 myid (1)。S2 胜出,投票不变。
  6. S2 收到 S3 的投票 (3, 8, 1)。PK 结果:S2 的 zxid (10) > S3 的 zxid (8)。S2 胜出,投票不变。
  7. 最终,S1 和 S2 的投票都会收敛到 (2, 10, 1),S3 的投票也会因为 zxid 较低而更新为 (2, 10, 1)
  8. 很快,所有服务器的投票都变成了 (2, 10, 1)。每台服务器都会发现提议 (2, 10, 1) 获得了 3 票(超过半数)。
  9. S2 发现自己被选为 Leader,状态变为 LEADING。S1 和 S3 发现 S2 被选为 Leader,状态变为 FOLLOWING。选举结束。
posted @ 2026-01-31 15:34  寻找梦想的大熊  阅读(4)  评论(0)    收藏  举报