ZooKeeper
本文将介绍ZooKeeper,一个开源的分布式协调服务框架,它主要用于解决分布式系统中的一致性问题,提供可靠的协调服务。
论文参考:《ZooKeeper: Wait-free coordination for Internet-scale systems》
引言
ZooKeeper是一个开源的分布式协调服务框架,由Yahoo开发,后捐赠给Apache基金会,现已成为Apache的顶级项目。它旨在为分布式应用提供高效、可靠的协调服务,解决分布式系统中常见的协调问题,如数据一致性、配置管理、集群管理、分布式锁等。
ZooKeeper的核心设计理念是提供一个简单、高性能的“协调内核”,允许开发者基于其API构建更复杂的分布式协调功能。它通过树形结构的数据模型(类似文件系统)存储数据,并支持顺序一致性、原子性和实时性等特性。此外,ZooKeeper采用了Zab协议(ZooKeeper Atomic Broadcast)来实现数据一致性,同时支持高吞吐量和低延迟的读写操作。
ZooKeeper有一些典型的应用场景,如:
- 配置中心:集中存储和动态更新分布式系统配置,避免手动修改每个节点。
- 命名服务:生成全局唯一标识符,用于资源命名或任务分配。
- 分布式锁:通过临时节点实现互斥锁或读写锁,保障资源访问安全。
- Master选举:在集群中动态选举主节点,用于任务调度或负载均衡。
- 服务注册与发现:监控服务节点状态,实现动态扩容或缩减。
ZooKeeper通过其简单、高效的设计,为分布式系统提供了强大的协调能力,是Hadoop、Kafka、Dubbo等众多框架的重要基础组件。
ZooKeeper服务
用户(client)通过ZooKeeper的client库API与ZooKeeper交互。client库不仅提供ZooKeeper服务接口,还负责管理客户端与服务器之间的网络连接。
znode
ZooKeeper的数据模型以树形结构组织数据节点(znode),类似文件系统的目录结构。znode分为两种类型:
- 普通节点(Regular ZNode)
- 由客户端显式创建和删除。
- 适用于需要长期存在的元数据或配置数据。
- 临时节点(Ephemeral ZNode)
- 由客户端创建,但与客户端会话(Session)绑定。
- 当创建它的会话结束时(无论是主动关闭还是因故障断开),节点会自动删除。
- 常用于实现分布式锁、心跳检测或短期任务标识。
znode还支持顺序节点功能,在创建节点时,可以设置“顺序标志”(Sequential Flag),ZooKeeper会在节点名称后附加一个递增的序号。这些序号在同一父节点下是全局递增的,确保顺序一致性。顺序节点常用于任务队列或事件排序场景。
每个ZNode还包含一些附加的元数据,例如时间戳和版本号。这些信息允许客户端跟踪节点的变化,并基于版本号执行条件更新操作。
znode的数据模型
ZooKeeper的数据模型类似于文件系统,但更为简化。其特点如下:
- 层次化命名空间:znode以树形结构组织,路径采用标准UNIX文件系统格式。例如,“/A/B/C”表示C节点,其父节点为B,B的父节点为A。
- 轻量级设计:ZNode不适合存储大量数据,而是用于存储少量元数据或配置信息,通常用于协调分布式系统中的状态信息。
- 灵活组织:层次化结构便于为不同应用分配子树,并设置访问权限。例如,可以为应用1分配路径“/app1”,为应用2分配路径“/app2”。应用程序1的子树实现了一个简单的组成员身份协议:每个客户端进程pi在/app1下创建一个znode pi,只要进程正在运行,该节点就会持续存在。如下图:
- 元数据支持:每个znode都包含时间戳和版本计数器,用于跟踪变化并支持条件更新操作。一个典型案例是领导者选举:当前领导者可以将自身信息写入一个已知位置的znode中,新加入的服务通过读取该位置快速获知当前领导者是谁。
watch机制
ZooKeeper通过Watch机制实现高效的事件通知,而无需客户端轮询。这种机制既降低了服务器负载,又提高了客户端对系统状态变化的感知能力。其工作原理如下:
- 客户端在执行读操作时可以设置“Watch标志”(Watch Flag)。
- 如果目标znode的数据发生变化,ZooKeeper会触发一次性通知,告知客户端该节点已被修改。
- Watch是与会话绑定的,且为一次性标志,一旦触发或会话关闭,Watch即被注销。
NOTE:
- Watch是一次性的,需要重新设置以捕获后续变化。
- 除了ZNode变化事件外,连接丢失等会话事件也会通过Watch回调通知客户端。
session
ZooKeeper通过会话(Session)管理客户端与服务器之间的连接。每个客户端在连接ZooKeeper时都会启动一个会话,其生命周期由以下几个关键特性决定:
- 会话超时
- 每个会话都有一个超时时间。如果ZooKeeper在超时时间内未收到来自客户端的任何消息,会认为客户端故障,并终止该会话。
- 会话超时后,与该会话相关联的临时节点(Ephemeral ZNode)将被自动删除。
- 透明迁移
- ZooKeeper集群中通常包含多个服务器,客户端可以连接到任意一个服务器。如果当前连接的服务器发生故障,客户端会自动切换到其他服务器,而无需重新建立会话。这种透明迁移机制保证了会话在集群中的持续性。
- 事件通知(结合Watch机制)
- Session支持事件监听机制(Watch),不仅能监听znode的数据变化,还能捕获与会话相关的事件(如连接丢失)。
- 当发生网络问题或连接中断时,ZooKeeper通过Session事件通知客户端,从而帮助其及时感知系统状态。
Client API
ZooKeeper提供了一组相关的API,允许客户端与znode进行交互。以下是主要的API方法及其语义:
- create(path, data, flags):创建一个znode,路径为path,存储data[],并返回新znode的名称。flags用于选择znode类型(普通、临时)和设置顺序标志。
- delete(path, version):删除指定路径的znode,仅在该ZNode的版本与预期版本一致时执行。
- exists(path, watch):检查指定路径的znode是否存在,返回布尔值。watch标志允许客户端对该znode设置监听。
- getData(path, watch):获取指定znode的数据及元数据(如版本信息)。如果znode不存在,则不能设置watch。
- setData(path, data, version):更新指定路径的znode数据,仅在版本号与当前版本一致时执行。
- getChildren(path, watch):返回指定znode的子节点名称集合。
- sync(path):等待所有待处理更新传播到当前连接的服务器,当前路径参数被忽略。
所有方法都有同步和异步版本可用。同步API适用于没有并发任务时的单个操作,而异步API允许并行执行多个ZooKeeper操作和其他任务。ZooKeeper客户端保证每个操作的回调按顺序调用。
ZooKeeper不使用句柄来访问ZNode,每个请求直接包含完整路径,这简化了API设计,并消除了服务器需要维护的额外状态。此外,更新方法接受预期版本号,以实现条件更新。如果实际版本与预期不符,则更新失败;若版本号为−1,则不进行版本检查。
ZooKeeper操作顺序保证
ZooKeeper提供了两项基本的顺序保证:
- 线性化写入(Linearizable Writes):所有更新ZooKeeper状态的请求都是可序列化的,并遵循先后顺序。
- FIFO客户端顺序(FIFO Client Order):来自同一客户端的所有请求按发送顺序执行。
ZooKeeper的线性化定义被称为A-线性化(Asynchronous Linearizability),这与Herlihy最初提出的线性化概念存在显著差异。Herlihy的线性化定义要求客户端在任何时刻只能有一个未完成的操作,这意味着每个客户端在执行请求时都是单线程的。而ZooKeeper则允许客户端同时发起多个未完成的操作,这种设计使得多个请求可以并行进行,从而提高了系统的吞吐量。
通过将读请求本地化处理,ZooKeeper实现了系统的线性扩展。具体来说,当系统中增加更多服务器时,读取操作的性能不会受到影响,因为每个服务器都可以独立处理其本地的读请求。这种架构设计使得ZooKeeper能够有效地应对大量并发读取需求,同时保持更新操作的一致性和可靠性。
例如:在领导者选举场景中,ZooKeeper通过将路径指定为“ready znode”来确保其他进程在配置更新完成前不使用该配置。新领导者可以通过以下步骤快速完成配置更新:
- 删除“ready znode”:新领导者首先删除表示配置准备好的“ready znode”,这标志着配置即将进行更改。
- 更新配置znode:接下来,新领导者更新多个配置znode,以反映新的系统状态和参数。
- 重新创建“ready znode”:最后,新领导者在完成所有必要的更新后,重新创建“ready znode”。只有当这个节点存在时,其他进程才会使用新的配置。
由于ZooKeeper提供的顺序保证,一旦其他进程看到“ready znode”,它们就必须能够看到新领导者所做的所有配置更改。这种机制确保了在配置更新过程中,其他进程不会使用部分更新的配置,从而避免潜在的错误和不一致性。
ZooKeeper还提供了以下两项活跃性和持久性保证:
- 活跃性保证:如果大多数ZooKeeper服务器处于活动状态并且能够通信,ZooKeeper服务将保持可用。这意味着即使有部分服务器故障,只要超过半数的服务器正常工作,系统仍然能够响应客户端请求。
- 持久性保证:如果ZooKeeper服务成功响应了更改请求,那么该更改将会在系统恢复后依然存在,即使在发生故障的情况下也不会丢失。这确保了重要的数据和状态能够保持不变,直到系统恢复到正常状态。
这种设计使得ZooKeeper能够在分布式环境中提供高可靠性和一致性,是构建容错和高可用系统的重要基础。
API使用示例
如何使用ZooKeeper API来实现更强大的协调原语?ZooKeeper服务本身并不知道这些原语的具体实现,因为它们完全是在客户端通过ZooKeeper客户端API实现的。一些常见的原语,如组成员管理和配置管理,都是无阻塞的。而对于其他一些原语,比如会合(rendezvous),客户端需要等待特定事件的发生。尽管ZooKeeper是无阻塞的,我们仍然可以利用ZooKeeper实现高效的阻塞原语。ZooKeeper的顺序保证使得对系统状态的推理变得高效,而watch机制则允许客户端高效地等待事件发生。
配置信息管理
ZooKeeper可以用于实现分布式应用中的动态配置。最简单的形式是将配置存储在一个znode(称为zc)中。进程启动时使用zc的完整路径名,并通过设置watch标志为true来读取zc的配置。如果zc中的配置被更新,进程会收到通知并再次读取新的配置,同时重新设置watch标志。
在这种机制中,watch用于确保进程获取最新的信息。例如,如果一个进程正在监视zc并收到更改通知,但在它发起读取之前又发生了三次更改,进程不会收到额外的三个通知事件。这不会影响进程的行为,因为这些通知只是提醒它之前的信息已经过时。
会合(Rendezvous)
在分布式系统中,有时无法提前确定最终的系统配置。例如,客户端可能需要启动一个主进程和多个工作进程,但这些进程的启动是由调度器完成的,因此客户端无法事先知道主进程的地址和端口等信息。为了解决这个问题,ZooKeeper使用一个会合znode(称为zr),该节点由客户端创建。客户端将zr的完整路径作为主进程和工作进程的启动参数。当master启动时,它会在zr中填充有关其正在使用的地址和端口的信息。当工作进程启动时,他们会读取zr并将watch设置为true。如果zr尚未填写,worker等待zr更新时收到通知。如果zr是临时节点,则主进程和工作进程可以监视zr是否被删除,并在客户端结束时自行清理。
群组成员管理
可以利用临时节点来实现组成员管理。具体来说,我们指定一个znode(称为zg)来表示组。当组中的一个进程启动时,它会在zg下创建一个临时子znode。如果每个进程有唯一的名称或标识符,则使用该名称作为子znode的名称;否则,进程会创建一个带有SEQUENTIAL标志的znode以获得唯一名称。进程可以在子znode的数据中存储其信息,例如使用的地址和端口。
一旦在zg下创建了子znode,进程就可以正常启动,无需其他操作。如果进程失败或结束,代表它的znode会自动被移除。进程可以通过列出zg的子节点来获取组信息。如果进程希望监视组成员变化,可以将watch标志设置为true,并在收到变化通知时刷新组信息(始终设置watch标志为true)。
分布式锁
尽管ZooKeeper不是一个锁服务,但可以用来实现锁机制。应用程序通常使用根据需求定制的同步原语。最简单的锁实现使用“锁文件”,即通过一个znode来表示锁。
要获取锁,客户端尝试创建带有EPHEMERAL标志的指定znode。如果创建成功,客户端就持有该锁;如果失败,客户端可以设置watch标志读取znode,以便在当前领导者死亡时收到通知。客户端在死亡或显式删除znode时释放锁。其他等待获取锁的客户端在观察到znode被删除后会重新尝试获取锁。
尽管这种简单的锁协议有效,但存在一些问题:
- 群体效应:如果有多个客户端等待获取锁,当锁被释放时,它们会同时争抢锁,尽管只有一个客户端能成功获取。
- 仅实现独占锁:该协议只能实现独占锁,而无法支持其他类型的锁机制。
无群体效应的分布式锁
定义一个锁znode(l)来实现这种锁机制。直观上,我们将所有请求锁的客户端排成一队,并按照请求到达的顺序授予锁。客户端获取和释放锁的步骤如下:
1 | Lock |
在第1步中使用SEQUENTIAL标志可以对客户端请求获取锁的顺序进行排序。如果客户端的znode在第3步中是序号最低的,则该客户端持有锁。否则,客户端等待前一个znode被删除,从而避免了群体效应,因为只有一个进程会在锁释放时被唤醒。释放锁只需删除表示锁请求的znode n。使用EPHEMERAL标志创建znode时,崩溃的进程会自动清理任何锁请求或释放它们持有的锁。
这种锁的优点:
- 删除一个znode只会唤醒一个客户端,避免了群体效应;
- 没有轮询或超时;
- 通过浏览ZooKeeper数据,可以查看锁竞争情况、强制释放锁并调试锁问题。
读写锁
为了实现读/写锁,需要稍微修改了锁的过程,并分别定义了读锁和写锁的程序。释放锁的过程与全局锁相同。
1 | Write Lock |
这种锁机制与之前的实现略有不同。写锁仅在命名上有所区别,而读锁可以共享,因此第3和第4步略有变化,因为只有早期的写锁znode会阻止客户端获取读锁。虽然在多个客户端等待读锁时,可能会出现“群体效应”,当具有较低序号的“write-” znode被删除时所有等待的读客户端都会被通知,但这实际上是期望的行为,因为这些读客户端现在可以获得锁。
双重屏障
双重屏障使客户端能够同步计算的开始和结束。当达到定义的屏障阈值的足够进程加入屏障时,进程开始计算,并在完成后离开屏障。我们用一个znode(称为b)在ZooKeeper中表示屏障。每个进程p在进入时通过创建b的子znode进行注册,并在准备离开时通过删除子znode进行注销。当b的子znode数量超过屏障阈值时,进程可以进入屏障;当所有进程都删除了自己的子znode后,进程可以离开屏障。使用watch机制高效地等待进入和退出条件的满足。进入时,进程监视b中由使子节点数量超过阈值的进程创建的ready子节点的存在;离开时,进程监视特定子节点的消失,并仅在该znode被移除后检查退出条件。
ZooKeeper的具体设计实现
ZooKeeper的服务组件如下图所示(主要包含请求处理器,原子广播(使用Zab协议)和副本数据库):
ZooKeeper是一个分布式协调服务系统,其核心设计通过数据复制在多个服务器上来保证高可用性。在处理请求时,系统采用两种不同的处理方式:对于读请求,服务器可以直接从本地的内存数据库中获取数据;而对于写请求,则需要通过一个leader服务器来协调,并使用一致性协议来确保所有服务器的数据一致性。
系统的数据存储采用内存数据库的形式,存储着完整的数据树结构,每个节点默认可存储1MB的数据(这个值可变)。为了确保数据的可恢复性,系统会将更新操作记录到磁盘日志中,并定期生成内存数据库的快照。
在服务器角色划分上,ZooKeeper采用leader和follower的结构。所有的写操作都需要经过leader处理,follower负责接收并确认leader发送的状态变更提议。客户端在使用时只需要连接到任一服务器即可提交请求,这种设计既保证了系统的可用性,也简化了客户端的使用复杂度。
ZooKeeper请求处理器
ZooKeeper的请求处理采用原子消息层,确保本地副本永不出现分歧,虽然不同服务器之间的事务应用进度可能不同。系统中的事务具有幂等性,这一点与客户端请求不同。当leader收到写请求时,会执行以下处理流程:
- 计算系统在应用写操作后的未来状态
- 将写请求转换为描述这个未来状态的事务
系统需要计算未来状态的原因是可能存在尚未应用到数据库的待处理事务。具体处理时:
- 成功情况:如版本号匹配时,生成包含新数据、新版本号和更新时间戳的setDataTXN
- 失败情况:如版本号不匹配或节点不存在时,则生成errorTXN
原子广播
ZooKeeper的更新操作采用了名为Zab的原子广播协议来实现状态同步。在这个机制中,所有的更新请求都会被转发到leader服务器,由leader执行请求并通过Zab协议向其他服务器广播状态变更。只有当状态变更被实际应用后,接收请求的服务器才会响应客户端。
在容错设计上,ZooKeeper采用简单多数投票机制,这意味着在总共2f+1台服务器的情况下,系统可以容忍f台服务器的故障。为了保证数据一致性,Zab协议确保了leader发送的变更会按照发送顺序被传递,并且新的leader在广播自己的变更之前,必须先接收并处理前任leader的所有变更。
在具体实现上,系统采用了多项优化措施:使用TCP协议确保网络消息的顺序传递;将日志同时用作内存数据库的预写日志,避免重复写入磁盘;采用幂等事务设计,使得在系统恢复过程中即使消息重复传递也不会影响数据一致性。特别要注意的是,系统要求在恢复时必须重新传递最后一次快照之后的所有消息,以确保数据的完整性。
副本数据库
ZooKeeper的每个副本都在内存中维护着完整的系统状态数据。为了解决服务器崩溃后的恢复问题,系统不会选择重放所有历史消息这种耗时的方案,而是采用了周期性快照配合消息重放的方式,只需要重放最近一次快照之后的消息即可。
系统采用了一种称为”模糊快照”的特殊机制,其特点是在不锁定系统状态的情况下,通过深度优先搜索的方式搜索数据树,原子性地读取每个节点的数据和元数据并写入磁盘。这种快照方式可能会在生成过程中包含部分状态变更,导致快照中的状态可能并不对应系统在任何时间点的真实状态。
不过,由于ZooKeeper的状态变更操作具有幂等性,即使快照数据不完全准确,系统也可以通过按顺序重新应用状态变更来确保数据的一致性。当服务器使用这样的快照恢复,并重新接收状态变更消息后,最终仍能恢复到崩溃前的正确状态。这种设计既保证了数据的一致性,又提高了系统的性能。
客户端服务器的交互机制
在请求处理方面,系统对读写请求采用了不同的处理策略。写请求必须按严格顺序处理,服务器在处理写请求时会同时发送并清除与该更新相关的所有通知(watch)。系统不允许并发处理其他读写请求,这确保了通知的严格顺序性。特别的是,通知机制是本地化的,只有与客户端直接连接的服务器才负责追踪和触发该客户端的通知。
读请求则采用本地处理机制,每个读请求都会被标记上一个事务ID(zxid),该ID对应服务器看到的最后一个事务。这种设计使得读操作仅需在本地服务器的内存中执行,无需磁盘操作和一致性协议,从而实现了极高的读性能。但这种设计也带来了一个缺点:读操作可能会返回过期数据。为了解决这个问题,系统提供了sync原语,客户端可以先调用sync再执行读操作,以确保获取最新数据。
在会话管理和故障检测方面,系统实现了完善的机制。服务器按FIFO顺序处理客户端请求,响应中包含相关的事务ID。即使在无活动期间的心跳消息中,也会包含服务器最后看到的事务ID。当客户端需要连接新服务器时,系统会通过比较事务ID(由客户端发往新服务器)确保服务器的数据至少与客户端一样新,这对保证数据持久性至关重要。
为了及时检测客户端会话故障,系统采用了精心设计的超时机制。leader通过判断在会话超时时间内是否有服务器收到客户端的消息来确定会话是否失效。在实际运行中,如果客户端频繁发送请求,则无需额外的心跳消息;否则,客户端需要在低活动期间发送心跳。具体而言,当会话空闲时间达到超时时间(s)的1/3时,客户端会发送心跳;如果在2s/3时间内没有收到服务器响应,客户端会切换到新的服务器以重建会话。这种机制既确保了会话的可靠性,又避免了不必要的网络开销。
总结
ZooKeeper在分布式系统中采用了无等待(wait-free)的设计理念来解决进程协调问题。这种创新的设计方式使得系统在读操作为主的工作负载下能够实现每秒数十万次操作的惊人吞吐量。这主要得益于系统通过副本提供的本地快速读取和监视(watch)功能。虽然ZooKeeper的读操作一致性保证看似较弱,但实际应用证明,这种设计选择是合理且高效的。系统能够支持客户端实现复杂的协调协议,而无等待特性的设计更是成为实现高性能的关键因素。它不仅在Yahoo内部得到广泛应用,更在整个分布式系统领域发挥着重要作用。