第七章 ZooKeeper 技术内幕(三)

7.7 各服务器角色介绍

在一个 ZooKeeper 集群(Ensemble)中,服务器节点通常扮演三种角色:Leader(领导者)、Follower(跟随者)和 Observer(观察者)。一个正常工作的集群在任意时刻只会有一个 Leader,其余的都是 Follower 或 Observer。

7.7.1 Leader (领导者)

Leader 是整个 ZooKeeper 集群的核心和大脑,它负责协调和管理整个集群。其主要职责如下:

  1. 处理所有写请求(事务性请求):

    • 任何会改变 ZooKeeper 数据状态的操作,如 create, setData, delete 等,都属于写请求。
    • 无论客户端连接到哪个服务器(Follower 或 Observer),所有的写请求都会被转发给 Leader 进行统一处理。
    • 这种集中处理的方式是保证数据一致性的基础。
  2. 发起并同步事务:

    • Leader 接收到写请求后,会将其转换为一个事务,并为这个事务分配一个全局唯一的、单调递增的 zxid
    • 然后,Leader 会通过 Zab (ZooKeeper Atomic Broadcast) 协议将这个事务广播给所有的 Follower。
    • Leader 会等待,直到收到超过半数(Quorum)的 Follower 对该事务的 ACK(确认)响应。
    • 一旦收到过半数的 ACK,Leader 就会提交 (commit) 这个事务,并向所有 Follower 发送 COMMIT 消息,同时也会将该事务应用到自己的内存数据树中。
    • 最后,Leader 会向最初发起请求的客户端发送成功响应。
  3. 管理和协调 Follower:

    • Leader 负责与所有 Follower 保持心跳(通过 PING 消息),监控它们的存活状态。
    • 在数据同步阶段,Leader 负责指导 Follower 如何与自己对齐数据(通过 DIFF, TRUNC, 或 SNAP 方式)。
  4. 处理非事务性请求: Leader 自身也能处理读请求(get, exists 等),就像一个 Follower 一样。

总结:Leader 是集群的“写操作”瓶颈所在,但也是一致性的唯一保证者。它的存在简化了分布式系统中的一致性问题,将复杂的“多点写入”问题转换为了一个简单的“单点写入、多点复制”模型。


7.7.2 Follower (跟随者)

Follower 是 Leader 的“追随者”,是构成 ZooKeeper 集群高可用性的基石。它们是集群中的“大多数”。

其主要职责如下:

  1. 处理读请求(非事务性请求):

    • Follower 可以独立、快速地处理客户端的读请求。它会直接从自己的本地内存数据树中返回结果。
    • 这使得 ZooKeeper 集群可以通过增加 Follower 节点来水平扩展读性能
  2. 转发写请求给 Leader:

    • 当 Follower 收到一个写请求时,它不会自己处理,而是简单地将其转发给 Leader。
  3. 参与事务投票:

    • 这是 Follower 最重要的职责之一。当收到 Leader 广播来的事务提议(PROPOSAL)时,它会:
      a. 以事务日志(WAL, Write-Ahead Log)的形式将该事务记录到本地磁盘。
      b. 成功写入磁盘后,向 Leader 发送一个 ACK 响应。
    • 这个“先写日志,再发 ACK”的机制确保了即使 Follower 在提交事务前宕机,重启后也能通过日志恢复该事务,不会丢失数据。
  4. 执行 Leader 的 COMMIT 命令:

    • 当收到 Leader 的 COMMIT 消息后,Follower 会将对应的事务应用到自己的内存数据树中,使其数据状态与 Leader 保持同步。
  5. 参与 Leader 选举:

    • 如果 Leader 宕机,所有的 Follower 会立即进入 LOOKING 状态,并参与新一轮的 Leader 选举,以确保集群能够快速恢复服务。

总结:Follower 通过处理读请求分担了 Leader 的压力,并通过参与投票和选举保证了集群的高可用性和数据一致性。


7.7.3 Observer (观察者)

Observer 是在 ZooKeeper 3.3.0 版本中引入的一种特殊角色。它像 Follower 一样从 Leader 同步数据,但有一个关键区别:它不参与任何形式的投票

其主要职责和特点如下:

  1. 处理读请求: 与 Follower 完全相同,可以独立处理读请求,用于扩展集群的读性能。

  2. 转发写请求: 与 Follower 完全相同,将写请求转发给 Leader。

  3. 同步数据,但不投票:

    • Observer 会接收 Leader 广播的事务提议(PROPOSAL)和提交命令(COMMIT),并像 Follower 一样更新自己的数据。
    • 但是,它不会向 Leader 发送 ACK。Leader 不需要等待 Observer 的确认。
  4. 不参与 Leader 选举: Observer 不会发起选举,也不会对选举提议进行投票。它只是一个被动的学习者。

为什么需要 Observer?

引入 Observer 的主要目的是在不影响集群写性能和选举效率的前提下,进一步提升集群的读性能和可扩展性

  • 提升写性能: 在一个标准的 Leader-Follower 集群中,每增加一个 Follower,Leader 在提交事务时就需要多等待一个节点的网络通信和磁盘写入。当 Follower 数量非常多时,这会显著增加写操作的延迟。而增加 Observer 则完全没有这个副作用,因为 Leader 不需要等待它们的 ACK。
  • 提升选举效率: 选举过程需要集群中所有参与者进行网络通信。节点越多,选举收敛的速度可能越慢。Observer 不参与选举,因此不会增加选举的复杂性。
  • 跨数据中心部署: Observer 特别适用于跨数据中心(WAN)的场景。由于跨地域网络延迟高且不稳定,如果将 Follower 部署在远程数据中心,会导致整个集群的写性能急剧下降。而部署 Observer 则可以在远程数据中心提供低延迟的读服务,同时又不会拖慢主集群的写性能。

总结:Observer 是一种“只读”的服务器角色,是提升 ZooKeeper 集群在超大规模或跨地域场景下读扩展能力的利器。


7.7.4 集群间消息通信

为了完成选举、同步、心跳等各种复杂的协调任务,ZooKeeper 服务器之间需要进行高效的通信。其通信方式主要基于 TCP 长连接NIO 技术

  1. 建立连接:

    • 当集群启动,Follower(或 Observer)确定了 Leader 之后,它会主动与 Leader 建立一个 TCP 长连接。
    • 这个连接一旦建立,就会在整个运行期间保持,用于后续所有的消息传递。
  2. 消息队列 (Message Queue):

    • 每个服务器内部都有专门的发送队列和接收线程。
    • 当 Leader 需要向某个 Follower 发送消息时,它会将消息放入对应 Follower 的发送队列中。一个专门的 LearnerHandler 线程会负责从队列中取出消息,并通过 TCP 连接发送出去。
    • 这种异步队列的设计可以有效解耦业务处理和网络 I/O,提高吞吐量。
  3. 消息类型:

    • 服务器间的消息被封装成不同的类型,以应对不同的场景。主要包括:
      • 选举消息: 在 LOOKING 状态下,用于广播和交换投票。通常使用 UDP,因为它更快且允许广播,但新版也可以配置为 TCP。
      • PROPOSAL: Leader 向 Follower 提议一个新事务。
      • ACK: Follower 对 PROPOSAL 的确认。
      • COMMIT: Leader 通知 Follower 提交事务。
      • PING: Leader 向 Follower 发送的心跳包,用于检测存活。
      • REVALIDATE: Follower 重连 Leader 时,用于验证会话是否有效。
      • SYNC: 用于数据同步阶段的消息。
  4. 序列化与反序列化:

    • 为了在网络中传输,所有的消息对象都需要被序列化成字节流。ZooKeeper 使用了自家的序列化框架 Jute
    • Jute 能够将 Java/C++ 对象高效地序列化为二进制格式,相比 Java 原生序列化更紧凑、性能更高。

通过这套精心设计的角色分工和高效的通信机制,ZooKeeper 集群得以在保证强一致性的同时,提供了良好的性能、高可用性和优秀的扩展能力。


7.8 请求处理 (Request Processing)

ZooKeeper 的请求可以分为两大类:事务性请求 (Transactional Request)非事务性请求 (Non-transactional Request)

  • 事务性请求:会改变服务器数据状态的请求,如 create, delete, setData。这类请求必须由 Leader 统一处理,并通过 Zab 协议在集群中达成一致。
  • 非事务性请求:不会改变数据状态的只读请求,如 getData, getChildren, exists。这类请求可以由任何一台服务器(Leader, Follower, Observer)独立处理。

现在,我们来分别看几个典型请求的处理流程。

7.8.1 会话创建处理 (Session Creation)

会话(Session)是 ZooKeeper 中一个至关重要的概念。客户端与服务器之间的所有操作都绑定在一个会话之上。会话创建本身是一个特殊的事务性请求。

处理流程:

  1. 客户端发起连接:

    • 客户端(ZooKeeper 类)启动时,会尝试连接到其连接串(connectString)中指定的某一台服务器的客户端端口(如 2181)。
  2. 服务器接收连接:

    • 服务器端的 NIOServerCnxnFactory 接收到这个新的 TCP 连接,并创建一个 NIOServerCnxn 对象来专门处理这个连接。
  3. 创建会话 (ConnectRequest):

    • 客户端连接成功后,会立即向服务器发送一个 ConnectRequest,请求创建一个新会话。这个请求中包含了客户端希望的会话超时时间(sessionTimeout)等信息。
  4. 服务器处理会话创建:

    • NIOServerCnxn 收到 ConnectRequest 后,会将其交给 ZooKeeperServer 处理。
    • ZooKeeperServer 会调用其内部的 SessionTracker 组件来创建一个会话。SessionTracker 是一个会话管理器。
    • 生成 Session ID: SessionTracker 会生成一个全局唯一的 64 位 sessionID
    • 协商 Timeout: 服务器会根据自己的配置(minSessionTimeout, maxSessionTimeout)与客户端期望的 sessionTimeout 进行协商,确定一个最终的超时时间。
    • 设置初始密码: 为会话生成一个密码(password),用于后续客户端重连时验证身份。
  5. 作为事务提交:

    • 会话的创建会改变服务器的状态(在 SessionTracker 中注册了一个新会话),因此它必须作为事务处理
    • 服务器将 "创建会话" 这个事件包装成一个事务,并提交给请求处理链
    • 这个事务会经过 Zab 协议的广播、投票和提交过程,最终被持久化到所有服务器的事务日志中。
  6. 响应客户端:

    • 一旦会话创建事务被成功提交,Leader 会通知处理该连接的服务器。
    • 该服务器会向客户端发送一个 ConnectResponse,其中包含了最终协商好的 sessionIDsessionTimeoutpassword
  7. 会话建立完成:

    • 客户端收到 ConnectResponse 后,会话正式建立。客户端状态变为 CONNECTED,并开始定期向服务器发送心跳(PING 请求)来维持会话。

关键点:会话创建是一个需要集群达成共识的事务性操作,因为它需要在整个集群中注册这个会话,以便客户端在连接到任何一台服务器时都能被识别。


7.8.2 SetData 请求处理

setData 是一个典型的事务性请求,我们以此为例,看看一个写操作的完整生命周期。

处理流程:

  1. 客户端提交请求: 客户端调用 zk.setData("/path", data, version),将请求打包发送给当前连接的服务器。

  2. 服务器接收并预处理:

    • NIOServerCnxn 接收到请求。
    • 如果当前服务器是 FollowerObserver,它会执行 请求转发(详见 7.8.3),将请求原封不动地发给 Leader。
    • 如果当前服务器就是 Leader,它将开始处理这个请求。
  3. Leader 处理请求 (请求处理链):

    • Leader 将请求交给一个被称为“请求处理链”(Request Processor Chain)的组件进行处理。这是一个责任链模式的实现,请求会依次通过多个处理器。
    • PrepRequestProcessor: 这是第一站。它会对请求进行解析和预处理,例如:
      • 验证权限(ACL)。
      • 检查版本号(version)是否匹配,实现乐观锁。
      • 将请求转化为一个事务(SetDataTxn),并生成事务头(TxnHeader),包含 cxid, zxid, time 等信息。
    • ProposalRequestProcessor: 这是发起 Zab 协议广播的处理器。它会将 PrepRequestProcessor 生成的事务包装成一个 PROPOSAL,广播给所有的 Follower。
    • SyncRequestProcessor: 将事务以日志形式写入本地磁盘(WAL)。写盘成功后,它会向下一个处理器传递请求。
    • AckRequestProcessor: (仅在 Leader 上) 当 SyncRequestProcessor 写盘成功后,这个处理器会向 Leader 自己发送一个 ACK,表示 Leader 本身已经完成了事务的持久化。
    • FinalRequestProcessor: 当一个事务被 Leader 确认已提交(收到过半数 ACK)后,FinalRequestProcessor 会负责将该事务应用到内存数据库DataTree)中,即真正在内存中修改节点的数据。同时,它还会负责唤醒等待该请求结果的客户端连接。
  4. Follower 参与处理:

    • Follower 收到 Leader 的 PROPOSAL 后,也会将请求传递给自己的处理链。
    • Follower 的处理链相对简单,核心是 SyncRequestProcessor,它负责将事务写入本地磁盘,并向 Leader 发送 ACK
    • 当收到 Leader 的 COMMIT 消息后,Follower 的 FinalRequestProcessor 才会将事务应用到自己的内存数据树中。
  5. 返回响应:

    • Leader 的 FinalRequestProcessor 在内存应用成功后,会生成响应,并将其交由 NIOServerCnxn 发回给最初接收请求的服务器,最终由该服务器返回给客户端。

7.8.3 事务请求转发 (Forwarding)

这是保证所有写操作都由 Leader 统一处理的关键机制。

  • 触发条件: 当一个 Follower 或 Observer 收到一个事务性请求(如 create, setData)时。
  • 转发过程:
    1. Follower/Observer 的 FollowerRequestProcessorObserverRequestProcessor 会识别出这是一个事务性请求。
    2. 它不会在本地处理,而是直接通过与 Leader 建立的长连接,将这个请求消息转发给 Leader。
    3. Leader 接收到这个被转发的请求后,就如同收到一个直接来自客户端的请求一样,开始走标准的请求处理链流程。
  • 响应返回: 当 Leader 处理完该请求后,会将最终的响应消息发回给最初转发请求的那个 Follower/Observer,再由它返回给客户端。客户端对于这个转发过程是无感的。

7.8.4 GetData 请求处理

getData 是一个典型的非事务性(只读)请求,它的处理流程要简单得多。

处理流程:

  1. 客户端提交请求: 客户端调用 zk.getData("/path", watch, stat),将请求发送给当前连接的服务器。

  2. 服务器处理请求:

    • NIOServerCnxn 接收到请求。
    • 由于 getData 是只读请求,任何服务器(Leader, Follower, Observer)都可以直接处理它,无需转发。
    • 请求被直接交给 FinalRequestProcessor
    • FinalRequestProcessor 会直接访问本地内存中的 DataTree,查找对应路径 /path 的节点。
    • 如果找到了节点,它会读取节点的数据和元数据(Stat)。
    • 处理 Watch: 如果请求中设置了 watchtrue,服务器会将这个 Watcher 注册到 DataTreeWatchManager 中。这个 Watcher 与客户端的会话和节点路径绑定。
    • 生成响应: FinalRequestProcessor 将读取到的数据和 Stat 信息包装成一个响应。
  3. 返回响应:

    • 响应被 NIOServerCnxn 直接发送回客户端。

数据一致性说明:
由于读请求是在各个服务器本地处理的,可能会存在数据延迟。例如,一个事务刚刚在 Leader 上提交,但 COMMIT 消息还在发送给某个 Follower 的路上,此时一个读请求到达了这个 Follower,它读到的就是旧数据。ZooKeeper 默认提供的是 “顺序一致性” (Sequential Consistency),但不保证实时一致性。如果需要读取最新的数据,客户端可以先调用 sync() 方法,该方法会强制客户端连接的服务器与 Leader 进行一次数据同步,然后再执行读操作。


7.9 数据与存储 (Data and Storage)

ZooKeeper 的数据模型在逻辑上是一棵树,但其物理存储和管理则要复杂得多。它采用了一种“内存 + 磁盘”相结合的混合模式,以平衡性能和持久性。

7.9.1 内存数据 (In-Memory Data)

ZooKeeper 的所有实时数据和状态都存储在内存中。这是其能够提供极高读性能的根本原因。

  • DataTree: 这是 ZooKeeper 内存数据的核心载体。它是一个树形数据结构,完整地对应了客户端视角下的 znode 树。对 znode 的任何 getexists 操作,都是直接访问这个内存中的 DataTree 对象,因此速度非常快。
  • DataNode: DataTree 中的每个节点都是一个 DataNode 对象。它包含了 znode 的路径、数据内容、ACL 列表、元数据(Stat 对象,如 czxid, mzxid, pzxid, version 等)以及子节点列表。
  • WatchManager: 这是一个独立的管理器,也存在于内存中。它负责存储所有的 Watcher。WatchManager 维护了从 znode 路径到 Watcher 列表的映射,以及从 Watcher 到路径的反向映射。当 DataTree 中的某个节点发生变化时,会触发 WatchManager 去查找并通知相关的客户端。
  • SessionTracker: 会话管理器,同样是纯内存的。它负责管理所有活跃的会-话,包括会话 ID、超时时间、临时节点等。Leader 上的 SessionTracker 负责会话的创建、销毁和续期。

优点: 极高的读性能,因为所有读操作都是内存访问。
挑战: 内存是易失的。如何保证服务器宕机后数据不丢失?这就引出了磁盘存储机制。


7.9.2 事务日志 (Transaction Log)

事务日志(也称 Write-Ahead Log, WAL)是保证 ZooKeeper 数据持久性和一致性的基石。任何对数据状态的变更操作,都必须先以事务的形式写入日志文件,然后才能应用到内存中。

  • 作用:

    1. 持久化: 确保所有已提交的事务都被永久记录,即使服务器宕机,也可以通过重放日志来恢复内存状态。
    2. 一致性: Leader 通过将事务日志广播给 Follower,并在收到过半数 ACK 后才提交,保证了集群数据的一致性。
  • 文件格式:

    • 日志文件位于 dataDir 目录下的 version-2 子目录中。
    • 文件名格式为 log.<zxid>,其中 zxid 是该日志文件中第一条事务记录的 ID。例如,log.100000001
    • 当一个日志文件达到一定大小(默认 64MB)或发生日志切换时,会创建一个新的日志文件。
  • 内容:

    • 文件由一系列事务记录顺序组成。
    • 每个记录包含两部分:事务头(TxnHeader事务体(Txn
      • TxnHeader: 包含 sessionID, cxid (客户端请求ID), zxid (全局事务ID), time (时间戳), type (事务类型,如 create, delete)。
      • Txn: 包含事务的具体内容,如 create 操作的路径和数据。
    • 所有内容都通过 Jute 序列化为二进制格式。
  • 刷盘机制:

    • 为了性能,ZooKeeper 并不会每写入一条日志就立即 fsync 到磁盘。它会先写入操作系统的文件缓存(page cache)。
    • SyncRequestProcessor 负责将缓存中的日志批量、异步地刷到磁盘。这种机制在性能和数据安全性之间取得了很好的平衡。只有当事务日志成功刷盘后,Follower 才会向 Leader 发送 ACK。

7.9.3 Snapshot -- 数据快照

随着时间的推移,事务日志会变得越来越大。如果一个服务器宕机后需要重启,重放所有历史日志会是一个极其漫长的过程。为了解决这个问题,ZooKeeper 引入了数据快照 (Snapshot) 机制。

  • 作用:

    • 加速恢复: 快照是某一时刻全量内存数据 (DataTreeSessionTracker) 的一个持久化副本。服务器重启时,只需加载最新的快照文件到内存,然后重放该快照之后产生的事务日志即可,大大缩短了启动时间。
    • 清理日志: 一旦某个时间点的快照被成功创建,那么这个时间点之前的所有事务日志文件就都可以被安全地清除了。
  • 触发时机:

    • 由配置参数 snapCount 控制。默认值为 100,000。
    • 每当处理了 snapCount / 2snapCount 之间的一个随机数的事务数量后,就会触发一次快照。例如,默认配置下,大约每处理 5万到10万个事务,就会生成一个新的快照。
    • 也可以通过管理命令手动触发。
  • 文件格式:

    • 快照文件也位于 dataDir/version-2/ 目录下。
    • 文件名格式为 snapshot.<zxid>,其中 zxid 是生成此快照时,服务器已经处理的最后一个事务的 ID。这个 zxid 代表了快照的数据版本。
  • 过程:

    1. 触发快照时,ZooKeeper 会开启一个独立的线程。
    2. 该线程会将当前的 DataTreeSessionTracker 数据完整地序列化。
    3. 序列化后的数据被写入一个新的临时快照文件中。
    4. 写入成功后,将临时文件重命名为正式的 snapshot.<zxid> 文件。
    5. 快照生成后,会清理掉比这个快照 zxid 更旧的快照和日志文件(会保留少数几个以备不时之需)。

7.9.4 初始化 (Initialization)

当一个 ZooKeeper 服务器节点启动时,它需要做的第一件事就是恢复自己的内存状态,以便能加入集群或参与选举。这个过程就是初始化。

  1. 寻找最新可用数据:

    • 服务器会扫描 dataDir/version-2/ 目录,找到所有 snapshot.*log.* 文件。
    • 它会找到最新的那个快照文件(即 zxid 最大的那个)。如果一个快照都没有,说明是第一次启动,内存为空。
  2. 加载快照:

    • 服务器读取最新快照文件的内容,反序列化,并在内存中重建 DataTreeSessionTracker
    • 加载后,服务器的内存状态就恢复到了快照生成时的那个时间点。此时,服务器的 lastProcessedZxid 就是快照的 zxid
  3. 重放增量日志:

    • 接着,服务器会查找所有 zxid 大于快照 zxid 的事务日志文件。
    • 它会按 zxid 从小到大的顺序,依次读取这些日志文件中的每一条事务记录,并将其应用到内存的 DataTree 中(这个过程称为“重放”)。
    • 这个过程就像快进一样,把快照之后发生的所有数据变更都重新在内存里过一遍。
  4. 初始化完成:

    • 当所有相关的增量日志都被重放完毕后,服务器的内存数据就恢复到了它宕机前的最新状态。
    • 此时,服务器就拥有了准确的 zxid,可以正式进入 Leader 选举 流程。

7.9.5 数据同步 (Data Synchronization)

当 Leader 选举产生后,集群并不能立刻对外提供服务。Follower 和 Observer 必须先和新的 Leader 同步数据,确保集群中所有节点的数据视图是一致的。

同步的触发:

  • Leader 选举结束后,Follower/Observer 连接 Leader 时。
  • 运行期间,Follower/Observer 与 Leader 断开重连时。

同步的模式:

Leader 会根据 Follower/Observer 的数据状态,选择以下四种模式之一进行同步:

  1. DIFF 模式 (差异化同步):

    • 场景: Follower 的数据状态与 Leader 差距很小。Follower 的 lastProcessedZxid 处于 Leader 内存中还记录的事务提议队列 (committedLog) 范围内。
    • 过程: Leader 不需要发送全量数据,只需将 Follower 缺失的那些事务 PROPOSAL 逐一发送给它即可。Follower 应用这些事务后,就能赶上 Leader。这是最轻量、最快的同步方式。
  2. TRUNC+DIFF 模式 (先回滚,再差异化同步):

    • 场景: Follower 的 zxid 比 Leader 还要新。这通常发生在老的 Leader 网络分区后,自己处理了一些未被集群确认的事务,然后又重新加入集群。这些事务是“脏数据”,必须被清除。
    • 过程: Leader 会发送一个 TRUNC 命令,要求 Follower 回滚到某个共同认可的 zxid。然后,再进行 DIFF 模式的同步。
  3. SNAP 模式 (快照同步):

    • 场景: Follower 的数据状态与 Leader 差距太大,或者 Follower 是一个全新的节点。具体来说,Follower 的 lastProcessedZxid 比 Leader 事务提议队列的起始 zxid 还要旧。
    • 过程: Leader 会将自己的内存数据(DataTree 和会话信息)以快照的形式,通过网络发送给 Follower。Follower 接收到快照后,会直接加载到自己的内存中,完全覆盖本地数据。这是最耗时、最消耗网络带宽的同步方式,但也是最彻底的。
  4. PROPOSAL 模式:

    • 这其实不是一种独立的同步模式,而是同步完成后的正常状态。一旦 Follower 与 Leader 数据完全对齐,Leader 就会将它加入到正常的“广播列表”中,开始向它发送新的事务 PROPOSAL

通过这套完整的数据管理和同步机制,ZooKeeper 确保了即使在节点频繁启停、发生网络故障的情况下,数据也能保持最终一致,并且能快速从故障中恢复服务。

posted @ 2026-01-31 15:34  寻找梦想的大熊  阅读(3)  评论(0)    收藏  举报