ZooKeeper,是分布式系统中最常用的协调服务之一。
我用ZooKeeper做分布式协调,已经有三年了。这三年里,我用ZooKeeper做过服务发现、分布式锁、配置管理、Master选举、分布式队列、命名服务等各种协调场景,踩过很多坑,也积累了很多经验。
最开始用ZooKeeper的时候,我觉得它很简单,不就是一个分布式的文件系统吗,提供了节点的创建、删除、读取、监听等功能,用起来很简单。但是,用着用着,我发现ZooKeeper其实不简单,它有很多坑,很多细节,如果不注意,就会出问题,而且一出问题就是大问题——因为ZooKeeper通常是分布式系统的核心协调服务,一旦出问题,整个系统都可能瘫痪。
这三年里,我经历过ZooKeeper集群脑裂、会话过期、Watcher丢失、性能瓶颈、数据不一致等各种问题,每一次都让我对ZooKeeper有了更深的理解。
今天,我想分享一下我用了三年ZooKeeper之后,才真正明白的一些道理。包括ZooKeeper的核心原理、常见的使用场景、踩过的坑、性能优化、以及什么时候该用ZooKeeper、什么时候不该用。希望这些经验,能帮助正在用或者准备用ZooKeeper的朋友,少走一些弯路。
一、ZooKeeper到底是什么
在分享经验之前,先简单介绍一下ZooKeeper到底是什么。
ZooKeeper,是Apache基金会的一个开源项目,最初是Yahoo!开发的,后来捐献给了Apache。它是一个分布式的协调服务,为分布式应用提供一致性的协调服务。
简单来说,ZooKeeper就像一个分布式的文件系统,它的数据模型,很像一个文件系统的目录树。每个节点叫做"znode",znode可以存储数据,也可以有子节点。但是,和文件系统不同的是,ZooKeeper的znode是有生命周期的,而且提供了Watcher(监听器)机制,可以监听节点的变化。
ZooKeeper的核心特点:
- 顺序一致性:来自同一个客户端的更新请求,会按照发送的顺序被应用。
- 原子性:更新要么成功,要么失败,没有部分成功的情况。
- 单一系统镜像:无论客户端连接到哪个服务器,看到的都是同一个系统视图。
- 可靠性:一旦更新被应用,就会一直保持,直到被下一个更新覆盖。
- 实时性:客户端能在一定的时间范围内,看到系统的最新状态。
ZooKeeper通常以集群的方式部署,一个集群通常有奇数个节点(3个、5个、7个等)。集群通过Zab协议(ZooKeeper Atomic Broadcast)来保证数据的一致性。集群中会选举出一个Leader,所有的写请求都会转发给Leader处理,Leader通过Zab协议把更新广播给所有Follower,只要超过半数的节点确认,更新就会被提交。
理解了这些基本概念之后,我们来看看ZooKeeper的常见使用场景。
二、常见的使用场景
这三年里,我用ZooKeeper做过很多场景的协调,下面介绍几个最常见的:
1. 服务发现
这是ZooKeeper最常见的使用场景之一。在微服务架构中,服务的实例是动态变化的,服务上线、下线、扩容、缩容,都需要动态地注册和发现。
用ZooKeeper做服务发现的基本思路是:
- 服务提供者启动的时候,在ZooKeeper的某个目录下(比如/services/xxx-service)创建一个临时节点,节点里存储服务的地址和端口等信息。
- 服务消费者启动的时候,读取/services/xxx-service目录下的所有子节点,获取所有服务提供者的地址列表。
- 服务消费者同时监听这个目录的变化,当有新的服务上线(创建节点)或者旧的服务下线(删除节点)的时候,ZooKeeper会通知消费者,消费者更新本地的服务地址列表。
因为用的是临时节点(EPHEMERAL),当服务提供者宕机或者和ZooKeeper的会话断开的时候,临时节点会自动删除,消费者就能感知到服务下线,不会再调用已经下线的服务。
这个场景,我用了很多次,整体来说效果不错。但是,也踩过一些坑,后面会详细说。
2. 分布式锁
分布式锁,是另一个常见的使用场景。在分布式系统中,多个进程可能需要互斥地访问某个共享资源,这时候就需要分布式锁。
用ZooKeeper实现分布式锁的基本思路是:
- 客户端想要获取锁的时候,在ZooKeeper的某个目录下(比如/locks/xxx-lock)创建一个临时顺序节点(EPHEMERAL_SEQUENTIAL)。
- 客户端获取这个目录下的所有子节点,判断自己创建的节点是不是序号最小的。
- 如果是序号最小的,说明获取到了锁,执行业务逻辑,执行完之后删除自己的节点,释放锁。
- 如果不是序号最小的,说明没有获取到锁,客户端监听自己前面一个节点的删除事件,当前面一个节点被删除(锁被释放)的时候,客户端收到通知,再次判断自己是不是序号最小的,如果是,就获取到了锁。
这种实现方式,叫做"临时顺序节点"方式,是ZooKeeper分布式锁的标准实现。它的优点是,不会产生"惊群效应"——每个客户端只监听自己前面一个节点,锁释放的时候,只有下一个客户端会被唤醒,不会所有等待的客户端都被唤醒。
这个场景,我也用了很多次。分布式锁看起来简单,但是有很多细节需要注意,比如会话过期、锁的超时、业务执行时间过长等,后面会详细说踩过的坑。
3. 配置管理
配置管理,也是ZooKeeper的一个常见使用场景。在分布式系统中,配置可能需要动态更新,而且所有的服务实例都需要看到一致的配置。
用ZooKeeper做配置管理的基本思路是:
- 把配置数据存储在ZooKeeper的某个节点上(比如/config/xxx-config)。
- 服务启动的时候,读取这个节点的数据,加载配置。
- 服务同时监听这个节点的变化,当配置更新(节点数据变化)的时候,ZooKeeper会通知服务,服务重新加载配置。
这样,配置的更新是实时的、一致的,所有的服务实例都能在很短的时间内看到最新的配置,不需要重启服务。
这个场景,我也用过,效果不错。但是,要注意配置的数据量不能太大,因为ZooKeeper不适合存储大量数据。
4. Master选举
在分布式系统中,有时候需要从多个节点中选举出一个Master,负责协调和管理整个集群。比如,Hadoop的NameNode、HBase的HMaster、Kafka的Controller等,都需要Master选举。
用ZooKeeper实现Master选举的基本思路是:
- 所有节点启动的时候,都尝试在ZooKeeper的某个目录下(比如/masters/xxx)创建一个临时节点。
- 谁创建成功了,谁就是Master。
- 其他节点创建失败,说明已经有Master了,它们监听这个临时节点的删除事件。
- 当Master宕机的时候,它的临时节点会自动删除,其他节点收到通知,再次尝试创建临时节点,谁创建成功谁就是新的Master。
这个场景,我在一个分布式任务调度系统中用过,效果不错。ZooKeeper的临时节点机制,非常适合做Master选举,因为Master宕机的时候,临时节点会自动删除,触发重新选举,不需要额外的心跳和检测机制。
5. 分布式队列
分布式队列,是一个比较少见的使用场景。用ZooKeeper可以实现先进先出(FIFO)的分布式队列。
基本思路是:
- 入队的时候,在队列目录下创建一个临时顺序节点。
- 出队的时候,获取队列目录下的所有子节点,取序号最小的那个节点,读取它的数据,然后删除它。
这个场景,我只在一个项目中用过,而且后来换成了消息队列(Kafka)。因为ZooKeeper不适合做高吞吐量的队列,它的设计目标是协调服务,不是消息队列。如果需要高吞吐量的队列,还是用专门的消息队列比较好。
三、踩过的那些坑
用了三年ZooKeeper,踩过很多坑。下面分享几个让我印象最深刻的坑:
坑1:会话过期(Session Expired)导致的问题
这是我踩过的最频繁、最严重的坑。
ZooKeeper的客户端和服务器之间,维护着一个会话(Session)。会话有超时时间,如果客户端在超时时间内没有和服务器进行有效通信(心跳或者请求),服务器就会认为会话过期,会删除这个会话创建的所有临时节点,并且关闭会话。
最开始,我对会话过期的认识不足,踩了很多坑:
- 服务发现场景:服务提供者因为网络抖动或者GC停顿,和ZooKeeper的会话过期了,临时节点被删除,服务消费者以为服务下线了,就不再调用这个服务了。但是实际上,服务提供者还在正常运行,只是和ZooKeeper的会话断了。结果,服务还在,但是流量没了,服务"被下线"了。
- 分布式锁场景:客户端获取到了锁,但是因为业务执行时间太长,或者GC停顿,导致会话过期,临时节点被删除,锁被释放了。这时候,另一个客户端获取到了锁,两个客户端同时执行业务逻辑,锁失效了,导致数据不一致。
- Master选举场景:Master因为会话过期,临时节点被删除,触发了重新选举,选出了新的Master。但是旧的Master还在运行,它以为自己还是Master,继续执行Master的逻辑。结果,出现了"双Master"的情况,两个Master同时在协调,导致整个集群混乱。
这些问题,都是因为会话过期导致的。后来,我总结了几条经验:
- 合理设置会话超时时间:不要设置得太短,否则稍微有点网络抖动或者GC停顿就会会话过期;也不要设置得太长,否则真正的节点宕机不能及时感知。一般设置在30秒到2分钟之间比较合适。
- 处理会话过期事件:客户端要监听会话过期事件,当会话过期的时候,要做相应的处理。比如,服务提供者要重新注册服务,分布式锁的持有者要停止执行业务逻辑,Master要降级为Follower。
- 不要在持有锁的时候执行长时间业务:分布式锁的持有时间要尽量短,不要在持有锁的时候执行长时间的业务逻辑,否则容易因为会话过期导致锁失效。
- 处理GC停顿:Java应用的Full GC可能导致长时间停顿,导致会话过期。要合理设置JVM参数,避免长时间的GC停顿。
坑2:Watcher是一次性的
这是另一个常见的坑。ZooKeeper的Watcher(监听器)是一次性的,也就是说,一个Watcher一旦被触发,就会被移除,如果还想继续监听,需要重新注册Watcher。
最开始,我以为Watcher是持续的,注册一次就会一直监听。结果,第一次节点变化的时候,Watcher触发了,我处理了变化,但是没有重新注册Watcher。后来,节点又变化了,我没有收到通知,导致数据不一致。
比如,在服务发现场景中,我监听了服务目录的子节点变化。第一次有服务上线,Watcher触发了,我更新了本地的服务列表,但是没有重新注册Watcher。后来,又有服务下线了,我没有收到通知,本地的服务列表没有更新,还在调用已经下线的服务,导致调用失败。
后来,我养成了一个习惯:每次Watcher触发之后,处理完变化,立即重新注册Watcher。而且,要注意在重新注册Watcher的过程中,可能会有节点变化,这时候需要重新读取一次节点数据,确保不会漏掉变化。
比如,正确的服务发现监听流程应该是:
- 读取服务目录的子节点列表,更新本地服务列表。
- 注册Watcher,监听服务目录的子节点变化。
- 当Watcher触发的时候,再次读取服务目录的子节点列表,更新本地服务列表。
- 再次注册Watcher,继续监听。
- 重复3-4。
这样,就不会漏掉任何变化了。
坑3:羊群效应(Herd Effect)
在分布式锁的实现中,如果实现得不好,会产生"羊群效应"。
最开始,我实现分布式锁的时候,用的是最简单的方式:所有等待锁的客户端,都监听锁节点的删除事件。当锁被释放的时候,所有等待的客户端都会被唤醒,然后同时尝试获取锁,但是只有一个能获取到,其他的又要继续等待。
这种方式,在等待的客户端很多的时候,会产生"羊群效应"——锁释放的时候,成百上千的客户端同时被唤醒,同时尝试获取锁,给ZooKeeper造成很大的压力,甚至可能导致ZooKeeper集群性能下降或者不可用。
后来,我改用了"临时顺序节点"的方式,每个客户端只监听自己前面一个节点的删除事件。这样,锁释放的时候,只有下一个客户端会被唤醒,不会产生羊群效应。
这个教训让我明白,用ZooKeeper实现分布式锁,一定要用临时顺序节点的方式,不要用所有客户端监听同一个节点的方式。
坑4:ZooKeeper不适合存储大量数据
最开始,我以为ZooKeeper可以当数据库用,什么数据都往里面存。结果,有一次,我把一个几MB的配置文件存到了ZooKeeper的节点里,导致ZooKeeper的性能严重下降,甚至出现了超时。
后来我才明白,ZooKeeper的设计目标是协调服务,不是数据存储。它的每个节点的数据量,默认最大是1MB(可以通过jute.maxbuffer参数调整,但是不建议调太大)。而且,ZooKeeper的数据是加载在内存里的,如果存储大量数据,会占用大量内存,影响性能。
所以,ZooKeeper只适合存储少量的、协调用的数据,比如服务地址、配置信息、锁节点、选举节点等。如果需要存储大量数据,应该用专门的数据库或者存储系统。
而且,ZooKeeper的节点数量也不能太多。如果节点数量太多(比如几十万个),也会影响性能。如果需要大量节点,要考虑分区或者用其他系统。
坑5:集群部署的坑
ZooKeeper集群的部署,也有很多坑。
最开始,我部署ZooKeeper集群的时候,以为节点越多越好,就部署了7个节点。结果,发现7个节点的性能,反而不如5个节点。因为,ZooKeeper的写操作,需要超过半数的节点确认才能提交。节点越多,需要确认的节点越多,写操作的延迟就越高。
后来我才明白,ZooKeeper集群的节点数,不是越多越好,而是要根据实际需求选择。一般来说,3个节点就能容忍1个节点故障,5个节点能容忍2个节点故障,7个节点能容忍3个节点故障。对于大部分场景,3个或者5个节点就足够了,不需要部署太多节点。
还有一个坑,是集群节点的物理部署。最开始,我把ZooKeeper集群的所有节点都部署在同一台物理机上,以为这样性能好。结果,那台物理机宕机了,整个ZooKeeper集群都不可用了。后来,我把集群节点部署在不同的物理机上,甚至不同的机架上,避免单点故障。
还有,ZooKeeper集群的节点数一定要是奇数。因为,ZooKeeper需要超过半数的节点存活才能正常工作。如果是偶数个节点(比如4个),需要超过半数(3个)存活才能工作,能容忍1个节点故障,和3个节点的容忍度一样,但是多了一个节点的成本。所以,ZooKeeper集群一般用奇数个节点。
坑6:客户端连接的坑
ZooKeeper客户端的连接,也有一些坑。
最开始,我在客户端配置ZooKeeper连接地址的时候,只写了一个节点的地址。结果,那个节点宕机了,客户端就连接不上了,整个系统都不可用了。后来,我把集群所有节点的地址都写在了连接字符串里,客户端会自动尝试连接不同的节点,一个节点连不上会自动连下一个,避免了单点故障。
还有,客户端的重连机制也很重要。当客户端和ZooKeeper的连接断开的时候,客户端会自动尝试重连。但是,在重连的过程中,会话可能会过期,临时节点可能会被删除。所以,客户端要处理连接状态的变化,在连接断开、重新连接、会话过期等事件发生的时候,做相应的处理。
还有,不要在一个进程里创建太多的ZooKeeper客户端连接。每个客户端连接都会占用资源,而且ZooKeeper服务器对客户端连接数也有限制。一般来说,一个进程里创建一个ZooKeeper客户端连接就够了,所有的操作都复用这个连接。
四、性能优化的经验
除了踩坑,我也积累了一些ZooKeeper性能优化的经验:
1. 合理设置事务日志和数据快照的存储位置
ZooKeeper的性能,很大程度上取决于磁盘I/O。ZooKeeper的事务日志(transaction log)和数据快照(snapshot),都需要写入磁盘。如果磁盘I/O慢,会严重影响ZooKeeper的性能。
优化建议:
- 把事务日志和数据快照放在不同的磁盘上,避免I/O竞争。
- 用性能好的磁盘(比如SSD),特别是事务日志的磁盘。
- 合理设置数据快照的间隔(autopurge.snapRetainCount和autopurge.purgeInterval),定期清理旧的快照和事务日志,避免磁盘空间被占满。
2. 合理设置JVM参数
ZooKeeper是Java应用,JVM的参数设置对性能影响很大。
优化建议:
- 堆内存不要设置得太大,也不要太小。太大了会导致GC停顿时间长,太小了会导致频繁GC。一般来说,几个GB的堆内存就足够了。
- 用CMS或者G1垃圾收集器,减少GC停顿时间。
- 合理设置新生代和老年代的比例,避免频繁的Full GC。
3. 客户端的优化
客户端的使用方式,也会影响ZooKeeper的性能。
优化建议:
- 减少不必要的Watcher注册。每个Watcher都会占用资源,不要注册太多不需要的Watcher。
- 批量操作。如果有多个操作,可以用multi接口批量执行,减少网络往返。
- 合理设置会话超时时间和连接超时时间。
- 复用客户端连接,不要频繁创建和销毁连接。
4. 读操作的优化
ZooKeeper的读操作,可以直接从Follower节点读取,不需要经过Leader。这样可以减轻Leader的压力,提高读操作的吞吐量。
但是,从Follower读取的数据,可能不是最新的(因为Follower的数据可能有延迟)。如果对数据的一致性要求很高,需要从Leader读取,可以在读取的时候设置sync标志。
所以,要根据业务需求,选择合适的读取方式。如果对一致性要求不高,可以从Follower读取,提高性能;如果对一致性要求很高,从Leader读取,保证一致性。
五、什么时候该用ZooKeeper,什么时候不该用
用了三年ZooKeeper,我最大的体会是:ZooKeeper是一个很强大的协调服务,但是它不是万能的,不是什么场景都适合用。
适合用ZooKeeper的场景:
- 服务发现:服务的注册和发现,特别是服务实例动态变化的场景。
- 分布式锁:需要分布式互斥的场景。
- 配置管理:需要动态更新、一致的配置管理。
- Master选举:需要从多个节点中选举Master的场景。
- 命名服务:需要统一的命名和寻址的场景。
- 分布式协调:需要分布式屏障、队列等协调原语的场景。
这些场景,都是ZooKeeper的设计目标,用ZooKeeper来做,效果很好。
不适合用ZooKeeper的场景:
- 消息队列:ZooKeeper不适合做高吞吐量的消息队列。如果需要消息队列,用Kafka、RabbitMQ等专门的消息队列。
- 数据存储:ZooKeeper不适合存储大量数据。如果需要存储大量数据,用数据库或者对象存储。
- 缓存:ZooKeeper不适合做缓存。如果需要缓存,用Redis、Memcached等专门的缓存系统。
- 高吞吐量的写操作:ZooKeeper的写操作吞吐量有限,因为所有写操作都要经过Leader,而且需要超过半数的节点确认。如果需要高吞吐量的写操作,用其他系统。
- 复杂查询:ZooKeeper只支持按路径查询,不支持复杂的查询条件。如果需要复杂查询,用数据库。
用ZooKeeper之前,要先想清楚,你的场景是不是ZooKeeper擅长的。如果不是,就不要勉强用ZooKeeper,否则会遇到很多性能和功能上的问题。
而且,近年来,也出现了一些ZooKeeper的替代方案。比如,etcd(用Go语言写的,更轻量,HTTP API,更适合云原生场景)、Consul(集成了服务发现、健康检查、KV存储等功能)等。在选择的时候,可以根据自己的需求,对比一下这些方案,选择最适合的。
六、写在最后
用了三年ZooKeeper,踩了很多坑,也积累了很多经验。ZooKeeper,是一个很强大的分布式协调服务,但是它也有很多坑,很多细节,如果不注意,就会出问题。
我最大的体会是:用ZooKeeper,不能只停留在"会用"的层面,要深入理解它的原理和机制,知道它的优点和缺点,知道什么场景适合用,什么场景不适合用,知道常见的坑在哪里,怎么避免。只有这样,才能真正用好ZooKeeper,让它为你的分布式系统提供稳定、可靠的协调服务。
而且,分布式系统的协调,是一个很复杂的问题。ZooKeeper只是提供了一些基础的协调原语,具体的业务逻辑,还需要你自己去设计和实现。在设计的时候,要充分考虑各种异常情况(网络分区、节点宕机、会话过期、消息丢失等),确保系统在各种异常情况下都能正确运行。
最后,用一句话来总结这三年使用ZooKeeper的感受:"ZooKeeper,入门容易,精通难。它是分布式协调的利器,但是用不好也会成为坑。只有深入理解它的原理,避开常见的坑,才能真正发挥它的威力,让你的分布式系统更稳定、更可靠。"
愿每一个用ZooKeeper的朋友,都能少踩坑,多用好这个强大的工具,构建出稳定、可靠的分布式系统。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录