目录

谈谈 ZooKeeper 的局限性

Zookeeper 是最常用的分布式协调服务之一,用于配置管理、分布式锁、服务注册与发现等。笔者在使用 Zookeeper 的过程中,也意识到了 Zookeeper 局限性,本文将对这些痛点问题进行总结与分析,并提出一些可行的解决方案。

概述

Zookeeper 是大名鼎鼎的谷歌分布式锁服务 Chubby 的开源实现,它提供了一种分布式系统数据一致性的解决方案,通过类 Paxos 算法(ZAB 协议)保证数据的一致性与服务的高可用,同时也提供了一些高级特性:比如 Watcher 机制、ACL 权限控制等。Zookeeper 的出现大大简化了分布式系统的开发,但是由于初期的系统设计缺陷,也积累了一些问题,本文将对这些局限性进行总结与分析,并与后起之秀分布式 KV 存储系统 ETCD 进行对比,分析他们在设计思想上的差异。

本文建立在对分布式共识算法、Zookeeper/ETCD 的基本原理有一定了解的基础上,如果对这些概念不熟悉,建议先阅读相关资料。漫谈分布式共识算法与数据一致性分布式键值存储 etcd 原理与实现

存储性能

Zookeeper 是一个分布式 KV 存储系统,内部实现了一个内存数据库ZKDatabase,其中有两个核心数据结构,一个是以基数树为逻辑数据模型的纯内存的存储引擎DataTree,另一个是基于文件系统的持久化存储模块snapLog。这两部分组成了 Zookeeper 的 KV 数据库ZKDatabase,并保证服务重启后数据不会丢失。

1
2
3
4
public class ZKDatabase {
    protected DataTree dataTree;
    protected FileTxnSnapLog snapLog;
    ...

DataTree

Zookeeper 对外暴露的数据模型是一个基于路径的树形结构,类似于文件系统的目录结构,在 Zookeeper 实现了一个内存数据库DataTree,每个路径节点都是一个 ZNode,我们可以向 DataTree 中添加、删除、更新 ZNode,同时也可以在 ZNode 上注册 Watcher。

./ZooKeeper-DataTree@2x.png

ZNode 由五部分组成:pathdatastataclchildren,其中path是以/开始的全路径,剩余的四部分都存储在一个独立的DataNode数据结构中:data是 ZNode 的数据,stat是 ZNode 的元数据如版本号、数据长度等,acl是 ZNode 的权限控制,children是 ZNode 的子节点集合,同时DataNode也包含了一些辅助方法,比如getChildrengetDatasetData等,用于操作 ZNode 的数据。

DataTree 的所有path都被保存在一个哈希表中,pathDataNode的映射关系是一一对应,因此我们可以通过pathO(1) 时间复杂度内找到对应的DataNode,这样就能够保证数据的快速查询。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public class DataTree {
    private final NodeHashMap nodes; // 路径到 ZNode 的映射
    private IWatchManager dataWatches; // 路径 Watcher 管理
    private IWatchManager childWatches; // 子节点 Watcher 管理器
    ...
}

public class DataNode implements Record {
    byte[] data; // ZNode 数据
    public StatPersisted stat; // ZNode 元数据
    Long acl; // ZNode 权限控制
    private Set<String> children = null; // ZNode 子节点集合
    ...
}

DataTree 本质上是一个巨大的哈希表,虽然单次查询非常快,但是 Zookeeper 需要使用哈希表来维护基数树的路径关系,在创建、删除 ZNode 时,需要多次查询哈希表,增加写操作的延迟,降低写入性能。同时,Zookeeper 的内存管理是基于 JVM 的,JVM 的 GC 机制会导致一些停顿,同样会影响读写性能。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 代码逻辑有删减
public void createNode(final String path, byte data[], List<ACL> acl, long ephemeralOwner, int parentCVersion, long zxid, long time, Stat outputStat) {
    String parentName = path.substring(0, lastSlash);
    DataNode parent = nodes.get(parentName);
  
   synchronized (parent) {
        Set<String> children = parent.getChildren();
        if (children.contains(childName)) {
            throw new NodeExistsException();
        }
       // 更新 父节点的版本
        if (parentCVersion > parent.stat.getCversion()) {
            parent.stat.setCversion(parentCVersion);
            parent.stat.setPzxid(zxid);
        }
       // ..
   }
   // ..
}

SnapLog

SnapLog 是 Zookeeper 的持久化存储模块,用于将 Zookeeper 的内存数据备份到磁盘上。SnapLog 由两部分组成:事务日志(Transaction Log)和快照文件(Snapshot File)。事务日志用于记录所有的数据变更操作,快照文件会定期全量备份 DataTree 中的所有数据。当 Server 启动时,会先加载最近日期的快照文件,然后逐个加载事务日志文件,最终恢复到最新的状态。

1
2
3
4
5
6
7
public class FileTxnSnapLog {
    private final File dataDir; // 事务日志文件目录
    private final File snapDir; // 快照文件目录
    private TxnLog txnLog; // 事务日志管理器
    private SnapShot snapLog; // 快照管理器
    ...
}

客户端的每次写入操作都会同步到磁盘,这会增加写操作的延迟,因此事务日志的写入性能直接决定 Zookeeper Server 对请求的响应速度,为了增加写入性能,Zookeeper 采用磁盘预分配的策略,在事务日志文件创建之初就向操作系统预分配一个很大的磁盘块,默认是64M,而一旦已分配的文件剩余空间不足 4KB 时,那么将会再次进行预分配

性能瓶颈

相信各位读者看到这里,已经对 Zookeeper 的存储机制有熟悉的感觉了,可以将ZKDatabase看作是一个简化版本的 Redis 实现,只支持基数树这种 KV 数据结构,同样也使用 WAL 日志和快照文件来保证数据的持久化。Zookeeper 的数据存储是基于内存的,所有的数据都存储在内存中,虽然能够保证数据的快速查询,但是也会带来一些问题:

  1. 内存空间:Zookeeper 的所有数据都存储在内存中,包括 DataNode、Key Path、Watcher 等,内存上限就是 Zookeeper Server 的数据存储上限,因此 Zookeeper 只能存储 GB 级别的数;另一方面过多的数据量也会增加 GC 的压力,降低哈希表查询的性能,都会请求响应速度;
  2. 持久化: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 中写入了四条记录:

1
2
3
4
rev={1 0}, key=key1, value="valuel" 
rev={1 1}, key=key2, value="value2" 
rev={2 0}, key=key1, value="updatel"
rev={2 1}, key=key2, value="update2"

其中,reversion 主要由两部分组成, 第一部分是 main reversion,每次事务递增一;第二部分是 sub reversion,同一个事务的每次操作都会递增一,两者结合就可以保证 Key 唯一且递增。在上面的示例中,第一个事务的 main reversion 是 1,第二个事务的 main reversion 是 2。

从 MVCC 模块保存的数据格式我们可以看出,如果要从 BoltDB 中查询键值对,必须通过reversion进行查找。但客户端只知道具体键值对中的 Key 值,并不清楚每个键值对对应的 reversion 信息。

./B-Tree@2x.png

为了将客户端提供的原始键值对信息与reversion关联起来,ETCD 使用谷歌开源实现的 btree 数据结构维护 Key 与reversion之间的映射关系,BTree 的键部分存储了原始的 Key,值部分存储了一个 keyIndex 实例。一个 keyIndex 实例维护着某个 Key 全部历史修订版本信息。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// etcd/mvcc/backend/backend.go
type keyIndex struct {
    key []byte        // 客户端提供的原始 Key 值
    modified revision // 该 Key 值最后一次修改时对应的 revision 信息
    generations []generation
}

type revision struct {
    main int64
    sub int64
}

由此可见,ETCD 的存储实现是基于磁盘的,存储数据不会被限制在内存大小,同时也能够保证数据的持久化。另一方面,ETCD 核心团队对 BoltDB 的读写性能做了众多优化:Fully concurrent reads design proposalPerformance 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 带来的安全性问题。

1
2
3
4
5
6
7
func NewLock(client clientv3.Client, lockKey string, ttl int) (concurrency.Mutex, error) {
    session, err := concurrency.NewSession(client, concurrency.WithTTL(ttl))
    if err != nil {
        rerurn nil, error
    }
    rerurn concurrency.NewMutex(session, lockKey), nil
}

在上面的示例代码中,我们可以看到,每次创建分布式锁时,我们都会创建一个新的 Session,并将其绑定到 key 上,单个 Session 的生命周期与绑定到这个 Session 的所有 key 的生命周期是一致的,当我们创建分布式锁失败时,无论错误原因是是什么,我们只需要确保 Session 被关闭,或停止续租,当 TTL 超时后绑定到这个 Session 的所有 key 都会被删除,最终临时 key 都会被清理。

不可靠的 Watcher

Zookeeper 的 Watcher 机制是 Zookeeper 提供的一种事件通知机制,当我们在某个 ZNode 上注册了 Watcher 时,如果这个 ZNode 发生了变化,Zookeeper 服务器会通知客户端,客户端可以通过 Watcher 机制来实现一些高级特性,比如分布式锁、配置管理等。

实现原理

WatchManager是 Zookeeper Watcher 机制的核心组件,它负责管理所有的 Watcher,其内部维护了两个哈希表:watchTablewatch2PathswatchTable 是一个从 ZNode 到 Watcher 的映射表,watch2Paths 是一个从 Watcher 到 ZNode 的映射表,其内部实现如下:

1
2
3
4
5
6
7
public class WatchManager {
    private final HashMap<String, HashSet<Watcher>> watchTable =
        new HashMap<String, HashSet<Watcher>>();

    private final HashMap<Watcher, HashSet<String>> watch2Paths =
        new HashMap<Watcher, HashSet<String>>();
}

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。

1
2
3
4
5
6
public void process(WatchedEvent event) {
    if (event.getType() == Event.EventType.NodeDataChanged) {
        // 重新注册 Watcher
        zk.getData("/service/lock", this, null);
    }
}

并且 Watcher 与客户端的 Session 绑定,当 Session 超时或关闭时,所有的 Watcher 都会失效,客户端需要重新注册 Watcher,在重新建立连接前,任何 ZNode 的变化都不会通知客户端。那么我们在接收到通知后,或出现网络故障,都需要重新注册 Watcher,如果我们在重新注册 Watcher 之前,ZNode 发生了变化,那么我们就会错过这次变化,从而导致客户端观测到的数据变化过程少于真实的数据变化过程,因此 Zookeeper 的 Watcher 机制只能保证最终一致性,而不能保证线性一致性。

./Watch-Consistency.png

综上所述,Zookeeper 的 Watcher 机制是一次性的,且与 Session 绑定,当 Session 超时或关闭时,所有的 Watcher 都会失效,在重新建立连接前,任何 ZNode 数据变化事件都会丢失,无法保证 Watcher 事件通知的可靠性,因此 Watcher 机制只能保证最终一致性,而不能保证线性一致性或顺序一致性。

ETCD 实现思路

ETCD 也在 Watcher 机制上做了一些改进,ETCD 的 Watcher 机制是持久性的,当客户端收到通知后,Watcher 不会被删除,而是会一直保持有效,直到客户端主动删除 Watcher。ETCD Watcher 也与客户端的连接状态无关,即使客户端断开连接,Watcher 仍然有效,当客户端重新连接后,仍然可以接收到在断开期间发生的所有事件。具体来说,ETCD 的 Watcher 事件不会丢失的原理如下:

  1. 当客户端注册 Watcher 后,ETCD Server 会创建watcher实例并加入到 boltdb 存储的 Watchers 集合中管理,同时将watcher绑定到 Key 上,当 Key 的值发生变化时,ETCD 服务器会将这个变化事件发送给所有注册在该 Key 上的 Watcher。

  2. ETCD 的 Watcher 机制依赖于其多版本并发控制(MVCC)机制,实现了历史事件的持久化存储,如果客户端故障,Server 会将数据变更事件按照 FIFO 顺序持久化在存储引擎中,等待客户端恢复;

  3. 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,提高系统的性能与可靠性。