谈谈 ZooKeeper 的局限性
Zookeeper 是最常用的分布式协调服务之一,用于配置管理、分布式锁、服务注册与发现等。笔者在使用 Zookeeper 的过程中,也意识到了 Zookeeper 局限性,本文将对这些痛点问题进行总结与分析,并提出一些可行的解决方案。
概述
Zookeeper 是大名鼎鼎的谷歌分布式锁服务 Chubby 的开源实现,它提供了一种分布式系统数据一致性的解决方案,通过类 Paxos 算法(ZAB 协议)保证数据的一致性与服务的高可用,同时也提供了一些高级特性:比如 Watcher 机制、ACL 权限控制等。Zookeeper 的出现大大简化了分布式系统的开发,但是由于初期的系统设计缺陷,也积累了一些问题,本文将对这些局限性进行总结与分析,并与后起之秀分布式 KV 存储系统 ETCD 进行对比,分析他们在设计思想上的差异。
本文建立在对分布式共识算法、Zookeeper/ETCD 的基本原理有一定了解的基础上,如果对这些概念不熟悉,建议先阅读相关资料。漫谈分布式共识算法与数据一致性、分布式键值存储 etcd 原理与实现
存储性能
Zookeeper 是一个分布式 KV 存储系统,内部实现了一个内存数据库ZKDatabase
,其中有两个核心数据结构,一个是以基数树为逻辑数据模型的纯内存的存储引擎DataTree
,另一个是基于文件系统的持久化存储模块snapLog
。这两部分组成了 Zookeeper 的 KV 数据库ZKDatabase
,并保证服务重启后数据不会丢失。
|
|
DataTree
Zookeeper 对外暴露的数据模型是一个基于路径的树形结构,类似于文件系统的目录结构,在 Zookeeper 实现了一个内存数据库DataTree
,每个路径节点都是一个 ZNode,我们可以向 DataTree 中添加、删除、更新 ZNode,同时也可以在 ZNode 上注册 Watcher。
ZNode 由五部分组成:path
、data
、stat
、acl
、children
,其中path
是以/
开始的全路径,剩余的四部分都存储在一个独立的DataNode
数据结构中:data
是 ZNode 的数据,stat
是 ZNode 的元数据如版本号、数据长度等,acl
是 ZNode 的权限控制,children
是 ZNode 的子节点集合,同时DataNode
也包含了一些辅助方法,比如getChildren
、getData
、setData
等,用于操作 ZNode 的数据。
DataTree 的所有path
都被保存在一个哈希表中,path
到DataNode
的映射关系是一一对应,因此我们可以通过path
在 O(1) 时间复杂度内找到对应的DataNode
,这样就能够保证数据的快速查询。
|
|
DataTree 本质上是一个巨大的哈希表,虽然单次查询非常快,但是 Zookeeper 需要使用哈希表来维护基数树的路径关系,在创建、删除 ZNode 时,需要多次查询哈希表,增加写操作的延迟,降低写入性能。同时,Zookeeper 的内存管理是基于 JVM 的,JVM 的 GC 机制会导致一些停顿,同样会影响读写性能。
|
|
SnapLog
SnapLog 是 Zookeeper 的持久化存储模块,用于将 Zookeeper 的内存数据备份到磁盘上。SnapLog 由两部分组成:事务日志(Transaction Log)和快照文件(Snapshot File)。事务日志用于记录所有的数据变更操作,快照文件会定期全量备份 DataTree 中的所有数据。当 Server 启动时,会先加载最近日期的快照文件,然后逐个加载事务日志文件,最终恢复到最新的状态。
|
|
客户端的每次写入操作都会同步到磁盘,这会增加写操作的延迟,因此事务日志的写入性能直接决定 Zookeeper Server 对请求的响应速度,为了增加写入性能,Zookeeper 采用磁盘预分配的策略,在事务日志文件创建之初就向操作系统预分配一个很大的磁盘块,默认是64M,而一旦已分配的文件剩余空间不足 4KB 时,那么将会再次进行预分配
性能瓶颈
相信各位读者看到这里,已经对 Zookeeper 的存储机制有熟悉的感觉了,可以将ZKDatabase
看作是一个简化版本的 Redis 实现,只支持基数树这种 KV 数据结构,同样也使用 WAL 日志和快照文件来保证数据的持久化。Zookeeper 的数据存储是基于内存的,所有的数据都存储在内存中,虽然能够保证数据的快速查询,但是也会带来一些问题:
- 内存空间:Zookeeper 的所有数据都存储在内存中,包括 DataNode、Key Path、Watcher 等,内存上限就是 Zookeeper Server 的数据存储上限,因此 Zookeeper 只能存储 GB 级别的数;另一方面过多的数据量也会增加 GC 的压力,降低哈希表查询的性能,都会请求响应速度;
- 持久化:Zookeeper 的持久化机制是基于文件系统的,每次写入操作都会同步操作日志到磁盘,同样会增加写入操作的延迟,降低写入性能;
综上所述,内存空间是 Zookeeper 的死穴,内存决定了 Zookeeper 的数据存储上限,而磁盘 I/O 决定了 Zookeeper 的写入延迟与响应速度,使得 Zookeeper 只能支持几 GB 级别的数据存储,这是 Zookeeper 最大的局限性,也是 Zookeeper 在大规模集群中的瓶颈。
ETCD 存储实现
做为对比,我们再来看看 ETCD 的存储模块实现思路。ETCD 内部实现了一个基于 MVCC(多版本并发控制)的存储引擎,ETCD 的数据存储是基于磁盘的,所有的数据都存储在磁盘中,ETCD 的数据存储突破了内存的上限,因此 ETCD 能够存储几十甚至上百 GB 级别的数据。
ETCD 的 MVCC 模块实现了状态机存储功能,其底层使用的是开源的嵌入式键值存储数据库 BoltDB,但是这个项目已经由作者归档不再维护了,因此 ETCD 社区自己维护了一个 bbolt 版本。
为了实现多版本并发控制,ETCD 会将键值对的每个版本都保存到 BoltDB 中,ETCD 在 BoltDB 中存储的 Key 是修订版本reversion
,Value 是客户端发送的键值对组合。为了更好地理解这一概念,假设我们通过读写事务接口写入了两个键值对,分别是(key1, value1)和(key2, value2),之后我们再调用读写事务接口更新这两个键值对,更新后为(key1, update1)和(key2, update2),虽然两次写操作更新的是两个键值对,实际上在 BoltDB 中写入了四条记录:
|
|
其中,reversion 主要由两部分组成, 第一部分是 main reversion,每次事务递增一;第二部分是 sub reversion,同一个事务的每次操作都会递增一,两者结合就可以保证 Key 唯一且递增。在上面的示例中,第一个事务的 main reversion 是 1,第二个事务的 main reversion 是 2。
从 MVCC 模块保存的数据格式我们可以看出,如果要从 BoltDB 中查询键值对,必须通过reversion
进行查找。但客户端只知道具体键值对中的 Key 值,并不清楚每个键值对对应的 reversion 信息。
为了将客户端提供的原始键值对信息与reversion
关联起来,ETCD 使用谷歌开源实现的 btree 数据结构维护 Key 与reversion
之间的映射关系,BTree 的键部分存储了原始的 Key,值部分存储了一个 keyIndex 实例。一个 keyIndex 实例维护着某个 Key 全部历史修订版本信息。
|
|
由此可见,ETCD 的存储实现是基于磁盘的,存储数据不会被限制在内存大小,同时也能够保证数据的持久化。另一方面,ETCD 核心团队对 BoltDB 的读写性能做了众多优化:Fully concurrent reads design proposal、Performance optimization of etcd in web scale data scenario 等,尽可能避免读写事务之间互相阻塞。虽然 ETCD 需要大量的内存来维护索引与数据缓存,当与 Zookeeper 相比,ETCD 能够支撑上百 GB 级别的数据存储,并且能够保证请求的响应速度。
危险的全局 Session
在 Zookeeper 中,Session 是个非常重要的概念,客户端与 Server 之间的任何交互都是通过 Session 来完成的,包含临时节点的生命周期、Watcher 通知、客户端与 Server 之间的心跳等。Zookeeper 的 Session 是一个全局的概念,每个客户端首先会与服务器建立一个 TCP Socket 连接,从连接建立开始,客户端会话的生命周期也开始了,并为该 Session 分配一个全局唯一的SessionId
,标识客户端的身份。通过这个连接,客户端能够通过心跳检测与服务器保持有效的会话,也能够向 Zookeeper Server 发送请求并接受响应,同时还能够通过该连接接收来自服务器的 Watch 事件通知。
实现原理
Session 通过心跳检测来保持有效,如果客户端在一定时间内没有向服务器发送心跳检测,那么服务器会认为客户端已经失效,Session 也会被关闭。如果 Session 超时,Zookeeper 服务器会将 Session 关联的所有临时节点删除。Session 设计在一定程度上简化了开发,能够在客户端故障时自动释放资源,在分布式锁、服务注册与发现等场景中也能够发挥作用,但是全局 Session 也带来了一些问题。
每个客户端实例同一时刻只能有一个 Session,这意味着如果一个客户端实例同时创建了多个临时节点,那么这些临时节点的生命周期是一致的。如果我们想要显式地删除某个临时节点,那么我们只能通过delete
操作来删除,而不能通过关闭 Session 来让 ZNode 失效,这样操作会带来额外的复杂性:
-
增加 Client 实现的复杂度:由于 Session 是全局的,因此我们需要在客户端实现中维护 Session 的生命周期,确保 Session 超时后能够去激活所有的 Watcher,并在 Session 超时后重新创建 Session,这样会增加客户端实现的复杂性,也会增加客户端出现问题的概率;
-
异常处理:如果我们的客户端实例在删除临时节点时发生了异常,那么这个临时节点可能会一直存在,直到 Session 超时。因此我们在编写业务逻辑代码时,需要特别小心,确保我们的代码在发生异常时,能够处理异常并重试,保证临时节点能够被正确删除;
1 2 3 4 5 6 7 8 9 10
func deleteNode(client *zk.Conn, path string) error { for { err := client.Delete(path, -1) if err != nil { if errors.Is(err, zk.ErrNoNode) { return nil } } return nil }
-
重复创建:如果我们的客户端实例在执行
create
临时节点操作后,该临时节点在 Zookeeper 集群上创建成功,但是客户端没有及时接收到创建成功的响应,或是响应丢失,那么这个临时节点可能会一直存在,直到 Session 超时。例如在实现分布式锁时,我们需要为每个服务实例创建一个临时有序节点,如果某个实例第一次创建临时节点/service/lock-100
成功,但是没有及时接收到创建成功的响应,那么它可能会再次创建一个临时节点/service/lock-102
,这样就会导致同一个服务实例同时持有两个分布式锁节点,导致死锁:1 2 3
# znode: /service/lock-100 session: node-1 # znode: /service/lock-101 session: node-2 # znode: /service/lock-102 session: node-1
如上所示,node-1 同时持有
/service/lock-100
和/service/lock-102
两个分布式锁节点,同时 node-2 持有/service/lock-101
分布式锁节点,node-1 会认为自己持有/service/lock-102
分布式锁节点,等待 node-2 释放/service/lock-101
分布式锁节点,而 node-2 也在等待释放/service/lock-100
节点,这样就会导致死锁,所有的实例都抢占到分布式锁而无法继续执行。由于 node-1 的所有临时节点的生命周期是一致的,上述问题只能在 node-1 的 Session 超时或主动关闭 Client 后才能解决,因此我们在编写业务逻辑代码时,需要针对这种情况进行处理:node-1 在创建临时节点后,如果没有及时接收到创建成功的响应,那么它需要检查自己是否已经创建了临时节点,遍历
/service
目录下的所有临时节点,检查是否有存在临时节点的 SessionID 与当前客户端一致,如果存在,则复用这个临时节点,否则创建新的临时节点;
上述问题是笔者在使用 Zookeeper 时踩到过的坑,虽然可以通过一些手段来规避——增加大量的重试代码与边界条件处理代码,但是这些方式会增加客户端与业务逻辑的复杂度,另一方面,在系统初期实现时,如果对 Zookeeper 的 Session 机制不够熟悉,我们很容易忽略这些问题,导致系统出现 BUG。
ETCD 设计思想
为了规避上述问题,在 ETCD 的设计中,采用了更加灵活的 Session 概念,客户端与服务端连接建立后,不需要绑定一个特定的 Session,每个客户端实例可以创建并持有多个 Session,每个 Session 可以关联一个或多个临时节点,单个 Session 的失效只会影响与其关联的临时节,避免了全局 Session 带来的安全性问题。
|
|
在上面的示例代码中,我们可以看到,每次创建分布式锁时,我们都会创建一个新的 Session,并将其绑定到 key 上,单个 Session 的生命周期与绑定到这个 Session 的所有 key 的生命周期是一致的,当我们创建分布式锁失败时,无论错误原因是是什么,我们只需要确保 Session 被关闭,或停止续租,当 TTL 超时后绑定到这个 Session 的所有 key 都会被删除,最终临时 key 都会被清理。
不可靠的 Watcher
Zookeeper 的 Watcher 机制是 Zookeeper 提供的一种事件通知机制,当我们在某个 ZNode 上注册了 Watcher 时,如果这个 ZNode 发生了变化,Zookeeper 服务器会通知客户端,客户端可以通过 Watcher 机制来实现一些高级特性,比如分布式锁、配置管理等。
实现原理
WatchManager
是 Zookeeper Watcher 机制的核心组件,它负责管理所有的 Watcher,其内部维护了两个哈希表:watchTable
和 watch2Paths
,watchTable
是一个从 ZNode 到 Watcher 的映射表,watch2Paths
是一个从 Watcher 到 ZNode 的映射表,其内部实现如下:
|
|
Watcher 机制的实现是通过在 ZNode 上注册 Watcher,当 ZNode 发生变化时,通过watchTable
找到所有注册在这个 ZNode 上的 Watcher,然后通知这些 Watcher。Zookeeper Server 会将 Watcher 事件通知发送给客户端,从而实现 Watch 事件通知机制。但 Zookeeper 的 Watcher 机制存在一些问题。
Watcher 机制是一次性的,当 ZNode 发生变化时,Zookeeper Server 会调用WatchManager.triggerWatch
方法触发数据变更事件,同时将这些 Watcher 从watchTable
中删除,这意味着每个 Watcher 只能接收到一次通知,如果我们想要继续监听 ZNode 的变化,那么我们需要重新注册 Watcher。
|
|
并且 Watcher 与客户端的 Session 绑定,当 Session 超时或关闭时,所有的 Watcher 都会失效,客户端需要重新注册 Watcher,在重新建立连接前,任何 ZNode 的变化都不会通知客户端。那么我们在接收到通知后,或出现网络故障,都需要重新注册 Watcher,如果我们在重新注册 Watcher 之前,ZNode 发生了变化,那么我们就会错过这次变化,从而导致客户端观测到的数据变化过程少于真实的数据变化过程,因此 Zookeeper 的 Watcher 机制只能保证最终一致性,而不能保证线性一致性。
综上所述,Zookeeper 的 Watcher 机制是一次性的,且与 Session 绑定,当 Session 超时或关闭时,所有的 Watcher 都会失效,在重新建立连接前,任何 ZNode 数据变化事件都会丢失,无法保证 Watcher 事件通知的可靠性,因此 Watcher 机制只能保证最终一致性,而不能保证线性一致性或顺序一致性。
ETCD 实现思路
ETCD 也在 Watcher 机制上做了一些改进,ETCD 的 Watcher 机制是持久性的,当客户端收到通知后,Watcher 不会被删除,而是会一直保持有效,直到客户端主动删除 Watcher。ETCD Watcher 也与客户端的连接状态无关,即使客户端断开连接,Watcher 仍然有效,当客户端重新连接后,仍然可以接收到在断开期间发生的所有事件。具体来说,ETCD 的 Watcher 事件不会丢失的原理如下:
-
当客户端注册 Watcher 后,ETCD Server 会创建
watcher
实例并加入到 boltdb 存储的 Watchers 集合中管理,同时将watcher
绑定到 Key 上,当 Key 的值发生变化时,ETCD 服务器会将这个变化事件发送给所有注册在该 Key 上的 Watcher。 -
ETCD 的 Watcher 机制依赖于其多版本并发控制(MVCC)机制,实现了历史事件的持久化存储,如果客户端故障,Server 会将数据变更事件按照 FIFO 顺序持久化在存储引擎中,等待客户端恢复;
-
ETCD Watcher 能够在异常场景重试,并对历史事件进行重放:当客户端重新连接后,ETCD 服务器会将在断开期间发生的所有历史事件重新发送给客户端,确保客户端不会错过任何事件。
由此可见,ETCD 的 Watcher 机制是持久性的,且与客户端的连接状态无关,当客户端发生故障时,ETCD 服务器会保存这个事件,等待客户端重新连接后按照 FIFO 顺序重新发送,这样就能够保证 Watcher 事件通知的可靠性与有序性。ETCD 的 Watcher 机制具有更高的一致性级别,能够保证顺序一致性。
总结
本文总结了 Zookeeper 的存储性能、全局 Session、Watcher 机制等局限性,针对这些问题给出了一些可行的规避方案,并与新兴的 ETCD 实现原理进行了对比。
ZooKeeper 的出现填补了分布式协调组件的空白,在经历了多年的业务发展与技术迭代后,ZooKeeper 暴露了许多问题,难以支撑更大规模的集群。ETCD 是一个新兴的分布式 KV 存储系统,相较于 Zookeeper,ETCD 的存储性能更好,支持上百 GB 级别的数据存储;ETCD Watcher 机制更加灵活,支持持久性 Watcher,能够保证 Watcher 事件通知的可靠性与有序性。
在笔者看来,ETCD 与 ZooKeeper 并非是孰优孰劣的关系,ETCD 能够覆盖 Zookeeper 的应用场景,并且对 Zookeeper 暴露的许多问题进行了改进,是 ZooKeeper 的上位替代品,社区内的许多中间件也在考虑剥离对 Zookeeper 的依赖,替换为 Raft 实现或其它分布式协调服务:Kafka Needs No Keeper - Removing ZooKeeper Dependency (confluent.io)、Moving Toward a ZooKeeper-Less Apache Pulsar (streamnative.io)。在新的分布式系统设计中,我们可以考虑使用 ETCD 来替代 Zookeeper,提高系统的性能与可靠性。