ZooKeeper是分布式系统中常用的协调服务能实现配置管理命名服务分布式锁集群管理等等功能。

很多人都用过ZooKeeper但是大部分人只会基本的使用,比如创建节点读取数据设置Watcher等等对于一些进阶的技巧和最佳实践可能不太了解。

我在工作中大量使用ZooKeeper做分布式协调踩了很多坑也积累了一些经验今天分享一些ZooKeeper协调进阶的技巧这些技巧可能你不知道,但是很实用能帮你更好地使用ZooKeeper避免踩坑。

一、ZooKeeper基础回顾

在讲进阶技巧之前,先简单回顾一下ZooKeeper的基础知识。

ZooKeeper是一个分布式的协调服务提供了类似文件系统的数据结构和,通知机制能实现分布式系统中的各种协调场景。

核心概念

  1. Znode:ZooKeeper的数据节点类似文件系统的文件和,目录每个Znode可以存储数据也可以有子节点。
  2. 会话(Session):客户端和ZooKeeper服务器之间,的连接会话有超时时间超时后会话失效临时节点会被删除。
  3. Watcher:事件监听器客户端可以在Znode上设置Watcher当Znode发生变化的时候ZooKeeper会通知客户端。
  4. ACL:访问控制列表控制Znode的访问权限。

节点类型

  1. 持久节点(PERSISTENT):创建后一直存在除非主动删除。
  2. 临时节点(EPHEMERAL):会话有效期间存在会话结束后自动删除。
  3. 顺序节点(SEQUENTIAL):创建节点的时候,候ZooKeeper会自动在节点名后面加上递增的序号。
  4. 临时顺序节点(EPHEMERAL_SEQUENTIAL):既是,临时节点又是顺序节点常用于分布式锁。

常用场景

  1. 配置管理:把配置存在ZooKeeper的节点上客户端监听节点变化配置更新时自动通知客户端。
  2. 命名服务:用ZooKeeper的顺序节点生成全局唯一的ID。
  3. 分布式锁:用临时顺序节点实现分布式锁公平锁避免惊群效应。
  4. 集群管理:用临时节点做服务注册和发现节点上下线自动感知。
  5. Master选举:用临时节点实现Master选举Master宕机后自动重新选举。

回顾了基础下面开始讲进阶技巧。

二、进阶技巧1:正确使用Watcher避免一次性

很多人用ZooKeeper的Watcher的时候,会遇到一个问题就是Watcher是一次性的触发一次后就失效了,如果想持续监听节点变化需要重新设置Watcher。

但是很多人在重新设置Watcher的时候,会有时间差导致错过一些事件,或者重复处理事件。

正确的做法

在处理Watcher事件的回调里先重新设置Watcher再处理业务逻辑这样能保证不会错过事件。

比如用Curator的话Curator已经封装了这个逻辑用NodeCache或者PathChildrenCache就能持续监听不用自己处理Watcher的重新设置问题。

如果用原生API的话,要注意在getDatagetChildren的回调里先重新设置Watcher再处理数据,比如:

// 错误的写法先处理数据再设置Watcher可能错过事件
public void process(WatchedEvent event) {
    byte[] data = zk.getData(path, false, null); // 不设置Watcher
    handleData(data); // 处理数据
    zk.getData(path, this, null); // 重新设置Watcher但是中间可能有事件错过
}

// 正确的写法先设置Watcher再处理数据
public void process(WatchedEvent event) {
    zk.getData(path, this, null); // 先重新设置Watcher
    byte[] data = zk.getData(path, false, null); // 再读取数据
    handleData(data); // 处理数据
}

不过,即使这样也可能有竞态问题,因为读取数据和设置Watcher不是原子的,所以更推荐用Curator等高级客户端它们已经处理了这些问题。

技巧总结

  • Watcher是一次性的要持续监听需要重新设置
  • 重新设置Watcher要在处理业务逻辑之前
  • 推荐用Curator等高级客户端已经封装了持续监听
  • 注意事件丢失和重复处理的问题

三、进阶技巧2:分布式锁的正确实现

分布式锁是ZooKeeper最常用的场景之一,但是很多人实现的分布式锁有问题,比如惊群效应锁不公平等等。

错误的实现

很多人实现分布式锁的时候,会用临时节点所有客户端都尝试创建同一个临时节点创建成功的获得锁创建失败的监听这个节点的删除事件等锁释放后再尝试获取锁。

这种实现有一个问题就是惊群效应当锁释放的时候,所有等待的客户端都会收到通知,然后都尝试获取锁,但是,只有一个能成功其他的又要继续等待这会造成大量的无效操作和网络流量影响性能。

而且这种锁是不公平的获取锁的顺序和等待的顺序无关可能有客户端一直抢不到锁饿死。

正确的实现

正确的分布式锁应该用临时顺序节点实现公平锁避免惊群效应。

实现步骤:

  1. 客户端在锁的目录下创建临时顺序节点,比如/lock/lock-0000000001。
  2. 客户端获取锁目录下所有子节点看自己的节点是不是序号最小的。
  3. 如果是序号最小的说明获得了锁执行业务逻辑执行完删除自己的节点释放锁。
  4. 如果不是序号最小的说明没有获得锁监听自己前面一个节点的删除事件等前面的节点被删除后再判断自己是不是最小的重复步骤2-4。

这种实现是公平锁获取锁的顺序和创建节点的顺序一致不会有客户端饿死。而且每个客户端只监听自己前面一个节点锁释放的时候,只有下一个客户端会收到通知不会有惊群效应性能更好。

用Curator实现

Curator已经封装了分布式锁的实现用InterProcessMutex就能很方便地使用分布式锁不用自己实现:

InterProcessMutex lock = new InterProcessMutex(client, "/lock");
try {
    if (lock.acquire(10, TimeUnit.SECONDS)) { // 获取锁超时10秒
        try {
            // 执行业务逻辑
        } finally {
            lock.release(); // 释放锁
        }
    }
} catch (Exception e) {
    e.printStackTrace();
}

Curator的分布式锁实现很完善支持可重入超时等等功能推荐直接用不要自己实现容易出错。

技巧总结

  • 不要用同一个临时节点实现分布式锁会有惊群效应
  • 用临时顺序节点实现公平锁避免惊群效应
  • 每个客户端只监听前一个节点不是监听所有节点
  • 推荐用Curator的InterProcessMutex不要自己实现
  • 注意锁的释放要用finally确保锁能释放
  • 注意会话超时临时节点会被删除锁会自动释放

四、进阶技巧3:集群管理的正确姿势

ZooKeeper常用于集群管理服务注册和发现,但是很多人用的方式有问题,比如节点数据太大Watcher太多等等。

服务注册和发现

常见的服务注册和发现的实现是服务提供者启动的时候,在ZooKeeper的服务目录下创建临时节点把自己的地址端口等信息存在节点数据里。服务消费者监听服务目录的子节点变化获取所有服务提供者的地址做负载均衡。

这种实现是正确的,但是有一些细节要注意。

注意事项

  1. 节点数据不要太大:ZooKeeper的节点数据建议不要超过1MB最好小一些,因为ZooKeeper的数据都在内存里数据太大会占用大量内存也会影响性能。服务注册的节点数据只需要存地址端口等基本信息不要存太多数据。
  1. 用临时节点:服务注册一定要用临时节点这样服务提供者宕机的时候,会话超时节点会自动删除服务消费者能及时感知服务下线不会调用到已经下线的服务。
  1. 会话超时设置合理:临时节点的生命周期和,会话绑定会话超时后节点才会被删除。会话超时时间设置要合理太短网络抖动可能导致会话超时节点被误删服务频繁上下线太长服务宕机后很久才被感知会调用到宕机的服务影响可用性。一般建议设置为30秒到1分钟左右根据实际情况调整。
  1. 不要频繁注册注销:服务注册和注销会触发Watcher通知所有消费者,如果频繁注册注销会造成大量的通知影响性能。要保证服务稳定不要频繁上下线。
  1. 消费者本地缓存服务列表:服务消费者不要每次调用都去ZooKeeper读服务列表要在本地缓存服务列表监听ZooKeeper的变化更新本地缓存这样能减少ZooKeeper的压力也能提高性能。

用Curator实现

Curator提供了ServiceDiscovery能很方便地实现服务注册和,发现不用自己实现:

// 服务注册
ServiceInstance<InstanceDetails> instance = ServiceInstance.builder()
    .name("my-service")
    .address("127.0.0.1")
    .port(8080)
    .build();
ServiceDiscovery<InstanceDetails> discovery = ServiceDiscoveryBuilder.builder(InstanceDetails.class)
    .client(client)
    .basePath("/services")
    .build();
discovery.start();
discovery.registerService(instance);

// 服务发现
Collection<ServiceInstance<InstanceDetails>> instances = discovery.queryForInstances("my-service");

技巧总结

  • 服务注册用临时节点宕机自动下线
  • 节点数据不要太大只存基本信息
  • 会话超时设置合理不要太短也不要太长
  • 消费者本地缓存服务列表不要每次都读ZK
  • 推荐用Curator的ServiceDiscovery
  • 注意ZooKeeper的压力不要频繁注册注销

五、进阶技巧4:配置管理的最佳实践

ZooKeeper也常用于配置管理把配置存在ZooKeeper的节点上应用监听配置变化自动更新配置不用重启应用。

最佳实践

  1. 配置分层存储:不要把所有配置都存在一个节点上要分层存储,比如/config/appName/env/key这样结构清晰也能减少单个节点的数据量也能更精细地控制权限和监听。
  1. 用持久节点存配置:配置一般用持久节点存储,因为配置需要持久化不会,因为会话结束而删除。如果需要版本管理可以用子节点存历史版本,或者用ZooKeeper的事务ID做版本。
  1. 配置变更要原子:如果一次要更新多个配置要用事务(transaction)保证原子性要么全部成功要么全部失败不要部分更新导致配置不一致。
client.inTransaction()
    .setData().forPath("/config/app/key1", "value1".getBytes())
    .and()
    .setData().forPath("/config/app/key2", "value2".getBytes())
    .and()
    .commit();
  1. 配置变更要通知:配置更新后要通过Watcher通知所有应用让应用及时更新配置。应用要处理配置更新的事件更新本地缓存的配置注意配置更新的原子性和,一致性不要更新到一半用新配置一半用旧配置。
  1. 配置要有默认值:应用读取配置的时候,要有默认值防止ZooKeeper不可用,或者配置不存在的时候,应用出错。即使ZooKeeper挂了应用也能用默认值,或者本地缓存的配置继续运行提高可用性。
  1. 本地缓存配置:应用不要每次用配置都去ZooKeeper读要在本地缓存配置监听ZooKeeper的变化更新本地缓存这样能减少ZooKeeper的压力也能提高性能ZooKeeper不可用的时候,也能继续用本地缓存的配置。
  1. 配置变更要审计:配置变更是敏感操作要有审计记录谁在什么时候改了什么配置改前是,什么改后是什么出了问题能追溯。可以在配置节点下创建历史子节点记录每次变更,或者用外部系统记录审计日志。

技巧总结

  • 配置分层存储结构清晰减少单节点数据量
  • 用持久节点存配置需要持久化
  • 多配置更新用事务保证原子性
  • 配置变更通过Watcher通知应用
  • 应用要有默认值防止ZK不可用
  • 本地缓存配置不要每次都读ZK
  • 配置变更要有审计能追溯

六、进阶技巧5:ZooKeeper集群部署最佳实践

ZooKeeper本身的部署也很重要部署不好会影响ZooKeeper的性能和,可用性。

最佳实践

  1. 集群节点数用奇数:ZooKeeper集群的节点数要用奇数,比如357因为ZooKeeper用过半机制选举Leader奇数节点能容忍更多的节点故障也能避免脑裂。比如3节点能容忍1个故障5节点能容忍2个故障一般3节点就够了对可用性要求高的可以用5节点不建议用太多节点会影响性能,因为写操作需要过半节点确认节点越多写越慢。
  1. 节点部署在不同机器:ZooKeeper集群的节点要部署在不同的机器上不要都部署在一台机器上,否则机器宕机整个集群就挂了没有高可用。最好部署在不同的机架甚至不同的机房提高容错能力。
  1. 独立部署不要和其他应用混部:ZooKeeper对性能和稳定性要求高最好独立部署在专门的机器上不要和,其他应用混部避免其他应用占用资源影响ZooKeeper的性能和稳定性。
  1. 配置合适的JVM参数:ZooKeeper是Java应用要配置合适的JVM参数特别是堆内存ZooKeeper的数据都在内存里堆内存要足够,但是也不要太大太大会导致GC时间长影响性能。一般建议堆内存设置为物理内存的一半左右根据数据量调整用G1垃圾回收器减少GC停顿。
  1. 数据目录和日志目录分开:ZooKeeper的数据目录和事务日志目录要分开挂载在不同的磁盘上,因为事务日志的写是顺序写对磁盘性能要求高和数据目录分开能避免竞争提高性能。最好用SSD磁盘提高IO性能。
  1. 配置合适的超时时间:ZooKeeper的tickTimeinitLimitsyncLimit等超时参数要配置合理。tickTime是基本时间单位默认2000msinitLimit是Follower连接Leader的超时默认10也就是,20秒syncLimit是Follower和Leader同步的超时默认5也就是10秒。这些参数要根据网络情况调整网络好的可以调小网络差的要调大避免频繁超时节点掉线。
  1. 开启监控:ZooKeeper要开启监控监控集群的状态性能指标,比如节点是否存活是否有Leader请求延迟吞吐量连接数等等出了问题能及时发现和处理。可以用ZooKeeper的四字命令,或者JMX获取监控指标用PrometheusGrafana等监控系统展示和告警。
  1. 定期备份:ZooKeeper的数据要定期备份防止数据丢失,或者集群故障能恢复。可以用ZooKeeper的快照和事务日志备份,或者用工具导出数据备份。

技巧总结

  • 集群节点数用奇数3或5不要太多
  • 节点部署在不同机器最好不同机架/机房
  • 独立部署不要和其他应用混部
  • 配置合适的JVM参数堆内存不要太大也不要太小
  • 数据目录和日志目录分开用SSD
  • 配置合适的超时时间根据网络情况调整
  • 开启监控及时发现问题
  • 定期备份数据防止丢失

七、进阶技巧6:避免常见的坑

最后说一下ZooKeeper使用中常见的坑和避免方法。

坑1:会话超时导致临时节点被删

很多人用临时节点的时候,会遇到会话超时临时节点被删除的问题,比如服务注册的临时节点被删了服务被认为下线了,但是服务其实还在运行。

原因可能是网络抖动导致客户端和ZooKeeper的连接断开会话超时临时节点被删除。或者客户端Full GC时间长导致心跳发不出去会话超时。

避免方法:

  • 合理设置会话超时时间不要太短
  • 保证网络稳定客户端和ZK集群网络要好
  • 避免客户端长时间GC调优JVM减少GC停顿
  • 客户端要处理会话超时事件重新连接重新注册临时节点
  • 用Curator等高级客户端会自动处理重连和重新注册

坑2:Watcher事件丢失

Watcher是一次性的,而且可能丢失,比如客户端和ZooKeeper断开重连的过程中发生的事件可能丢失客户端收不到。

避免方法:

  • 不要依赖Watcher做精确的事件通知Watcher只是通知有变化具体变化要重新读取数据
  • 重连后要重新读取数据重新设置Watcher不要只依赖Watcher
  • 用Curator的NodeCache等已经处理了这些问题
  • 重要的场景要考虑事件丢失的情况做容错处理

坑3:ZooKeeper压力过大

ZooKeeper的性能不是无限的,如果客户端太多请求太频繁会导致ZooKeeper压力过大响应慢甚至不可用。

常见的原因:

  • 客户端太多连接数太多
  • 频繁读写请求量太大
  • 节点数据太大传输慢
  • Watcher太多事件通知量大
  • 子节点太多getChildren慢

避免方法:

  • 客户端本地缓存数据不要每次都读ZK
  • 合并请求不要频繁小请求
  • 节点数据不要太大控制在1MB以内最好小一些
  • 合理设置Watcher不要设置太多不必要的Watcher
  • 子节点太多的话,要考虑分片,或者用其他方案
  • 监控ZK的性能及时发现压力问题

坑4:脑裂

ZooKeeper集群可能出现脑裂也就是网络分区导致集群分成两个部分各自选举Leader导致数据不一致。

不过ZooKeeper用过半机制能避免脑裂,因为,只有过半节点的分区才能选举Leader提供服务另一个分区不足过半不能选举Leader不会提供服务,所以不会脑裂。

但是,如果集群节点数是偶数,比如2节点4节点网络分区后可能两个分区都有过半节点导致脑裂,所以集群节点数一定要用奇数。

避免方法:

  • 集群节点数用奇数357
  • 节点部署在不同机架/机房避免,同时故障
  • 监控集群状态及时发现分区问题

坑5:数据不一致

ZooKeeper是一致性的,但是,如果使用不当也可能出现数据不一致的问题。

比如客户端读取数据后处理的过程中数据被其他客户端修改了客户端用旧数据处理导致问题。

避免方法:

  • 用ZooKeeper的版本号做乐观锁更新数据的时候,带上版本号版本不对说明数据被改过更新失败重新读取处理
  • 重要的操作用事务保证原子性
  • 不要在客户端做复杂的分布式逻辑尽量用ZooKeeper的原子操作
  • 用Curator等高级客户端已经封装了很多一致性的处理
// 乐观锁更新带上版本号
Stat stat = new Stat();
byte[] data = client.getData().storingStatIn(stat).forPath(path);
// 处理数据
client.setData().withVersion(stat.getVersion()).forPath(path, newData);

八、写在最后

以上就是ZooKeeper协调进阶的一些技巧和最佳实践这些技巧可能你以前不知道,但是很实用能帮你更好地使用ZooKeeper避免踩坑。

ZooKeeper是一个很强大的分布式协调服务,但是也不是银弹有它的适用场景和局限性要根据实际情况选择使用不要什么都往ZooKeeper里塞。

使用ZooKeeper的时候,要注意它的性能和稳定性合理设计数据结构和使用方式不要给ZooKeeper太大的压力也要做好监控和容错保证系统的可用性。

推荐大家用Curator等高级客户端不要用原生API自己实现复杂的逻辑Curator已经封装了很多最佳实践和容错处理能帮你少踩很多坑。

希望我的经验能帮大家更好地使用ZooKeeper做好分布式系统的协调。

如果有什么问题,或者更好的技巧欢迎在评论区留言我们一起交流。

最后用一句话结束这篇文章:"ZooKeeper用好了是分布式协调的利器用不好就是坑的集合。"

愿大家都能用好ZooKeeper少踩坑多办事。