Archipelago

Lec 9案例学习:Zookeeper

讲义完成更新于 2026-08-27

摘要

阅读资料: ZooKeeper: Wait-free coordination for Internet-scale systems, atc'10) 相关资料: Zookeeper API 一致性协议 ZAB Apache ZooKeeper Wait-Free Synchronization

Lec 9 案例学习:Zookeeper

阅读资料:

ZooKeeper: Wait-free coordination for Internet-scale systems, atc'10)

相关资料:

Zookeeper API

一致性协议 ZAB

Apache ZooKeeper

Wait-Free Synchronization

思考题:ZooKeeper 的一个用途是作为容错锁服务(fault-tolerant lock service),为什么两个客户端不可能同时获得同一把锁?具体来说,ZooKeeper 是如何判断某个客户端已经失败,并且可以将该客户端的锁转让给其他客户端的?

介绍

ZooKeeper 被非常广泛地使用,因此值得仔细研究。这节 Lec 从两个维度剖析 Zookeeper:

  1. 作为构建容错应用的简化基础架构
  2. 作为类 Raft 复制协议的典型实践案例

假设我们要实现一个容错服务,比如 MapReduce 的 coordinator(协调者),我们当然可以使用 Raft 来实现复制,这样是可行的。但如果直接基于 Raft 构建服务,会很难,因为复制状态机(RSM)的编程模型很繁琐:1)所有逻辑都必须抽象成事件;2)事件必须先提交;3)提交后才能执行。 可以这样理解:Raft 的“状态机复制”本质上是在复制计算过程,而状态的复制只是副作用【顺带的事】。那么,能否在不复制计算过程的前提下实现容错? 答案是可以。

我们可以使用一个普通的、非复制的服务器。这个服务器将其状态存储在一个容错存储服务中。如果服务器崩溃,新服务器只需要从存储中读取状态即可恢复。ZooKeeper 就是这样一个为此设计的容错存储系统。在这种方式下,MR coordinator 的代码可以用普通的直线式逻辑来写,每次状态变更时把更新写入 ZooKeeper,就像写文件一样简单。

MR coordinator 可以把自己的 IP 地址、所有作业、任务状态、worker 集合和任务分配等信息写到 ZooKeeper 中(注意:大数据本身仍然保存在 GFS,而不是 ZK)。worker 可以从 ZooKeeper 中读取 coordinator 的 IP 地址,甚至是任务分配信息。因此,MR 使用 ZooKeeper 来实现配置管理,跟踪一组服务器的存在与状态;帮助服务器相互发现。

那如果 MR coordinator 崩溃了怎么办?我们并没有运行一个备用的 coordinator 实例。但这没关系!我们只需挑选任意一台机器,启动 MR coordinator 程序,让它从 ZK 中读取状态即可。新的 coordinator 就能从上一个 coordinator 停止的地方继续运行。这种做法在云环境下非常合理:在云中分配一台替代机器非常容易;不需要复杂的复制或日志同步逻辑。

当然,这种做法也带来一些挑战,ZooKeeper 在这些问题上都能提供帮助。比如:

  • 如何检测 coordinator 故障;
  • 如何保证同一时刻只有一个 coordinator 在运行(防止“双主”);
  • 如何从 ZooKeeper 中恢复状态(旧的 coordinator 可能正在更新一半的数据);
  • 以及如何处理旧 coordinator 仍然在运行并可能继续写状态的风险;
  • 如何保证性能。

ZooKeeper 架构

ZooKeeper 的服务器架构类似 Raft,采用一个 leader 和多个 follower 的模式。leader 负责为所有写请求分配全局顺序(ZXID),所有 follower 按照这个顺序执行写操作,实现状态的一致复制;而读请求则由 follower 直接处理。

在本讲的剩余部分,我们先把 ZooKeeper 当作一个黑盒

image-20251014164652780

ZooKeeper 的数据模型是一个类似文件系统的树状结构,由 znode 构成,每个 znode 有名字、内容、子节点和版本号(层次结构有助于不同 APP 避免相互干扰),znode 的类型有常规节点(regular)、临时节点(ephemeral) 和顺序节点(sequential,名称+序列号)。

接口

ZooKeeper 提供的 API:

  • s = openSession()
  • create(s, path, data, flags) ——排他性,并发创建只会成功一个
  • exists(s, path, watch) ,watch=true 请求在该路径后续被创建/删除时接收通知
  • getData(s, path, watch)->(data, version),获取数据
  • setData(s, path, data, version) ,设置数据,如果 znode.version == version,则执行更新
  • getChildren(s, path, watch), 获取子节点列表

如果 ZK 服务器通知它已终止会话,则抛出异常,这样应用程序就不会继续执行。ZooKeeper API 专为同步和并发访问设计getData()/setData(x, version) 支持迷你事务,ephemeral znode 可在客户端崩溃后自动删除,而 sequential znode 在多个客户端间建立顺序。watch 机制也能避免频繁轮询。

选主

在 MapReduce coordinator 选举中,

s = openSession()
while true:
  if create(s, "/mr/c", ephemeral=true)
    // 我们赢得了选举,现在成为协调器
    setData(s, "/mr/ip", ...)
    setData(s, "/mr/...", ...)
  else if exists(s, "/mr/c", watch=true)
    // 我们输掉了选举
    wait for watch event

利用 create(s, "/mr/c", ephemeral=true) 实现简单锁的语义:如果创建成功,说明我们赢得选举并成为 coordinator,可以设置自身的 IP 地址和任务状态等;否则,通过 exists(..., watch=true) 注册 watch 等待 coordinator 失效后重新尝试。这种做法可以自动处理 coordinator 崩溃,因为 ZooKeeper 会在客户端不再发送 keep-alive 时自动终止 session、删除其 ephemeral 节点、并拒绝该 session 的后续请求。此时,其他候选者能通过重新创建 /mr/c 来接替 coordinator 的位置。

如果当选的协调器故障了,我们希望发生什么?

Solution: 重新选主,然后处理 ZK 中更新状态时发生崩溃的情况;能够处理实际并未故障的情况。ZK 在客户端(例如 MR Coordinator)故障时,客户端停止向 ZK 发送存活消息, ZK 领导者超时并终止该会话,ZK 领导者删除该会话的所有临时 znode 并且忽略来自该会话的后续请求。现在一个新的 MR 协调器可以选举自己。

如果 MR 协调器在更新 ZK 中的状态时崩溃了怎么办?

Solution:

  • 最简单的方式是将所有状态保存在一个 znode 中,单个 setData() 调用是原子的。

更进一步,如果 MR 协调器将状态存储在多个 znode 中怎么办?有两个办法。

  • 第一,使用论文的"ready" znode 方案(第 2.3 节)删除 "ready";更新 znode;创建 "ready",新当选的 MR 协调器随后可以判断更新是否部分完成。
  • 更好的方法:写入一整套新的 znode,然后更新,一个指示哪套数据是当前的 znode

如果旧的 coordinator 还活着但被 ZooKeeper 认为失效(如网络分区),并且认为它仍然是协调器怎么办?

Solution: 因为 ZK 会在 session 终止的同时原子性地删除其 ephemeral 节点并拒绝其所有请求。即使旧 coordinator 仍在运行,也无法再读写 ZooKeeper,它的 ZK 客户端会抛出异常,迫使它意识到自己不再是 coordinator。这种机制称为 防护(fencing),确保 ZooKeeper 拒绝来自“已死亡”客户端的请求,即便客户端还在运行,从而避免“双主”问题。

[!IMPORTANT]

"防护" 是一个术语,指忽略被宣告死亡的客户端的请求

在分布式系统中,一个重要的设计模式是:由一个单一实体(如 ZooKeeper)决定哪些机器是“活着的”,哪些是“死掉的”,这个实体也被称为故障检测器(failure detector)。虽然它的判断未必总是准确(例如网络丢包时),但全系统都必须遵循它的判断,因为达成一致比判断正确更重要,以避免“脑裂”问题。正因为有可能判断错误,因此必须有“fencing”机制——ZooKeeper 通过会话终止(session termination)来实现这一点。

性能

ZooKeeper 针对读和 watch 操作进行了优化。首先,系统部署了多个 follower,客户端的请求被均匀分布到各 follower 上。读操作在 follower 本地执行,从副本中读取,避免了对 leader 的负载压力;只有写操作需要转发给 leader。其次,watch 机制由 follower 维护,避免了繁重的轮询。再次,ZooKeeper 客户端支持异步操作:客户端可以连续发出多个请求,无需等待响应,而 ZooKeeper 会以顺序高效地批量处理这些操作,从而减少消息和磁盘写次数,提高吞吐量。

不过,ZooKeeper 的读操作不一定能看到最新提交的写操作,因为客户端可能连的是一个尚未更新到最新状态的 follower。那么在什么情况下这是可以接受的?比如:展示给人类用户的数据、只读数据、可验证的数据(如 GFS 的 chunk-server 状态);但在读-改-写场景中或多个数据项需要一致性时就不可以了。ZooKeeper 对读操作提供一定的保证:每个客户端会看到写入顺序一致的结果(基于 ZXID);客户端自己的读操作会看到之前写入的内容;即使切换 follower,客户端的读也只会“前进”而不会回退,保证了“客户端 FIFO 顺序”。

为了高性能,ZooKeeper 要求所有数据都能放在内存中以保证读操作速度,因此不适合存储大量数据。它将所有写操作记录到磁盘日志中,以防崩溃丢数据,这会影响性能,但可以通过批量写入提高吞吐量。同时,ZooKeeper 会周期性地将状态快照写入磁盘,以便可以截断日志,快照过程支持与写操作并发执行。

ZooKeeper 每秒可处理数万个操作(见 Figure 5),但在 100% 写操作时性能受限,例如每个 1000 字节的写入在无压缩时每秒约 2 万次,受限于磁盘写入、网络传输等。相比之下,若客户端在每次写入后等待响应(见 Table 2),则每秒仅处理约 2000 次写入,平均每次写耗时约 1.3 毫秒。这 1.3 毫秒可能来自磁盘写、通信延迟、ZK 内部计算等因素,尽管硬盘转速较慢,批量操作和预写日志等优化能显著提升性能。

当 ZooKeeper 服务器发生故障时,系统恢复速度也很关键。Leader 故障会导致几秒钟的暂停,用于超时检测和重新选举。Follower 故障对整体吞吐量只有短暂影响,因为不涉及领导权。总体来看,leader 的恢复时间受超时设置、网络延迟、集群状态稳定性等因素影响。

ZooKeeper 成为许多分布式系统(如 Hadoop、HBase、Kafka 等)的核心组件,应用广泛。但它也存在一些不足:例如读操作不是线性一致的;session 机制粗糙,若能细化为每个 znode 的租约更灵活;znode 树不易分片,导致难以扩展;事务只支持单个 znode,缺乏多节点原子操作。后来的系统如 etcd 和 Consul 针对这些问题做出了不同的设计改进。

论文阅读: ZooKeeper

本讲导览

在这篇论文中,我们介绍了 ZooKeeper——一个用于协调分布式应用中各个进程的服务。它的目标是为客户端构建更复杂的协调原语提供一个简单且高性能的内核。融合了组消息传递(group messaging)、共享寄存器(shared registers)和分布式锁服务的元素,以复制的集中式服务形式实现。

ZooKeeper 提供的接口同时具备无锁(wait-free)特性,结合了分布式文件系统缓存失效机制和事件驱动机制,从而实现了一个简单而强大的协调服务。除了无等待特性外,ZooKeeper 还提供了,对每个客户端请求按 FIFO 顺序执行,对所有会改变 Zookeeper 状态的请求保证可线性化。在典型负载(读写比例从 2:1 到 100: 1)情况下,ZK 能够处理每秒数万到数十万次事务。这种性能使得 ZooKeeper 能够被客户端应用广泛使用。

1. 引言

大规模分布式应用需要不同形式的协调。 配置就是其中最基本的协调形式。就最简单的形式而言,配置只是一组系统进程的操作参数,而更复杂的系统则具有动态配置参数。 组成员管理和 leader 选举在分布式系统中也非常通用:进程之间需要知道其他进程是否活着,以及这些进程负责哪些任务。锁构成了对关键资源的互斥访问。

一种协调的方法就是为不同的协调需求开发服务。有些负责配置、有些负责队列等等。例如,Chubby 是一个具有强大同步保证的分布式锁服务。锁可以用来实现领导者选举、组成员身份等。

我们设计的协调服务,**放弃了在服务器端实现特定的原语,而是公开一个 API,使 APP 开发人员能够实现自己的原语。**简单说,就是实现协调内核,为了适应应用程序的需求,能够支持启用新的原语,而不是限制在固定原语上。在设计 Zookeeper 的 API 时,我们摒弃了阻塞原语,比如锁。因为它会导致性能问题,对速度更快的客户端造成负面影响。如果处理请求依赖于其他客户端的响应和故障检测,服务本身的实现会变得复杂。因此 ZooKeeper 的实现是操纵简单的无等待数据对象(wait-free),同时也是一种类似文件系统的层级结构。仅从 API 签名看,ZooKeeper 就像是没有锁方法的 Chubby。

无等待特性对于性能和容错能力很重要,但对于协调而言还不充足。我们还需要为操作提供顺序保证。具体而言,我们发现,同时保证所有操作的先进先出 (FIFO) 客户端顺序和线性写入操作能够高效地实现服务,并且足以实现我们应用程序所需的协调原语。事实上,我们可以使用我们的 API 为任意数量的进程实现共识,,并且根据 Herlihy 的层次结构,Zoo Keeper 实现了一个通用对象。

Zookeeper 服务是由一组通过复制来实现高可用的服务器组成。我们能够使用简单的流水线架构来实现 ZooKeeper,该架构允许我们在处理数百或数千个待处理的请求的同时仍然实现低延迟。这样的流水线自然能够以 FIFO 的顺序执行来自单个客户端的操作。保证 FIFO 客户端顺序使客户端能够异步提交操作。使用异步操作,客户端可以同时拥有多个未完成的操作。例如,当新客户端成为领导者,并且必须操作元数据并进行相应更新时,此功能非常有用。

为了保证更新操作满足线性化,我们实现了一个基于领导者的原子广播协议,称为 Zab。Zookeeper 应用程序的典型工作负载以读操作为主,因此需要扩展读取吞吐量。在 ZooKeeper 中,服务器在本地处理读取操作,我们不使用 Zab 对它们进行完全排序。

客户端缓存数据时提高读取性能的重要技术。Zookeeper 使用监视机制,使客户端能够缓存数据,而无需直接管理客户端缓存。通过此机制,客户端可以监视给定数据对象的更新,并在更新时收到通知。Chubby 直接管理客户端缓存。它会阻止更新,以验证所有缓存了正在更改数据的客户端的缓存。在这种设计下,如果任何一个客户端速度慢或出现故障,更新就会延迟。Chubby 使用租约来防止故障客户端无限期地阻塞系统。然而,租约只能限制速度慢或故障客户端的影响,而 ZooKeeper 的监视机制则完全避免了这个问题。

本文讨论了 ZooKeeper 的设计和实现。借助 ZooKeeper,我们能够实现应用程序所需的所有协调原语,即使只有写入操作是线性化的。总而言之,本文的主要贡献如下:

  • 协调内核:我们提出了一种用于分布式系统的无等待协调服务,该服务具有宽松的一致性保证。具体来说,我们描述了协调内核的设计和实现,我们已在许多关键应用程序中使用该内核来实现各种协调技术。
  • 协调方案:我们展示了如何使用 ZooKeeper 构建更高级别的协调原语,甚至是分布式应用程序中经常使用的阻塞和强一致性原语。
  • 协调经验:我们分享了一些使用 ZooKeeper 的方法,并评估了其性能

2. Zookeeper 服务

客户端向 Zookeeper 提交请求,该客户端库函数还管理与服务器之间网络连接。使用znode表示 ZooKeeper 数据中的内存数据节点,该节点组织在一个称为**数据树(data tree)**的分层命名空间中。客户端连接到 Zookeeper 时会建立会话,并获取一个会话句柄,通过该句柄发出请求。

2.1 服务总览

ZooKeeper 向客户端提供 一组数据节点znode的抽象,这些节点根据分层命名空间组织。znode是客户端通过 Zookeeper API 操作的数据对象。因为用户习惯于这种分层命名空间的抽象,并且能够更好地组织应用程序元数据。例如用/A/B/C表示 znode C的路径。除了临时znode外,所有znode都可以有子节点。

客户端有两种类型的znode可供创建:

  • 常规: 客户端显式创建和删除来操作常规znode
  • 短暂(Ephemeral):客户端创建此类 znode,要么显式删除它们,要么让系统在创建它们的会话终止(故意或由于故障)时自动移除它们。

此外,在创建新 znode 时,客户端可以设置序列(sequential)标志。设置了序列标志的节点在其名称后附加一个单调递增的计数器值。如果 n 是新的 znode,且 p 是其父 znode,那么 序列值 n 永远不会小于 p 下创建的其他节点的序列值。

Zookeeper 实现了监视功能(watch),允许客户端即使接受更改通知,而无需轮询。当客户端发出设置了监视标志的读取操作时,该操作将正常完成,但服务器承诺在返回的信息发生变化时通知客户端。 watch 是与会话关联的一次性触发器。如果你对某个 znode 设置了 watch(比如用 getData("/foo", true)),那么一旦这个 znode 有变化(不告诉你具体变了什么),watch 会触发一次,然后就自动取消注册。你要想继续监听,必须重新设置 watch。如果变更发生多次,仍只触发一次。如果你的客户端与 ZooKeeper 的连接丢失了,你设置的 watch 可能延迟甚至永远不会触发。

数据模型。 Zookeeper 的数据模型本质上是一个具有简化 API 且仅支持完整数据读写的文件系统,或者是具有分层 K/V 表。分层命名空间可用于不同 APP 的命名空间分配子树,并设置这些子树的访问权限。我们还利用客户端的目录概念来构建更高级别的原语,2.4 节将介绍。

与文件系统中的文件不同,znode 并非设计用于通用数据存储。znode映射到客户端应用程序的抽象,通常是用于协调目的的元数据。在图 1 中,我们有两个子树,一个用于 APP1(/app1),一个用于 APP2(/app2)。APP1 在子树实习拿了简单的组成员协议:每个客户端进程pip_i在 /app1创建znode pip_i ,只要进程运行,该 znode 就一直存在。 虽然 znode 并非为通用数据存储而设计,但 ZooKeeper 允许客户端存储一些可用于分布式计算中的元数据或配置的信息。例如在领导者选举的应用中,,我们可以让当前领导者将此信息写入 znode 空间中的已知位置。znode 还具有与时间戳和版本计数器关联的元数据,这使得客户端可以跟踪 znode 的变化并根据 znode 的版本执行有条件的更新。

image-20250713210555947

**会话。**一个客户端连接到 ZK 并初始化一个会话,会话会关联一个超时时间。如果客户端在超过该超时时间后仍未从其会话中收到任何数据,ZooKeeper 会认为该客户端出现故障。在会话中,客户端会观察到一系列反映其操作执行情况的状态变化。会话使客户端能够在 ZooKeeper 集群内透明地从一个服务器移动到另一个服务器,从而实现跨 ZooKeeper 服务器的持久化。

2.2 客户端 API

  • create(path, data, flags): 创建一个znode, flag 设置常规还是临时的, 以及设置序列标志,并将data[]存入该节点,返回新节点的名称
  • delete(path, version):如果znode在预期的版本将对其进行删除
  • exists(path, watch):如果对应路径的znode存在返回 true,watch 标志 使能客户端设置一个 watch
  • getData(path, watch): 返回与 znode 关联的数据和 元数据,例如版本信息。watch 标志的工作方式与 exists()相同,不同之处在于,如果 znode 不存在,ZooKeeper 不会设置 watch;
  • setData(path, data, version):如果版本号是 znode 的当前版本,则将 data[] 写入 znode path;
  • getChildren(path, watch):返回 znode 子节点的名称集合;
  • sync(path):等待操作开始时所有待处理的更新传播到客户端连接的服务器。path 当前被忽略

所有方法都通过 API 提供同步和异步版本。当应用程序需要执行单个 ZooKeeper 操作且没有并发任务需要执行时,它会使用同步 API,因此它会进行必要的 ZooKeeper 调用并阻塞。然而,异步 API 允许应用程序并行执行多个未完成的 ZooKeeper 操作和其他任务。ZooKeeper 客户端保证按顺序调用每个操作对应的回调函数。

需要注意的是,ZooKeeper 不使用句柄来访问 znode。每个请求都包含正在操作的 znode 的完整路径。这种选择不仅简化了 API(无需 open() 或 close() 方法),而且还消除了服务器需要维护的额外状态。 每个更新方法都接受一个预期版本号,从而可以实现有条件的更新。如果 znode 的实际版本号与预期版本号不匹配,则更新失败并出现意外版本错误。如果版本号为 -1,则不执行版本检查。

2.3 Zookeeper 保证

4. ZK 的实现

image-20251014154223350

Zookeeper 通过在组成服务的每台服务器上复制 ZK 数据来实现高可用。图 4 展示了 ZooKeeper 服务的高层结构组件。当服务器收到一个请求时,首先进入请求处理器(Request Processor)准备执行。如果该请求需要再多台服务器上进行协调(即写请求),则会通过一个一致性协议(原子广播的实现)达成一致,最终各服务器将更改提交到完全复制的 Zookeeper 数据库。而对于读请求,服务器只需读取本地数据库状态并生成响应即可。

ZooKeeper 的复制数据库是一个内存数据库,包含了完整的数据树结构。数据树中的每个节点(znode)默认最多可存储 1MB 数据,但这个上限是可配置的,可以根据需要调整。为了保证可恢复性,在更新写入内存数据库之前需要强制将日志刷入磁盘,与 Chubby 类似,ZK 也维护一个重放日志,在我们的实现中,它是一个预写日志,同时系统会定期生成内存数据库的快照。每个 ZooKeeper 服务器都能直接为客户端提供服务。

4.1 请求处理器

由于消息层保证了原子性(atomicity),我们可以确保所有服务器的本地副本不会出现分歧,尽管在某一时刻,某些服务器可能已经应用了更多的事务。与客户端请求不同,这些事务是幂等的。当 leader 接收到一个写请求时,它会计算该请求应用后系统的未来状态,并将其转化为一个事务,以捕获这一新的状态。之所以需要计算未来状态,是因为数据库中可能存在尚未应用的待处理事务。例如,如果客户端发起一个条件setData操作,而请求中的版本号与将被更新的 znode 的未来版本号匹配,系统就会生成一个setDataTXN,其中包含:新数据内容、新的版本号和更新时间戳。如果出现错误(版本号不匹配或者 znode 不存在),则会生成一个 errorTXN

4.2 原子广播

所有会修改 ZooKeeper 状态的请求都会被转发到 leader,由 Leader 执行该请求,通过 Zab 原子广播协议将状态变化广播给所有 ZK 服务器。接受客户端请求的服务器在接收到对应状态变更后,才会向客户端发送响应。

Zab 默认使用简单多数派原则来对提案达成共识。为了获得高吞吐量,ZooKeeper 会尽量保持请求处理流水线处于饱和状态,系统中可能同时存在上千个不同阶段的请求。由于状态变化之间存在依赖关系,Zab 提供了比普通原子广播更强的顺序保证:leader 广播的变更必须按发送顺序被传递;新的 leader 必须在广播自己的变更前,先接收完前任 leader 的所有变更。

在正常情况下,Zab 保证所有消息有序且仅传递一次,但由于 Zab 并不会持久化记录每个已传递消息的 ID,在恢复期间可能出现消息重复传递。由于 ZK 的事务是幂等的,只要消息传递顺序正确,多次传递是可接受的,实际上 ZK 要求 Zab 至少重新传递自上一次快照开始后的所有消息。

4.3 复制数据库恢复

每个副本在内存中都维护着一份 ZooKeeper 的状态。当某个 ZooKeeper 服务器从崩溃中恢复时,需要恢复其内部状态。如果每次都重放所有已传递的消息,恢复时间会极其漫长,因此 ZooKeeper 采用了周期性快照, 只需重新传递自快照开始以来的消息即可恢复。

ZooKeeper 的快照称为模糊快照(fuzzy snapshot),因为系统在生成快照时不会锁住整个状态。 相反,它通过深度优先遍历依次原子地读取每个 znode 的数据与元数据,并写入磁盘。

这种模糊快照可能包含部分在快照生成过程中交付的状态变更,因此快照结果可能并不对应 ZooKeeper 在某个精确时间点的状态。不过,由于状态变更是幂等的,只要按顺序应用,重复应用也不会出错。

例如,我们假设 ZK 数据树中有两个节点/foo和/goo,他们值分别是f1和g1,版本号均为 1,在快照开始后,系统收到一下状态变更流:

<SetDataTXN, /foo, f2, 2>
<SetDataTXN, /goo, g2, 2>
<SetDataTXN, /foo, f3, 3>

应用这些变更后,/foo 的值为 f3(版本 3),/goo 的值为 g2(版本 2)。但模糊快照可能记录的是 /foo=f3, version=3 和 /goo=g1, version=1,这是一个在任何时刻都未真正存在过的状态。

如果服务器崩溃并以该快照恢复,Zab 会重新传递这些状态变更,最终恢复的状态将与崩溃前的服务状态一致。

4.4 客户端-服务器 交互

当服务器处理一个写请求时,它还会发送并清除与该更新相关的所有 watch 的通知。服务器按顺序处理写请求,并且不会同时处理其他写或读请求,从而确保通知的严格顺序性。需要注意的是,通知的处理是本地完成的:只有客户端所连接的那台服务器会跟踪并触发该客户端的通知。

读请求则由每个服务器本地处理。每个读请求会被处理并打上一个 zxid(ZooKeeper transaction id),它对应于该服务器所见到的最新事务。这个 zxid 定义了读请求相对于写请求的部分顺序。通过在本地处理读请求,我们获得了极高的读性能,因为这仅仅是一次内存操作,不涉及磁盘访问,也无需运行一致性协议。这一设计选择是 ZooKeeper 在读为主的负载下实现高性能的关键。

这种快速读取机制的一个缺点是——不能保证读操作的先后顺序。也就是说,一个读操作可能会返回过期的数据,即使对同一个 znode 的更新已经被提交。 并非所有应用都需要这种“先后顺序一致性”,但对于确实需要的应用,ZooKeeper 提供了 sync(同步) 原语。 该原语以异步方式执行,由 leader 在处理完本地所有待写请求后再进行排序。 若要保证一次读操作返回最新值,客户端可以先调用 sync,然后再执行读操作。 由于客户端操作具有 FIFO 顺序保证,而 sync 又具有全局顺序保证,因此读操作的结果能够反映出在 sync 之前发生的所有更改。

在我们的实现中,不需要对 sync 做原子广播,因为我们采用了基于 leader 的算法。 我们只需把 sync 操作放在 leader 与 follower 之间请求队列的末尾即可。为了让这个机制正常工作,follower 必须确信 leader 仍然是合法的 leader。如果存在待提交事务,follower 就不会怀疑 leader; 如果队列为空,leader 需要发出一个空事务(null transaction)来提交,然后将 sync 排在该事务之后。这样,当 leader 负载较高时,就不会产生额外的广播流量。 在实现中,我们设置的超时时间保证 leader 在被 follower 放弃之前就能意识到自己不再是 leader,因此不会发出空事务。

ZooKeeper 服务器以 FIFO 顺序 处理来自客户端的请求。响应中会包含与该响应对应的 zxid。即使在系统空闲时发送的心跳消息,也会携带服务器当前看到的最新 zxid。当客户端连接到另一台服务器时,新服务器会确保自己的数据视图至少和客户端的一样新: 它会检查客户端上次看到的 zxid 是否比自己更新。 如果客户端的 zxid 更新于服务器,那么服务器会先追上(catch up)再与客户端重建会话。 客户端总能找到一台视图足够新的服务器,因为客户端只会看到已经被多数服务器复制的更改。 这种机制对于保证**持久性(durability)**非常重要。

为了检测客户端会话失败,ZooKeeper 使用 超时机制。 如果在会话超时时间内,没有任何服务器从某个客户端会话接收到消息,leader 就判断该会话失效。 若客户端请求足够频繁,就不需要额外发送消息; 否则,客户端会在空闲时段发送心跳消息。如果客户端无法与某台服务器通信以发送请求或心跳,它会连接到另一台 ZooKeeper 服务器重新建立会话。为防止会话超时,ZooKeeper 客户端库会在会话空闲了 s/3 毫秒 后发送心跳;如果在 2s/3 毫秒 内仍未收到服务器响应,就会切换到新的服务器。 其中 s 是以毫秒为单位的会话超时时间。

5. 评估

50 台机器,每台机器 2 核 4G,千兆以太网,两个 SATA 硬盘。主要讨论请求吞吐量和延迟。其中一块硬盘用于记录日志,另一块生成快照。

5.1 吞吐量

在实验中,我们改变了组成 ZooKeeper 服务的服务器数量,但客户端数量保持不变。为了模拟大量客户端,我们使用 35 台机器来模拟 250 个并发客户端。客户端采用异步 API,每个请求包含 1KB 的读或写。客户端每 300ms 上报一次已完成的操作数,每 6 秒采样 1 次结果。为了防止内存溢出,服务器会限制系统中并发请求的数量。 ZooKeeper 通过 请求节流(request throttling) 防止服务器过载。我们将每台 ZooKeeper 服务器的并发处理上限设置为 2000 个请求。

image-20251014145315771

在图 5 中,我们展示了当读写比例变化时的吞吐量表现,每条曲线代表不同数量的 ZooKeeper 服务器。表 1 列出了读负载比例的两个极端情况的具体数值。

结果显示:

  • 读吞吐量明显高于写吞吐量,因为读请求不需要使用原子广播
  • 服务器数量越多,广播协议的性能越受影响

写请求比读请求耗时更长的原因有两点:

  1. 写请求必须经过原子广播,这需要额外的处理,从而增加了延迟
  2. 服务器必须确保事务被写入非易失性存储后,才能向 leader 发送确认。

理论上这个要求有些严格,但在生产系统中,我们牺牲性能以换取可靠性,因为 ZooKeeper 代表了应用程序的真实状态。

ZooKeeper 之所以能够实现如此高的吞吐量,是因为它将负载分布在组成服务的多台服务器之间。这种负载分布得以实现,是由于 ZooKeeper 采用了较为宽松的一致性保证。相比之下,Chubby 的客户端会将所有请求都直接发送给 leader。 图 6 展示了如果我们不利用 ZooKeeper 的这种宽松一致性,而强制所有客户端仅与 leader 通信时的情况。 可以看到,在读密集型负载下,吞吐量显著下降; 甚至在写密集型负载下,吞吐量也有所降低。

image-20251014145822598

ZooKeeper 系统中最主要的性能瓶颈来自于 原子广播协议。 图 7 展示了原子广播组件本身的吞吐量。 为了对其性能进行基准测试,我们在实验中直接在 leader 端生成事务,以此模拟客户端请求,因此不涉及实际的客户端连接、请求或回复。 在最大吞吐情况下,原子广播组件受限于 CPU(CPU-bound)。

image-20251014145934179理论上,图 7 中的性能应该等同于 ZooKeeper 在 100% 写负载下的性能。 但实际上,ZooKeeper 还需要额外的 CPU 处理:客户端通信、ACL 权限检查、请求到事务的转换。这些额外的 CPU 竞争导致 ZooKeeper 的整体吞吐量明显低于单独的原子广播模块。

由于 ZooKeeper 是关键的生产组件,目前开发的重点主要在于正确性和鲁棒性性(robustness)。不过,仍有大量机会可以显著提升性能,例如:

  • 减少不必要的数据拷贝;
  • 避免同一对象的多次序列化;
  • 使用更高效的内部数据结构等。

为了展示系统在故障注入时的动态表现,我们运行了一个由 5 台机器组成的 ZooKeeper 服务,并在其上执行与之前相同的饱和基准测试。不过这次我们将写请求比例固定为 30%,这是一个保守的、符合预期生产负载的比例。在测试过程中,我们定期杀死部分服务器进程。图 8 展示了随着时间推移系统吞吐量的变化情况。 图中标注的事件如下:

  1. 一个 follower 故障并恢复;
  2. 另一个 follower 故障并恢复;
  3. leader 故障;
  4. 两个 follower(a, b)相继故障,并在标记 (c) 时恢复;
  5. leader 再次故障;
  6. leader 恢复。

image-20251014150443381

从该图中可以得到几个重要观察结论:

  1. 如果 follower 发生故障并快速恢复,ZooKeeper 仍能保持较高的吞吐量。
  2. ZooKeeper 的 leader 选举算法恢复非常迅速,能够防止吞吐量显著下降。实验观察表明,ZooKeeper 选出新 leader 的时间小于 200ms。
  3. 即使 follower 恢复较慢,ZooKeeper 也能在它们重新开始处理请求后逐步恢复吞吐量。 不过,在事件 1、2 和 4 之后,吞吐量没有完全恢复到故障前水平,原因是: 客户端只会在与 follower 的连接断开时切换到其他 follower。因此,在事件 4 之后,客户端并未立即重新分布,直到 leader 在事件 3 和 5 处发生故障时才发生重新分配。

课件精讲:ZooKeeper 的核心思想

课件:Zookeeper (l-zookeeper.txt);论文:ZooKeeper (2010);FAQ

一句话精髓

ZooKeeper 给"协调(coordination)"做了专门设计:用 watch / session / 精心选择的一致性语义,把关键状态存进一个容错系统,而计算跑在不容错的服务器上——协调者挂了,新协调者从 ZK 加载状态即可恢复,无需自己做 Raft 那样的状态机复制。

两个关键语义

① A-linearizability(异步线性一致):保证同一客户端异步提交的一批请求按提交顺序执行(FIFO client order);写是线性一致的。② 读可由任意 follower 本地服务、可能陈旧——以此换取高吞吐(三台 21000 写/秒)。能接受陈旧读,因为副本通常只落后几个写、且应用(如每 10 秒查一次分配的 MapReduce 协调者)容忍小延迟。

session + ephemeral znode = 锁/选主/fencing

session 维护客户端状态、管 FIFO 顺序与 watch;临时(ephemeral)znode 在 session 过期时自动删除。于是选主 = 抢建一个临时 znode(相当于可续约的租约),持有者崩溃 → znode 消失 → 锁释放 → 别人接管;过期即失权,天然做到 fencing(防被废黜的旧 leader 继续捣乱)。pipeline(leader 批量 + 客户端异步连发)+ FIFO 顺序撑起高吞吐。

FAQ(ZooKeeper 答疑整理)

来自 zookeeper-faq.txt。

  • 本文主要 take-away? 用 watch/session + 选定的一致性语义做容错协调:关键状态存容错系统、计算跑在非容错服务器,协调者挂了加载状态即恢复,无需复杂的状态机复制。
  • session 有什么用? 维护客户端-服务器状态(带 session ID),管 FIFO 顺序、追踪 watch,并控制 ephemeral znode(session 过期自动删)——支撑可续约租约式选主与 fencing。
  • §2.4 的锁保护一组 znode 更新,持有者更新到一半崩了怎么恢复? 它的 ephemeral znode 消失、锁释放;新持有者看到半更新数据,须做类数据库崩溃恢复(如写新文件、文件不全就回退旧版本)。
  • A-linearizability 与 linearizability 区别? A 版保证同一客户端异步请求按提交顺序执行,而标准线性一致允许并发操作任意排序。
  • 为什么只有写是 A-linearizable,读不是? 让 follower 各自本地服务读以最大化吞吐;但 follower 可能缺已提交写或含未提交数据 → 读可能陈旧,是性能换一致性的取舍。
  • linearizability 与 serializability 区别? serializable 只要求"看起来串行"、不管实时序;linearizable 还要求顺序尊重真实时间。ZK 用"serializable"指写串行 + FIFO 客户端序。
  • 为什么 ZK 可以返回过期读数据? 副本通常只落后 leader 几个写;且即便保证最新,回复到达前也可能又有新写;应用(如每 10 秒查的 MR 协调者)能容忍。
  • pipelining 是什么? leader 批量多操作高效发网络/写盘 + 客户端异步连发多写不等回复;FIFO 顺序防并发重排暴露中间态。
  • leader 怎么知道客户端异步更新的顺序? 客户端库给异步请求编序号,leader 按 session 追踪下一个期望序号;序号随复制日志项传递以跨 leader 切换存活。
  • wait-free 是什么? 任一进程在有限步内完成任一操作、不受他人速度影响;ZK 的 API 无需等其他客户端(靠 watch + 轮询而非阻塞)。
  • 客户端没收到回复怎么办?会不会重复执行? 客户端重发;leader 按 session 追踪已收/已提交请求号过滤重复;若发送期间 session 过期,客户端无法可靠判断是否已执行。
  • 异步写后立刻读,能看到该写吗? 能,FIFO 客户端序保证后续读看到同客户端先前的写(follower 会阻塞读直到收齐该 session 的前序操作)。
  • fuzzy snapshot 为什么? ZK 周期把全量状态打快照、截断日志以便掉电恢复;不暂停写、"模糊"地拍内存 → 快照是不一致子集;恢复时从快照点重放所有日志,因操作幂等而被纠正。
  • 何处需要把操作转成幂等? sequential create 非幂等(执行两次产生两个编号 znode);leader 把它转成"先算好最终结果(含 znode 编号)再记日志"的幂等形式,使含部分日志的模糊快照恢复时不重复建 znode。
  • leader 怎么选? 用 ZAB(ZooKeeper 原子广播),内置类 Raft 的选举。
  • 性能对比 Paxos/Raft? 三台 21000 写/秒;机械盘 Raft 通常几十/秒、SSD 几百/秒,远慢于 ZK。
  • 能不停机加服务器吗? 现代 ZK 支持动态重配置;原论文是静态集群。
  • watch 在客户端库怎么实现? 注册回调;如 Go 客户端给 GetW() 传 channel,watch 触发时往 channel 发事件。
  • 为什么叫 ZooKeeper? "协调分布式系统就像看管一个动物园",戏称分布式管理的混乱复杂。