ZooKeeper: Wait-free coordination for Internet-scale systems
用于互联网规模系统的无等待协调服务
Abstract
在本文中,我们描述了ZooKeeper,这是一个用于协调分布式应用程序进程的服务。由于ZooKeeper是关键基础设施的一部分,ZooKeeper旨在为客户端构建更复杂的协调原语提供一个简单且高性能的内核。它将group messaging、shared register和分布式锁服务的元素融入到一个复制的、集中式服务中。ZooKeeper 暴露出的接口具有shared register的无等待属性,并具有类似于分布式文件系统缓存失效的事件驱动机制,以提供一个简单而强大的协调服务。
ZooKeeper接口使高性能服务实现成为可能。除了无等待属性外,ZooKeeper还为每个客户端提供FIFO执行请求的保证以及对所有更改ZooKeeper状态的请求的线性化。这些设计决策使得可以实现一个高性能处理pipeline,读取请求由本地服务器满足。我们展示了针对目标工作负载,2:1至100:1的读写比,ZooKeeper可以处理每秒数万到数十万的事务。这种性能使得ZooKeeper可以被客户端应用程序广泛使用。
1. Introduction
大规模分布式应用程序需要不同形式的协调。配置是最基本的协调形式之一。在其最简单的形式中,配置只是系统进程的操作参数列表,而更复杂的系统具有动态配置参数。组成员关系和领导者选举也常见于分布式系统中:进程通常需要知道哪些其他进程是活动的以及这些进程负责什么。锁是一种强大的协调原语,实现对关键资源的互斥访问。
一种协调方法是为不同的协调需求开发服务。例如,亚马逊简单队列服务[3]专注于排队。其他服务专门为领导者选举[25]和配置[27]而开发。另一种方法是实现更强大原语的服务可以用于实现较弱的原语。例如,Chubby[6]是具有强同步保证的锁服务。然后,锁可以用于实现领导者选举、组成员关系等。
在设计我们的协调服务时,我们放弃了在服务器端实现特定原语,而是选择暴露一个API,使应用程序开发人员能够实现他们自己的原语。这样的选择导致了一个协调内核的实现,它能够在不需要更改服务核心的情况下实现新的原语。这种方法使得可以根据应用程序的需求实现多种形式的协调,而不是将开发人员限制在固定的原语集合中。(所以说,ZooKeeper 是一种通用的协调服务。)
在设计ZooKeeper的API时,我们摒弃了阻塞原语,例如锁。对于协调服务来说,阻塞原语可能会导致其他问题,例如,缓慢或故障的客户端对更快客户端的性能产生负面影响。如果处理请求依赖于其他客户端的响应和故障检测,那么服务本身的实现就会变得更加复杂。因此,我们的系统ZooKeeper实现了一个API,该API操作简单的无等待数据对象(znode),这些对象按照文件系统的方式分层组织。实际上,ZooKeeper API类似于任何其他文件系统的API,从API签名来看,ZooKeeper似乎是没有lock方法、open和close的Chubby。然而,实现无等待数据对象使得ZooKeeper与基于阻塞原语(如锁)的系统有很大不同。
尽管无等待属性对于性能和容错非常重要,但它对于协调来说还不够。我们还需要为操作提供顺序保证。特别是,我们发现保证所有操作的FIFO客户端顺序和线性化写入可以实现服务的高效实现,并且足以实现我们应用程序感兴趣的协调原语。实际上,我们可以使用我们的API为任意数量的进程实现共识,并根据Herlihy的层次结构,ZooKeeper实现了通用对象[14]。
ZooKeeper服务包括一个服务器集群,通过复制实现高可用性和性能。其高性能使得包含大量进程的应用程序可以使用这样一个协调内核来管理所有协调方面。我们能够使用简单的流水线架构实现ZooKeeper,这使我们可以在保持低延迟的同时处理数百或数千个请求。这样的流水线自然地使得来自单个客户端的操作按照FIFO顺序执行。保证FIFO客户端顺序使客户端能够异步提交操作。通过异步操作,客户端可以同时拥有多个未完成的操作。例如,当一个新客户端成为领导者并且需要操作元数据并相应地更新它时,这个功能是可取的。如果没有多个未完成操作的可能性,初始化时间可能会达到几秒钟,而不是亚秒级。
为了确保更新操作满足线性化,我们实现了一种基于领导者的原子广播协议[23],称为Zab[24]。然而,ZooKeeper应用程序的典型工作负载主要是由读操作占据的,因此需要扩展读取吞吐量。在ZooKeeper中,服务器本地处理读操作,我们不使用Zab对它们进行完全排序。
在客户端缓存数据是提高读取性能的一种重要技术。例如,对于一个进程来说,缓存当前领导者的标识符而不是每次需要知道领导者时都探测ZooKeeper是很有用的。ZooKeeper使用一种监听机制,使客户端可以在不直接管理客户端缓存的情况下缓存数据。通过这种机制,客户端可以监听某个数据对象的更新,并在更新时收到通知。Chubby直接管理客户端缓存。它阻塞更新以使所有缓存正在更改的数据的客户端的缓存失效。在这种设计下,如果这些客户端中的任何一个速度慢或故障,更新将被延迟。Chubby使用租约来防止故障客户端无限期地阻塞系统。然而,租约只限制了慢速或故障客户端的影响,而ZooKeeper监听则完全避免了这个问题。
在本文中,我们讨论了我们对ZooKeeper的设计和实现。通过ZooKeeper,即使只有写操作是线性化的,我们能够实现我们的应用程序所需的所有协调原语。为了验证我们的方法,我们展示了如何使用ZooKeeper实现一些协调原语。
总之,在本文中,我们的主要贡献是:
Coordination kernel:
我们提出了一种无等待协调服务,用于分布式系统中的宽松一致性保证。特别是,我们描述了我们设计和实现的协调内核,我们已经在许多关键应用中使用它来实现各种协调技术。
Coordination recipes:
我们展示了如何使用ZooKeeper构建更高级别的协调原语,甚至是阻塞和强一致性原语,这些原语通常用于分布式应用程序。
Experience with Coordination:
我们分享了我们使用ZooKeeper的一些方法,并评估其性能。
2. The ZooKeeper service
客户端通过使用ZooKeeper客户端库通过客户端API向ZooKeeper提交请求。除了通过客户端API暴露ZooKeeper服务接口外,客户端库还管理客户端与ZooKeeper服务器之间的网络连接。
在本节中,我们首先提供ZooKeeper服务的高层视图。然后我们讨论客户端用来与ZooKeeper互动的API。
**术语。**在本文中,我们使用客户端表示ZooKeeper服务的用户,服务器表示提供ZooKeeper服务的进程,znode表示ZooKeeper数据中的内存数据节点,该节点以称为data tree的分层命名空间组织。我们还使用术语update和write来指代修改data tree状态的任何操作。客户端在连接到ZooKeeper时建立会话,并通过session handle发出请求。
2.1 Service overview
ZooKeeper为其客户端提供了一组数据节点(znodes)的抽象,这些节点按照分层的命名空间进行组织。这个层次结构中的znodes是客户端通过ZooKeeper API操作的数据对象。分层命名空间通常用于文件系统。这是组织数据对象的理想方式,因为用户习惯于这种抽象,而且它能更好地组织应用程序元数据。要引用给定的znode,我们使用标准的UNIX文件系统路径表示法。例如,我们使用/A/B/C表示到znode C的路径,其中C的父节点是B,B的父节点是A。所有znodes都存储数据,除了临时znodes之外,所有znodes都可以有子节点。
有两种类型的znode客户端可以创建:
**常规(Regular):**客户端通过显式创建和删除regular znodes来操作它们;
短暂的(Ephemeral): 客户端创建此类znodes,它们可以显式删除它们,或者在创建它们的会话终止时(有意或因故障)让系统自动删除它们。
此外,在创建新的 znode 时,客户端可以设置一个sequential标志。使用sequential标志创建的节点会将单调递增计数器的值附加到其名称中。如果 n 是新的 znode,p 是父 znode,那么 n 的sequential值永远不会小于在 p 下创建的任何其他sequential znode 名称中的值。
ZooKeeper实现了监听器,以便客户端在无需轮询的情况下接收到变更的及时通知。当客户端发出带有监听器标志的读取操作时,操作将按正常方式完成,但服务器承诺在返回的信息发生更改时通知客户端。监听器是与会话关联的一次性触发器;一旦触发或会话关闭,它们将被注销。监听器表明已发生更改,但不提供更改。例如,如果客户端在“/foo”更改两次之前发出getData(‘‘/foo’’, true),客户端将获得一个监听器事件,告知客户端“/foo”的数据已更改。会话事件,如连接丢失事件,也会发送到监听器回调,以便客户端知道监听器事件可能会延迟。
**数据模型(Data model)。**ZooKeeper的数据模型本质上是一个具有简化API和完整数据读写的文件系统,或者是一个具有分层键的键/值表。分层命名空间对于分配不同应用程序的命名空间的子树和设置对这些子树的访问权限非常有用。我们还利用客户端的目录概念构建更高级别的原语,如我们将在2.4节中看到。

与文件系统中的文件不同,znodes 不是为一般数据存储而设计的。相反,znodes 映射到客户端应用程序的抽象,通常对应于用于协调目的的元数据。为了说明,在图 1 中,我们有两个子树,一个用于应用程序 1(/app1),另一个用于应用程序 2(/app2)。应用程序 1 的子树实现了一个简单的组成员关系协议:每个客户端进程 pi 在 /app1 下创建一个 znode p i,只要进程运行,它就会一直存在。
尽管znodes没有为通用数据存储而设计,但ZooKeeper确实允许客户端存储一些可用于元数据或分布式计算中的配置的信息。例如,在基于领导者的应用程序中,对于刚刚启动的应用程序服务器来说,了解当前哪个服务器是领导者非常有用。为了实现这个目标,我们可以让当前的领导者将这些信息写入znode空间中的已知位置。Znodes还具有与时间戳和版本计数器相关联的元数据,这使得客户端能够跟踪对znodes的更改并根据znode的版本执行条件更新。
客户端连接到ZooKeeper并启动一个会话。会话有一个关联的超时时间。如果ZooKeeper在超过该超时时间内没有收到来自会话的任何内容,它会认为客户端有问题。当客户端显式关闭会话句柄或ZooKeeper检测到客户端有问题时,会话结束。在一个会话中,客户端观察到一系列状态变化,反映了其操作的执行。会话使客户端能够在ZooKeeper集群中从一个服务器透明地移动到另一个服务器,因此在ZooKeeper服务器之间持续存在。
2.2 Client API
我们在下面展示了ZooKeeper API的一个相关子集,并讨论了每个请求的语义。
create(path, data, flags):
使用路径名创建一个znode,将数据[]存储在其中,并返回新znode的名称。标志使客户端能够选择znode的类型:常规,临时,并设置顺序标志;
delete(path, version):
如果该znode处于预期版本,则删除znode路径;
exists(path, watch):
如果路径名为path的znode存在,则返回true,否则返回false。观察标志使客户端能够在znode上设置watch;
getData(path, watch):
返回与znode关联的数据和元数据,例如版本信息。监听标志的工作方式与exists()相同,只是如果znode不存在,ZooKeeper不会设置监听;
setData(path, data, version):
如果版本号是znode当前版本,将数据[]写入znode路径;
getChildren(path, watch):
返回znode子节点的名称集合;
sync(path):
等待在操作开始时挂起的所有更新传播到客户端连接的服务器。路径被忽略。
所有方法都有同步和异步版本可通过API获得。当应用程序需要执行单个ZooKeeper操作且没有并发任务要执行时,它会使用同步API并阻塞。然而,异步API使应用程序能够同时执行多个未完成的ZooKeeper操作和其他任务。ZooKeeper客户端保证按顺序调用每个操作的相应回调。
请注意,ZooKeeper 不使用handles来访问 znodes。相反,每个请求都包括正在操作的 znode 的完整路径。这种选择不仅简化了 API(没有 open() 或 close() 方法),而且还消除了服务器需要维护的额外状态。
每个更新方法都需要一个预期的版本号,这使得可以实现有条件的更新。如果znode的实际版本号与预期版本号不匹配,则更新失败,出现意外版本错误。如果版本号为-1,则不执行版本检查。
2.3 ZooKeeper guarantees
ZooKeeper 有两个基本顺序保证:
Linearizable writes:
所有更新ZooKeeper状态的请求都是可线性化的,并遵循优先级顺序;
FIFO client order:
来自给定客户端的所有请求都按照客户端发送的顺序执行。
请注意,我们对线性化的定义与Herlihy [15]最初提出的定义不同,我们称之为A-线性化(异步线性化)。在Herlihy对线性化的原始定义中,客户端一次只能有一个未完成的操作(客户端是一个线程)。在我们的定义中,我们允许客户端有多个未完成的操作,并且因此我们可以选择不保证同一客户端的未完成操作的特定顺序,或者保证FIFO顺序。我们选择后者作为我们的属性。重要的是要注意到,对于线性化对象所成立的所有结果也适用于A-线性化对象,因为满足A-线性化的系统也满足线性化。由于只有更新请求是A-线性化的,ZooKeeper在每个副本上本地处理读取请求。这使得服务在系统中添加服务器时能够线性扩展。
要了解这两个保证如何相互作用,请考虑以下情景。一个由多个进程组成的系统选举出一个领导者来指挥工作进程。当一个新领导接管系统时,它必须更改大量的配置参数,并在完成后通知其他进程。然后我们有两个重要的要求:
- 随着新领导开始进行更新,我们不希望其他进程使用正在更改的配置;
- 如果新领导在配置完全更新之前挂掉,我们不希望进程使用这个部分配置。
请注意,分布式锁,如Chubby提供的锁,有助于满足第一个要求,但对于第二个要求则不满足。使用ZooKeeper,新领导者可以将一个路径指定为ready znode;其他进程只有在该znode存在时才会使用配置。新领导者通过删除ready,更新各种配置znodes,并创建ready来进行配置更改。所有这些更改都可以进行流水线处理,并异步发出以快速更新配置状态。尽管更改操作的延迟为2毫秒的数量级,但如果请求是一个接一个发出的,那么需要更新5000个不同znodes的新领导者将花费10秒钟;通过异步发出请求,请求将在不到一秒钟内完成。由于顺序保证,如果进程看到ready znode,它还必须看到新领导者所做的所有配置更改。如果新领导者在ready znode创建之前死亡,其他进程知道配置尚未最终确定,因此不会使用它。
上述方案仍然存在一个问题:如果一个进程在新领导者开始进行更改之前就看到 ready 存在,然后在更改过程中开始读取配置。这个问题通过通知的顺序保证得到解决:如果一个客户端正在监听更改,那么客户端会在看到系统更改后的新状态之前看到通知事件。因此,如果读取 ready znode 的进程请求被通知该 znode 的更改,它将在读取任何新配置之前看到通知客户端更改的通知。
另一个问题可能出现在客户端除了ZooKeeper之外还有自己的通信管道。例如,考虑两个客户端A和B,它们在ZooKeeper中有一个共享配置,并通过一个共享通信管道进行通信。如果A在ZooKeeper中更改共享配置,并通过共享通信管道告诉B更改,B会在重新读取配置时期望看到更改。如果B的ZooKeeper副本稍微落后于A,它可能看不到新配置。使用上述保证,B可以通过在重新读取配置之前发出写操作来确保看到最新数据。为了更有效地处理这种情况,ZooKeeper提供了同步请求:当后面跟着一个读取操作时,构成一个slow read。同步请求会导致服务器在处理读取操作之前执行所有待处理的写请求,没有完整写入的开销。这个原语在思想上类似于ISIS[5]的刷新原语。
ZooKeeper还提供以下两个活性和持久性保证:如果大多数ZooKeeper服务器处于活动状态并进行通信,服务将可用;如果ZooKeeper服务成功响应变更请求,只要有特定数量的服务器最终能够恢复,该变更将持久存在,即使发生任意数量的故障。
2.4 Examples of primitives
在这个部分,我们展示如何使用ZooKeeper API来实现更强大的原语。ZooKeeper服务对这些更强大的原语一无所知,因为它们完全是在客户端使用ZooKeeper客户端API实现的。一些常见的原语,如组成员关系和配置管理,也是无等待的。对于其他的,如rendezvous,客户端需要等待一个事件。尽管ZooKeeper是无等待的,我们可以用ZooKeeper实现高效的阻塞原语。ZooKeeper的顺序保证允许对系统状态进行高效的推理,而监听器允许进行高效的等待。
Configuration Management
ZooKeeper 可用于在分布式应用程序中实现动态配置。在其最简单的形式中,配置存储在一个 znode 中:zc。进程启动时带有 zc 的完整路径名。启动进程通过将 watch 标志设置为 true 来读取 zc 以获取其配置。如果 zc 中的配置更新,进程会收到通知并读取新的配置,再次将 watch 标志设置为 true。
请注意,在这个方案中,就像在大多数其他使用watch的方案中一样,watch用于确保一个进程具有最新的信息。例如,如果一个正在观察zc的进程被通知zc发生了变化,在它可以发出对zc的读取请求之前,zc发生了三次更改,该进程不会收到三个更多的通知事件。这不影响进程的行为,因为这三个事件只是通知进程它已经知道的事情:它拥有的zc信息是陈旧的。
Rendezvous
在分布式系统中,有时候不一定能预先清楚最终的系统配置会是怎样的。例如,一个客户端可能希望启动一个主进程和多个工作进程,但启动进程的工作由调度器完成,因此客户端无法提前知道给工作进程提供的用于连接到主进程的地址和端口等信息。我们使用ZooKeeper处理这种情况,使用一个称为"rendezvous znode"的节点zr,它是由客户端创建的。客户端将zr的完整路径名作为主进程和工作进程的启动参数传递。当主进程启动时,它会在zr中填写所使用的地址和端口的信息。当工作进程启动时,它们会读取zr并将watch设置为true。如果zr尚未填写完毕,工作进程将会等待并在zr更新时收到通知。如果zr是一个临时节点,主进程和工作进程可以监听zr是否被删除,并在客户端结束时进行清理。
Group Membership
我们利用临时节点来实现群组成员关系。具体而言,我们利用临时节点可以查看创建节点的session状态。我们首先指定一个名为zg的znode来表示群组。当群组的一个成员进程启动时,它会在zg下创建一个临时子节点。如果每个进程都有一个唯一的名称或标识符,那么该名称将被用作子节点的名称;否则,进程将使用SEQUENTIAL标志创建节点以获得唯一的名称分配。进程可以在子节点的数据中添加进程的信息,例如使用的地址和端口。
在zg下创建子znode后,进程正常启动。 它无需执行任何其他操作。 如果进程失败或结束,表示其在zg下的znode将自动删除。
进程可以通过简单地列出zg的子节点来获取群组信息。如果进程想要监控组成员资格的变化,进程可以将watch标志设置为真,并在收到更改通知时刷新群组信息(始终将watch标志设置为真)。
Simple Locks
尽管ZooKeeper不是锁服务,但它可以用来实现锁。使用ZooKeeper的应用程序通常使用针对其需求的同步原语,例如上面所示的那些。在这里,我们展示了如何使用ZooKeeper实现锁,以表明它可以实现各种各样的通用同步原语。
最简单的锁实现使用“锁文件”。锁由znode表示。要获取锁,客户端尝试使用EPHEMERAL标志创建指定的znode。如果创建成功,客户端将持有锁。否则,客户端可以读取带有watch标志的znode,以便在当前领导者挂掉时得到通知。客户端在挂掉或显式删除znode时释放锁。其他正在等待锁的客户端在观察到znode被删除后再次尝试获取锁。
虽然这种简单的锁协议能够工作,但它也存在一些问题。首先,它会遭受惊群效应的影响。如果有很多客户端在等待获取锁,它们会在锁被释放时竞争获取锁,尽管只有一个客户端能够获取锁。其次,它只实现了互斥锁。下面的两个原语展示了如何克服这两个问题。
Simple Locks without Herd Effect
我们定义一个锁 znode 来实现这种锁。直观地说,我们将请求锁的所有客户端排成一行,每个客户端按请求到达的顺序获得锁定。因此,希望获得锁的客户端执行以下操作:

在 Lock 的第 1 行中使用 SEQUENTIAL 标志,按顺序排列客户端尝试获取锁的顺序。如果客户端的 znode 在第 3 行具有最低序列号,则客户端持有锁。否则,客户端等待删除前驱 znode2,znode2 持有锁或之前的 node 接收锁。通过仅监听 znode2,我们避免了惊群效应,当锁被释放或锁请求被丢弃时,只唤醒一个进程。一旦客户端watch的 znode 消失,客户端必须检查它现在是否持有锁。(之前的锁请求可能已被丢弃,仍有一个具有较低序列号的 znode 在等待或持有锁。)
释放锁和删除表示锁请求的 znode 节点。通过在创建时使用EPHEMERAL标志,崩溃的进程将自动清理任何锁请求或释放它们可能拥有的任何锁。
总之,这种锁定方案具有以下优点:
- 删除znode仅导致一个客户端唤醒,因为每个znode都被恰好一个其他客户端watch,所以我们没有惊群效应。
- 没有投票或超时;
- 因为我们实现锁的方式,我们可以通过浏览ZooKeeper数据查看锁竞争的数量,打破锁,并调试锁问题。
Read/Write Locks
读/写锁 为了实现读/写锁,我们稍微改变锁定过程,并拥有单独的读锁和写锁函数。释放锁与全局锁相同。

这个锁定过程与之前的锁略有不同。写锁仅在命名上有所不同。由于读锁可以共享,因此第3行和第4行略有不同,因为只有较早的写锁znode阻止客户端获取读锁。当有多个客户端等待读锁并在具有较低序列号的“write-”znode被删除时得到通知时,虽然看起来具有“惊群效应”;但是实际上,这是一种期望的行为,所有这些获取读锁的客户端都应该得到通知,因为读锁是可以被同时获取的。
Double Barrier
双重屏障 双屏障使客户端能够同步计算的开始和结束。当足够多的进程(由屏障阈值定义)加入屏障时,进程开始计算并在完成后离开屏障。我们在ZooKeeper中用一个称为b的znode来表示屏障。每个进程p在进入时向b注册(通过创建b的子节点),并在准备离开时注销(删除子节点)。当b的子节点数量超过屏障阈值时,进程可以进入屏障。当所有进程都删除了子节点时,进程可以离开屏障。我们使用watch机制有效地等待进入和离开条件得到满足。为了进入,进程watch b的一个准备好的子节点的存在,这个子节点将由导致子节点数量超过屏障阈值的进程创建。为了离开,进程watch一个特定的子节点消失,并且只有在该子节点被删除后才检查离开条件。
3. ZooKeeper Applications
我们现在描述一些使用ZooKeeper的应用程序,并简要说明它们如何使用它。我们展示每个示例的**粗体(bold)**原语。
The Fetching Service
爬虫是搜索引擎的重要组成部分,雅虎搜索引擎每天爬取数十亿个网页文档。抓取服务(FS)是雅虎爬虫的一部分,目前已经投入生产。主要由主进程指挥页面抓取进程。主进程提供抓取进程的配置信息,抓取进程则会回写状态和健康信息。使用ZooKeeper作为FS的主要优势是能够从主进程的故障中恢复,即使发生故障也能保证可用性,并且使客户端与服务器解耦,只需从ZooKeeper中读取服务器的状态即可将请求转发给健康的服务器。因此,FS主要使用ZooKeeper来管理配置元数据,同时也用于选举主进程(领导者选举)。

图2:一个运行抓取服务的ZK服务器的工作负载。每个点代表一秒钟的样本。
图2显示了在一个为期三天的时间段内,FS使用的ZooKeeper服务器的读取和写入流量。为了生成这个图表,我们计算了在每秒内的操作次数,每个点对应于该秒内的操作次数。我们观察到相比写入流量,读取流量要高得多。在每秒操作数高于1,000的时期,读取和写入的比例在10:1到100:1之间变化。在这个工作负载中,读取操作的优先级逐渐增加,依次为getData()、getChildren()和exists()。
Katta 是一个分布式索引器,它使用ZooKeeper进行协调,是一个非雅虎的应用程序示例。Katta通过分片来进行索引工作。master服务器将分片分配给follower服务器并跟踪进度。follower服务器可能会失败,因此master服务器必须在follower服务器出现故障后重新分配负载。master服务器也可能会失败,因此其他服务器必须准备好在发生故障时接管工作。Katta使用ZooKeeper来跟踪follower服务器和master服务器的状态(群组成员关系),并处理master服务器的故障转移(领导者选举)。Katta还使用ZooKeeper来跟踪和传递分片分配给follower服务器的任务(配置管理)。
雅虎消息代理(YMB)是一个分布式的发布-订阅系统。该系统管理着数千个主题,客户端可以向这些主题发布消息并接收消息。为了实现可扩展性,这些主题分布在一组服务器之间。每个主题都使用主备方案进行复制,确保消息被复制到两台机器上,以确保可靠的消息传递。组成YMB的服务器采用共享无状态的分布式架构,这使得协调对于正确的操作非常重要。YMB使用ZooKeeper来管理主题的分布(配置元数据),处理系统中的机器故障(故障检测和群组成员关系),以及控制系统的运行。

图3显示了YMB的部分znode数据布局。每个代理域都有一个名为nodes的znode,其中包含了组成YMB服务的活动服务器的临时znode。每个YMB服务器在nodes下创建一个临时znode,其中包含负载和状态信息,通过ZooKeeper提供群组成员和状态信息。诸如shutdown和migration prohibited之类的节点被所有组成服务的服务器监听,并允许对YMB进行集中控制。topics目录是YMB管理的每个主题对应的子节点。主题节点都有子节点,用于表示每个主题的主服务器和备份服务器,以及该主题的订阅者。主服务器和备份服务器的znode不仅允许服务器发现负责主题的服务器,还管理领导者选举和服务器崩溃。
4. ZooKeeper Implementation
ZooKeeper通过在组成服务的每台服务器上复制ZooKeeper数据来提供高可用性。我们假设服务器发生崩溃故障,这些故障的服务器可能会在之后恢复。图4显示了ZooKeeper服务的高级组件。在接收到请求后,服务器会准备执行该请求(请求处理器)。如果该请求需要在服务器之间进行协调(写入请求),则它们会使用协议达成一致(原子广播的实现),最终服务器会将更改提交到ZooKeeper数据库,该数据库在整个服务器集合中进行完全复制。对于读取请求,服务器只需读取本地数据库的状态并生成响应。

复制数据库是一个内存中的数据库,它包含了整个数据树。树中的每个znode默认存储最多1MB的数据,但这个最大值是一个可以在特殊情况下更改的配置参数。为了实现可恢复性,我们会将更新有效地记录到磁盘上,并在应用到内存数据库之前强制将写入操作写入磁盘介质。实际上,就像Chubby[8]一样,我们会保存已提交操作的重放日志(在我们的情况下是预写日志),并定期生成内存数据库的快照(加速崩溃恢复)。
每个ZooKeeper服务器都为客户端提供服务。客户端只连接一个服务器来提交其请求。正如我们之前提到的,读取请求是从每个服务器数据库的本地副本中提供服务的。改变服务状态的请求,即写请求,是通过原子广播协议来处理的。
作为协议一部分,写请求被转发到一个称为leader的服务器。其余的ZooKeeper服务器,称为follower,从leader接收包含状态更改的消息提案,并同意状态更改。
4.1 Request Processor
由于消息传递层是原子的,我们保证本地副本的状态永远不会偏离,尽管在任何时候,某些服务器可能已经应用了比其他服务器更多的事务。与来自客户端的请求不同,事务是幂等的。当领导者收到写入请求时,它计算执行写入后系统的状态,并将其转换为产生此新状态的事务(这个事务将会是幂等的)。future状态必须被计算,因为可能存在尚未应用到数据库的未完成事务。例如,如果客户端执行了一个有条件的setData操作,并且请求中的版本号与要更新的znode的future版本号匹配,服务将生成一个setData TXN,其中包含新数据、新版本号和更新时间戳。如果发生错误,比如版本号不匹配或要更新的znode不存在,将会生成一个error TXN。
4.2 Atomic Broadcast
所有更新ZooKeeper状态的请求都会被转发给leader。leader执行请求并通过Zab[24],即原子广播协议,广播ZooKeeper状态的更改。接收到客户端请求的服务器在传递相应的状态更改时向客户端作出响应。Zab默认使用简单多数原则来决定一个提案,因此Zab和ZooKeeper只能在大多数服务器正常的情况下工作(即,使用2f + 1个服务器,我们可以容忍f个故障)。
为了实现高吞吐量,ZooKeeper尽量保持请求处理管道处于满负荷状态。它可能在处理管道的不同部分有数千个请求。由于状态更改依赖于先前状态更改的应用,Zab提供比常规原子广播更强的顺序保证。具体而言,Zab保证由领导者广播的更改按发送顺序传递,并且在广播自己的更改之前,将所有先前领导者的更改传递给已建立的领导者(这里的的先前领导者的更改应该就是日志条目)。
有一些实现细节简化了我们的实现并提供了出色的性能。我们使用TCP作为传输协议,因此消息顺序由网络维护,这使得我们的实现更加简化。我们使用Zab选择的领导者作为ZooKeeper的领导者,这样同一个进程既能创建事务也能提议它们。我们使用日志作为内存数据库的预写日志来跟踪提议,这样我们就不需要将消息写入磁盘两次。
在正常操作过程中,Zab确实按顺序准确地传递了所有消息,但由于Zab没有持久记录每个已传递消息的ID,因此Zab在恢复过程中可能会重新传递消息(没看懂?为什么没有记录消息ID就必须重新传递消息,还有这个消息ID到底是什么?)。由于我们使用幂等事务,只要按顺序传递,多次传递是可以接受的。实际上,ZooKeeper要求Zab至少重新传递自最后一个快照开始后传递的所有消息。
4.3 Replicated Database
每个副本在内存中都有ZooKeeper状态的副本。当ZooKeeper服务器从崩溃中恢复时,需要恢复这个内部状态。重放所有已传递的消息以恢复状态在运行服务器一段时间后会花费过长的时间,因此ZooKeeper使用定期快照,并且只需要重新传递自快照开始后的消息。我们称ZooKeeper快照为“模糊快照”,因为我们不会锁定ZooKeeper状态以进行快照;相反,我们会对树进行深度优先扫描,原子地读取每个znode的数据和元数据,并将其写入磁盘。由于生成快照期间可能已经应用了一些状态更改的子集,因此生成的模糊快照可能与ZooKeeper在任何时间点的状态不一致。然而,由于状态更改是幂等的,只要按顺序应用状态更改,我们可以将它们应用两次。(通过版本号保证状态更改是幂等的)
例如,假设在一个ZooKeeper数据树中,两个节点/foo和/goo的值分别为f1和g1,当模糊快照开始时,它们的版本都是1,然后接收到以下形式的状态更改流 ⟨transactionType, path, value, new-version⟩:

在处理这些状态更改之后,/foo 和 /goo 的值分别为 f3 和 g2,版本分别为 3 和 2。然而,模糊快照可能记录了 /foo 和 /goo 的值分别为 f3 和 g1,版本分别为 3 和 1,这不是 ZooKeeper 数据树的有效状态。如果服务器崩溃并使用此快照恢复,同时 Zab 重新传递状态更改,那么产生的状态将对应于崩溃前服务的状态。
4.4 Client-Server Interactions
当服务器处理写请求时,它还会发送并清除与该更新关联的任何watch的通知。服务器按顺序处理写操作,不会同时处理其他写操作或读操作。这确保了通知的严格顺序。请注意,服务器在本地处理通知。只有客户端连接的服务器会跟踪和触发该客户端的通知。
每个服务器都会在本地处理读请求。每个读请求都会被处理并标记一个与服务器看到的最后一个事务相对应的zxid。这个zxid定义了读请求相对于写请求的部分顺序。通过在本地处理读取操作,我们获得了出色的读取性能,因为它只是在本地服务器上进行的内存操作,并且没有磁盘活动或协议需要运行。这种设计选择对于实现出色的读取优势工作负载的目标至关重要。
使用快速读取的一个缺点是无法保证读操作的优先顺序。也就是说,即使对同一个znode的更新已经提交,读操作可能仍然返回旧值。并不是所有的应用都需要优先顺序,但对于确实需要的应用,我们实现了同步操作。该原语以异步方式执行,并且在所有待处理写操作提交到其本地副本后由领导者进行排序。为了确保给定的读操作返回最新的更新值,客户端在读操作之前调用同步操作。客户端操作的FIFO顺序保证以及同步更改的全局保证使得读操作的结果能够反映在发出同步操作之前发生的所有更改。在我们的实现中,我们不需要原子地广播同步操作,因为我们使用基于领导者的算法,我们只需将同步操作放置在领导者和执行同步调用的服务器之间请求队列的末尾。(为了使这个工作正常,跟随者节点必须确信领导者仍然是领导者。如果有待处理的事务提交,那么服务器不会怀疑领导者的地位。如果待处理队列为空,领导者需要发出一个空事务以提交,并在该事务之后对同步操作进行排序。这样做的好处是,当领导者负载较重时,不会产生额外的广播流量)。在我们的实现中,设置超时时间使得跟随者节点在放弃领导者之前让领导者意识到它们不再是领导者,因此我们不需要发出空事务。
ZooKeeper服务器按照FIFO的顺序处理客户端的请求。响应包括与响应关联 的zxid 。即使在没有活动的间隔期间的心跳消息中,也包含客户端连接的服务器所看到的最后一个 zxid 。如果客户端连接到一个新的服务器,该新服务器通过检查客户端的最后一个 zxid 与自己的最后一个 zxid 相比,确保其对ZooKeeper数据的视图至少与客户端的视图一样新。如果客户端的视图比服务器更新,服务器将在赶上之前不会重新与客户端建立会话。客户端保证能够找到另一个具有系统最新视图的服务器,因为客户端只能看到已经复制到大多数ZooKeeper服务器的更改。这种行为对于保证持久性非常重要。
为了检测客户端会话故障,ZooKeeper使用超时机制。如果在会话超时时间内,没有其他服务器从客户端会话接收到任何消息,领导者将确定发生了故障。如果客户端发送请求的频率足够高,则不需要发送任何其他消息。否则,在低活动期间,客户端会发送心跳消息。如果客户端无法与服务器通信以发送请求或心跳消息,则它会连接到另一个ZooKeeper服务器以重新建立会话。为了防止会话超时,ZooKeeper客户端库在会话空闲s/3毫秒后发送心跳,并在2s/3毫秒内未收到服务器的消息时切换到新服务器,其中s是会话超时时间(以毫秒为单位)。
5. Evaluation
我们在一个拥有50台服务器的集群上进行了所有评估。每台服务器都有一颗Xeon双核2.1GHz处理器,4GB的RAM,千兆以太网和两个SATA硬盘。我们将以下讨论分为两部分:请求的吞吐量和延迟。
5.1 Throughput
要评估我们的系统,我们在系统饱和时对吞吐量进行基准测试,并对各种注入故障的吞吐量变化进行测试。我们改变了组成ZooKeeper服务的服务器数量,但始终保持客户端数量不变。为了模拟大量客户端,我们使用了35台机器来模拟250个同时在线的客户端。
我们有一个Java实现的ZooKeeper服务器,以及Java和C客户端2。对于这些实验,我们使用配置为将日志记录到一个专用磁盘并在另一个磁盘上拍摄快照的Java服务器。我们的基准客户端使用异步Java客户端API,每个客户端至少有100个未完成的请求。每个请求包括读取或写入1K的数据。我们没有显示其他操作的基准测试,因为修改状态的所有操作的性能大致相同,而不修改状态的操作(排除同步)的性能大致相同。(同步的性能接近轻量级写入,因为请求必须发送到领导者,但不会广播。)客户端每300 * ms *发送已完成操作的计数,我们每6 * s *进行一次采样。为了防止内存溢出,服务器限制了系统中并发请求的数量。ZooKeeper使用请求限制来防止服务器被压倒。对于这些实验,我们将ZooKeeper服务器配置为最多有2 , 000个正在处理的请求。

在图5中,我们展示了吞吐量随着读写请求比例的变化,每条曲线对应于提供ZooKeeper服务的不同数量的服务器。表1显示了读负载极值处的数字。读取吞吐量高于写入吞吐量,因为读取不使用原子广播。图中还显示,服务器数量对广播协议性能也有负面影响。从这些图中,我们观察到系统中服务器的数量不仅影响服务可以处理的故障数量,还影响服务可以处理的工作负载。请注意,三个服务器的曲线在60%左右与其他曲线相交。这种情况并非仅限于三服务器配置,由于本地读取启用的并行性,所有配置都会发生这种情况。然而,在图中其他配置中并未观察到这一点,因为我们已经将最大y轴吞吐量限制在可读范围内。
有两个原因导致写请求的处理时间比读请求长。首先,写请求必须经过原子广播,这需要一些额外的处理并增加了请求的延迟。写请求处理时间较长的另一个原因是,服务器必须确保在将确认信息发送回领导者之前,将事务记录到非易失性存储中。从原则上讲,这个要求是过分的,但对于我们的生产系统,我们用可靠性换取性能,因为ZooKeeper构成了应用程序的基本事实。我们使用更多的服务器来容忍更多的故障。我们通过将ZooKeeper数据分区到多个ZooKeeper集合来提高写入吞吐量。Gray et al. [12]之前已经观察到了这种复制和分区之间的性能权衡。

ZooKeeper能够通过在组成服务的服务器之间分配负载来实现如此高的吞吐量。我们之所以能够分配负载,是因为我们的一致性保证较为宽松。Chubby客户端则将所有请求直接发送给领导者。图6显示了如果我们不利用这种松弛性并强制客户端仅连接到领导者会发生什么。如预期的那样,对于读取为主的工作负载,吞吐量要低得多,但即使对于写入为主的工作负载,吞吐量也较低。由于服务客户端而产生的额外CPU和网络负载影响了领导者协调广播提案的能力,这反过来又对整体写入性能产生了负面影响。

原子广播协议完成了系统的大部分工作,因此限制了ZooKeeper的性能,比其他任何组件都要多。图7显示了原子广播组件的吞吐量。为了对其性能进行基准测试,我们通过在领导者处直接生成事务来模拟客户端,因此没有客户端连接或客户端请求和回复。在最大吞吐量下,原子广播组件变得受CPU限制。理论上,图7的性能将与100%写入的ZooKeeper性能相匹配。然而,ZooKeeper客户端通信、ACL检查和请求到事务转换都需要CPU。争用CPU使ZooKeeper的吞吐量远低于原子广播组件在隔离状态下的吞吐量。因为ZooKeeper是一个关键的生产组件,所以到目前为止,我们对ZooKeeper的开发重点一直是正确性和稳健性。通过消除额外的副本、同一对象的多次序列化、更高效的内部数据结构等,有很多机会可以显著提高性能。

要展示随着故障注入而系统行为随时间变化,我们运行了一个由5台机器组成的ZooKeeper服务。我们运行了与之前相同的饱和基准测试,但这次我们将写入百分比保持在恒定的30%,这是我们预期工作负载的保守比例。我们定期杀死了一些服务器进程。图8显示了系统吞吐量随时间变化。图中标记的事件如下:
失败与追随者的恢复;
失败和恢复一个不同的追随者;
领导者的失败;
故障的两个追随者(a,b)在前两个标记处,以及在第三个标记处(c)的恢复;
领导者的失败。
领导者的恢复。
从这个图中可以得出一些重要的观察结果。首先,如果追随者失败并迅速恢复,那么ZooKeeper能够在失败的情况下保持高吞吐量。单个追随者的失败并不会阻止服务器形成法定人数,而只是大致减少了服务器在失败前处理的读请求的份额。其次,我们的领导者选举算法能够快速恢复,以防止吞吐量大幅下降。在我们的观察中,ZooKeeper在不到200ms的时间内选出新的领导者。因此,尽管服务器在一小段时间内停止处理请求,但由于我们的采样周期在几秒钟的数量级上,我们没有观察到吞吐量为零。第三,即使追随者需要更多的时间来恢复,ZooKeeper也能在它们开始处理请求后再次提高吞吐量。我们在事件1、2和4之后没有恢复到完全吞吐量的一个原因是,客户端只有在与追随者的连接断开时才会切换追随者。因此,在事件4之后,客户端在领导者在事件3和5失败之前不会重新分配。在实践中,随着客户端的进出,这种不平衡会随着时间的推移自行解决。
5.2 Latency of requests
为了评估请求的延迟,我们创建了一个基于Chubby基准测试[6]的基准测试。我们创建了一个工作进程,它只是发送一个创建请求,等待它完成,发送一个异步删除新节点的请求,然后开始下一个创建请求。我们相应地改变工作进程的数量,对于每次运行,我们让每个工作进程创建50,000个节点。我们通过将完成的创建请求的数量除以所有工作进程完成所需的总时间来计算吞吐量。

表2显示了我们基准测试的结果。创建请求包括1K的数据,而不是Chubby基准测试中的5字节,以更好地符合我们的预期用途。即使在这些较大的请求下,ZooKeeper的吞吐量也比Chubby公布的吞吐量高出3倍多。单个ZooKeeper工作器基准测试的吞吐量表明,对于三个服务器,平均请求延迟为1*.2毫秒,对于9个服务器,为1.*4毫秒。
5.3 Performance of barriers
在这个实验中,我们顺序执行一定数量的障碍,以评估使用ZooKeeper实现的原语的性能。对于给定数量的障碍b,每个客户端首先进入所有b个障碍,然后依次离开所有b个障碍。由于我们使用了第2.4节的双重障碍算法,客户端首先等待所有其他客户端执行enter()过程,然后再移动到下一个调用(leave()过程也类似)。

我们在表3中报告了我们的实验结果。在这个实验中,我们有50、100和200个客户端依次进入b个障碍,b 200*,* 400*,* 800*,* 1600。尽管一个应用程序可以有数千个ZooKeeper客户端,但很多时候,每个协调操作中参与的客户端数量要小得多,因为客户端通常根据应用程序的具体情况进行分组。
从这个实验中得出的两个有趣的观察结果是,处理所有屏障的时间大致与屏障数量成线性增长,表明对数据树的同一部分进行并发访问没有产生任何意外的延迟,以及延迟与客户端数量成正比。这是由于没有饱和ZooKeeper服务的结果。实际上,我们观察到,即使客户端按锁定步骤进行,屏障操作(进入和离开)的吞吐量在所有情况下都在每秒1,950到3,100次操作之间。在ZooKeeper操作中,这相当于每秒10,700到17,000次操作的吞吐量。由于在我们的实现中,读写比为4:1(80%的读操作),我们的基准代码使用的吞吐量与ZooKeeper可以实现的原始吞吐量相比要低得多(根据图5,超过40,000)。这是因为客户端在等待其他客户端。
6. Related work
ZooKeeper 的目标是提供一种服务,以缓解分布式应用程序中协调进程的问题。为实现这一目标,其设计采用了先前协调服务、容错系统、分布式算法和文件系统的思想。
我们并非第一个提出分布式应用协调系统的人。一些早期系统提出了用于事务应用程序[13]和计算机集群中共享信息[19]的分布式锁服务。更近期地,Chubby 提出了一种用于分布式应用程序的建议锁管理系统[6]。Chubby 与 ZooKeeper 的几个目标相同。它还具有类似文件系统的界面,并使用一致性协议来保证副本的一致性。然而,ZooKeeper 不是锁服务。客户端可以使用它来实现锁,但其 API 中没有锁操作。与 Chubby 不同,ZooKeeper 允许客户端连接到任何 ZooKeeper 服务器,而不仅仅是领导者。ZooKeeper 客户端可以使用其本地副本提供数据和管理监听,因为其一致性模型比 Chubby 更为宽松。这使得 ZooKeeper 能够提供比 Chubby 更高的性能,从而使应用程序能够更广泛地使用 ZooKeeper。
在文献中已经提出了容错系统,目的是缓解构建容错分布式应用程序的问题。一个早期的系统是ISIS [5]。ISIS系统将抽象类型规范转换为容错分布式对象,从而使容错机制对用户透明。Horus [30] 和 Ensemble [31] 是从ISIS演变而来的系统。ZooKeeper采用了ISIS的虚拟同步概念。最后,Totem在利用局域网硬件广播的架构中保证了消息传递的总顺序[22]。ZooKeeper适用于各种网络拓扑,这激励我们依赖于服务器进程之间的TCP连接,而不是假设任何特殊的拓扑或硬件特性。我们还没有暴露ZooKeeper内部使用的任何整体通信。
构建容错服务的一种重要技术是状态机复制[26],Paxos[20]是一种能够实现异步系统中复制状态机的高效实现的算法。我们使用一种具有Paxos部分特征的算法,该算法将用于共识的事务日志记录与用于数据树恢复的预写日志记录相结合,以实现高效实现。已经有关于实用实现拜占庭容错复制状态机的协议的提议[7, 10, 18, 1, 28]。ZooKeeper并不假设服务器可能是拜占庭式的,但我们确实采用了诸如校验和和完整性检查之类的机制来捕获非恶意的拜占庭故障。Clement 等人讨论了一种使ZooKeeper在不修改当前服务器代码库的情况下完全具有拜占庭容错能力的方法[9]。迄今为止,我们在生产中尚未观察到使用完全拜占庭容错协议可以防止的故障。[29]。
"Boxwood [21]是一个使用分布式锁服务器的系统。Boxwood为应用程序提供更高级的抽象,并依赖于基于Paxos的分布式锁服务。与Boxwood一样,ZooKeeper是用于构建分布式系统的组件。然而,ZooKeeper具有高性能要求,并在客户端应用程序中得到了更广泛的应用。ZooKeeper暴露较低级别的原语,应用程序使用这些原语来实现更高级别的原语。"
ZooKeeper类似于一个小型文件系统,但它只提供了文件系统操作的一小部分,并添加了大多数文件系统中不存在的功能,如排序保证和条件写入。然而,Zoo-Keeper的监听与AFS[16]的缓存回调在精神上是相似的。
Sinfonia [2] 介绍了迷你事务,这是一种构建可扩展分布式系统的新范式。Sinfonia 旨在存储应用程序数据,而 ZooKeeper 存储应用程序元数据。ZooKeeper 保持其状态完全复制并存储在内存中,以实现高性能和一致的延迟。我们使用类似文件系统的操作和排序,实现类似迷你事务的功能。znode 是一个方便的抽象,我们在其基础上添加了监听器,这是 Sinfonia 中缺少的功能。Dynamo [11] 允许客户端在分布式键值存储中获取和存储相对较小(小于 1M)的数据量。与 ZooKeeper 不同,Dynamo 中的键空间不是分层的。Dynamo 也不为写操作提供强大的持久性和一致性保证,而是在读取时解决冲突。
DepSpace [4] 使用元组空间提供拜占庭容错服务。与ZooKeeper一样,DepSpace使用简单的服务器接口在客户端实现强同步原语。尽管DepSpace的性能比ZooKeeper低得多,但它提供了更强的容错和保密性保证。
7. Conclusions
ZooKeeper采用了无等待(wait-free)的方法来解决分布式系统中进程协调的问题,通过向客户端提供无等待对象。我们发现ZooKeeper在Yahoo!内外的多个应用中非常有用。通过使用快速读取和监听器(watches),ZooKeeper对于以读操作为主的工作负载可以达到每秒数十万个操作的吞吐量,这两个特性都由本地副本提供支持。尽管我们的读取和监听器的一致性保证似乎比较弱,但我们通过用例证明,这种组合使我们能够在客户端实现高效且复杂的协调协议,即使读取操作没有优先顺序,并且数据对象的实现是无等待的。无等待属性被证明对于高性能非常重要。
尽管我们只描述了一些应用程序,但还有许多其他应用程序使用ZooKeeper。我们相信这种成功是由于其简单的接口和可以通过此结构实现的强大抽象。此外,由于ZooKeeper的高吞吐量,应用程序可以大量使用它,不仅仅是粗粒度锁。
8. Acknowledgements
我们要感谢Andrew Kornev和Runping Qi对ZooKeeper的贡献;感谢Zeke Huang和Mark Marchukov的宝贵意见;感谢Brian Cooper和Laurence Ramontianu对ZooKeeper的早期贡献;感谢Brian Bershad和Geoff Voelker对演示的重要评论。