1. 先搞清楚一个反直觉的问题:ZooKeeper 到底卡在读还是卡在写?
1.1 读写路径的真相:读不走共识,写必须走共识
老有人问我:“ZooKeeper 性能瓶颈不都在写吗?搞读扩展有什么意义?”这个想法对了一半,但恰恰会让人错过 ZooKeeper 集群调优里最值钱的一块。
先说写。ZooKeeper 的写请求无论客户端连到哪个节点,最终都会被转发给 Leader,然后 Leader 向所有参与投票的 Follower 广播提案,等收到过半 ACK 之后才 Commit。这一整套过程依赖 Zab 协议,走的是共识路径,延迟对网络和磁盘同步都很敏感。
再说读。读请求完全不走共识。客户端连上任意一个节点,这个节点直接用本地内存中的数据副本把结果返回给客户端。没有网络交互、没有投票、没有 Leader 参与。也就是说,读操作的极限吞吐量基本由“单个节点自身的内存读取能力 + 网络吞吐”决定。
问题恰恰出在这里:写虽然慢,但它在一个没写压力的系统里根本不是瓶颈;读虽然快,但读流量一大,单节点分分钟被打满。很多业务的 ZooKeeper 集群长年累月没有多少写入量,但有几百上千个业务实例在启动时同时读配置、听 Watcher,这种场景下你会眼睁睁看着某一台节点的 CPU 冲到 90% 以上,而整个集群的写路径一点事都没有。
这时候直觉会告诉你:加节点,把读请求分散开。
1.2 投票节点越多,写路径越容易“钝”
那加 Follower 行不行?行,但有代价。Follower 是参与选举和投票的参与节点,每加一个 Follower,共识组的规模就变大一圈。Zab 协议的投票算法虽然只等多数派 ACK,并不需要等所有人,但集群节点越多,Leader 需要维护的会话、事务日志同步、心跳检测就越多,网络开销和选举耗时有明显增长。
有一个很经典的教训:早年我接触过一套集群,运维为了扛住读流量从 3 个节点加到 7 个节点,读是稳了,但后来某次机房抖动触发重新选举,7 个节点的选举耗时比 3 节点多了接近一倍。业务那边对写链路要求极其敏感,直接炸了。
这个矛盾的本质是:读容量和写性能在同一个资源池里抢位置。你要么多花钱堆机器,要么寻找一种“只出力、不站队”的节点形态。
ZooKeeper 早就给了答案:观察者节点(Observer)。它的定位就是被设计用来干这件事的——不参与投票、不参与选举,只同步数据、提供本地读服务。下面我会把这个角色的底层机制、配置方法、性能边界和踩坑经验一次说透。
2. 观察者节点的定位:一个只干活不投票的成员
2.1 它在 Zab 协议里扮演的角色
观察者节点的是机制要追溯到 ZooKeeper 3.4.0 引入 Learner 模型。官方文档里对 Observer 的定义很直接:不参与 Leader 选举投票,不参与写请求的提交投票,只作为学习节点从 Leader 接收数据变更并应用于本地状态。
放到 Zab 协议里,Observer 处于非常特殊的生态位:
- 它不是候选者,永远不会被选举为 Leader;
- 它没有投票权,任何集群变更的计算都不需要等它点头;
- 它照样维护完整的 znode 树、Session、Watcher,客户端连上来一切功能正常;
- 它还是会转发写请求给 Leader,只是自己不参与后续的提案确认环节。
这种设计在运行机制上让我想起“读副本”的概念,但它比读副本更轻,因为 ZooKeeper 本身不需要像数据库那样做 binlog 回放或者 MVCC,Observer 只需要把 Leader 的提交记录按顺序往本地状态机上套即可。
2.2 数据同步路径:Observer 是怎么拿到最新数据的
Observer 与 Leader 之间的数据同步走的是和 Follower 相同的 LearnerHandler 通道。Observer 启动并完成 Leader 发现后,会向 Leader 发起 FOLLOWERINFO 请求。Leader 测出它与最新提交位置之间的差距,如果差距不算大,就通过增量事务日志同步(DIFF);如果差距过大,比如 Log 已经被清理或者 Observer 长时间离线,Leader 会直接发快照(SNAP),之后继续追加增量。
关键区别在提交确认环节。Follower 收到提案后,需要落盘并向 Leader 返回 ACK;Observer 不落盘提案、不返回 ACK,只在收到 Leader 的 commit 消息后,像“追剧”一样把事务应用到本地 DataTree。这意味着 Observer 的数据更新天然存在一个异步窗口:某个写请求已经 Commit 成功并返回客户端了,但 Observer 可能还差那么几个事务没追上。
这么说吧,Follower 相当于“大家一起拍板,之后各自干活”,Observer 相当于“你们拍板,我只看结果”。所以它非常轻,但代价是你得接受读到的版本可能不是最新。
2.3 为什么加 Observer 不会拖慢写
这个问题我在前司分享的时候用一句话解释过:写路径的耗时取决于“Leader 发起提案后,等多数派 ACK 的时间”。在 ZooKeeper 的设计里,只有参与投票的 Follower 才会占用这个等待窗口。Observer 不在多数派里,所以它的存在对写路径的时延和非功能性损耗都趋近于零。
加 Follower 就像开会多拉了一个必须表态的参会人,人数越多,达成共识越慢。加 Observer 就像会议室后面多坐了一个旁听的,他可以看资料、可以做笔记,但不参与表决,会议该多久还是多久。
这也是观察者节点在处理读扩展时非常有价值的核心原因:你可以横向加一堆 Observer,把读流量摊出去,而不用提升写路径的成本。前提是控制好 Observer 与 Leader 之间的同步网络质量。
3. 什么场景下观察者节点真的值钱
3.1 跨机房部署场景
一到跨机房,Follower 的问题立刻变得很碍眼。假设你有两个机房 A 和 B,为了容灾在 A 部署两个节点、B 部署两个节点,加上 Leader 在 A,一共五节点。任何跨机房的写请求都要等四个 Follower 里的多数派 ACK,B 机房两个节点只要网络抖动一下,写延迟就能翻几倍。
这时把 B 机房的节点改成 Observer 就合理了:读流量留在 B 本地,写流量还是要发往 A 机房的 Leader 处理,但异步窗口对 B 机房读业务的瞬时可用性影响并不大。这本质上是在“读的本地性”和“写的跨机房一致性要求”之间取得平衡。
但别忘了一件事:Office 和部署在 B 机房的 Observer 之间的网络同样重要。如果 A 到 B 的专线丢包率偏高,Observer 落后太多,业务层面就要能容忍一定的数据延迟。
3.2 低频写高频读业务
ZooKeeper 最常见的两类使用方式是分布式锁和注册中心。分布式锁偏写,注册中心偏读。很多公司把微服务注册信息全部放进 ZooKeeper,每个服务实例启动时拉一遍全量服务列表,运行期还要维持大量 Watcher 感知上下线。这种场景下集群写量低,但读量和长连接数可以高得惊人。
我遇到过一个真实业务:配置中心只在发布的时候改几十个节点配置,平时完全无写,但每次发布都有一百多个应用实例同时去读,导致一个四节点集群的读 CPU 飙到 70% 以上。后来加了两个 Observer,把客户端读流量按权重分流过去,CPU 峰值直接降到了安全水位,而且整个改动只花了一个变更窗口。
这类场景适合观察者节点的本质原因在于:读流量对节点内存状态机的压力是可叠加的,写压力却受共识机制约束。观察者节点就是为“把读资源从共识组里剥离出来”而生的。
3.3 什么时候该用 Observer 而不是加 Follower
我一直建议团队在做容量规划时先建立一个判断框架。如果你的 ZooKeeper 集群面临的核心矛盾是“读吞吐不够、连接数被打满”,而写路径延迟健康有余量,那么优先考虑 Observer。如果集群整体参与节点的数量已经偏多,比如超过七个,或者写请求经常出现网络分区导致的延迟抖动,那你更应该慎重加 Follower,甚至在考虑把一部分 Follower 降级为 Observer。
相反,如果是以下情况,Observer 帮不了你:
- 瓶颈在写吞吐,加 Observer 改不了 Leader 的单点写能力;
- 集群需要更强的容错性而不是读性能;
- 业务要求所有客户端读取到的数据强一致,Observer 的异步窗口无法满足。
这些情况该考虑的是拆分集群、引入其他存储方案或者调整业务模型,不要把 Observer 当成万能膏药。
4. 从配置到上线:观察者节点的正确打开方式
4.1 单机伪集群验证配置
在动生产环境之前,我强烈建议先在本地用单机伪集群把配置跑通。所谓伪集群,就是在一台机器上起多个 ZooKeeper 进程,用不同端口区分。
假设要构建一个包含 1 个 Leader、1 个 Follower、1 个 Observer 的最小集群。三个目录分别生成 myid 文件,内容分别为 1、2、3。每个节点的 zoo.cfg 长这样:
节点一:
tickTime=2000 initLimit=10 syncLimit=5 dataDir=/data/zk1 clientPort=2181 server.1=127.0.0.1:2888:3888 server.2=127.0.0.1:2889:3889 server.3=127.0.0.1:2890:3890:observer节点二:
clientPort=2182 dataDir=/data/zk2 server.1=127.0.0.1:2888:3888 server.2=127.0.0.1:2889:3889 server.3=127.0.0.1:2890:3890:observer节点三除了端口和路径不同,配置里必须多一行peerType=observer,或者继续沿用 server.3 声明列表里的:observer后缀。
这里得解释一下两套写法的区别:server.X=host:port1:port2:observer是“告诉所有节点,X 号节点是观察者”,需要出现在所有节点的配置里,尤其是 Leader/Follower 需要知道它的存在;peerType=observer是“告诉本节点自己是观察者”,只放在观察者节点的配置里就够了。实际生产我习惯两种都写,防止某个节点配置缺失导致整个集群的成员身份认知不一致。
4.2 生产环境节点配置注意事项
伪集群跑通后,生产环境部署要额外注意三个细节。
第一,端口分配。ZooKeeper 每个节点需要两个集群通信端口:一个用于 Leader 与 Learner 之间的数据同步(默认 2888),另一个用于投票选举(默认 3888)。Observer 虽然不投票,但选举端口依然要保留,因为 Learner 需要通过这个端口和 Leader 建立初始连接。如果机器上有防火墙,记得把两个端口都放通,不然会出现节点能互相 ping 通但就是无法建立集群的诡异问题。
第二,Observer 节点的数据目录建议单独做持久化磁盘,不要放在系统盘。因为当 Observer 落后太多触发全量快照时,会一口气从 Leader 拉取整个数据树,瞬间写磁盘。如果磁盘 IO 能力太弱,快照同步会拖很久,造成“追不上又继续落后”的恶性循环。
第三,配置变更后需要滚动重启集群。修改 zoo.cfg 不会热生效。顺序上建议先重启 Observer,再重启 Follower,最后重启 Leader,或者利用滚动重启窗口逐个重启,避免同时宕掉多个参与节点导致法定人数不足。
4.3 用四字命令验证节点身份
配置完先别急着接流量,用四字命令确认节点身份是对的。ZooKeeper 原生支持通过nc发送四字命令来获取运行时状态:
echo stat | nc localhost 2181输出里会明确写着Mode: observer或Mode: leader/Mode: follower。如果观察到 Mode 还是 follower,多半是配置文件没有更新,或者server.X声明里的:observer后缀漏写了。
注意从 ZooKeeper 3.5 开始,四字命令默认是白名单模式。如果执行stat没有输出,需要在 zoo.cfg 里显式配置:
4lw.commands.whitelist=stat,conf,ruok,srvr如果忘了加白名单,你可能会在一台新装节点上排查半天发现命令根本发不进去。
5. 读扩展收益实测:性能数据的合理预期
5.1 一组典型的压测方案
数据永远是让人信服的最好方式。我拿了一个内部环境做参考:三台 8C16G 云主机组成 Leader + 两个 Follower,再用一台同样规格的机器作为 Observer。客户端使用官方 Java SDK 的异步接口,持续发起getData读请求,记录 QPS 和延迟分布。
压测模型很简单:
- 阶段一:三个参与节点均摊读流量,记录基准数据;
- 阶段二:把一半读流量切换到 Observer,剩余留在原节点;
- 阶段三:全部读流量打到 Observer,观察延迟和 QPS 变化。
每组压测持续 15 分钟,观察稳定后的中位数和 P99 延迟。为了避免 Watcher 通知和 session 建立对数据的干扰,客户端在压测前就预先建立好连接,压测期间不做 watch 注册。
5.2 不要被绝对数值带偏,看趋势
说结论前先打个预防针:ZooKeeper 的读性能受硬件、JVM 堆内存、客户端连接数影响太大,直接比绝对数值没有意义。我这次压测只是验证两个趋势:
第一,读 QPS 并不随节点数线性增长,但分散流量后单节点 CPU 明显下降。原本三个节点每台 CPU 在 60% 到 75% 之间徘徊,流量分流到 Observer 后,每个节点 CPU 回落到 25% 左右,相当于给系统预留了巨大的突发流量缓冲空间。
第二,延迟中位数基本不变,P99 在流量切换的瞬间有轻微上扬。原因不难理解:流量切换会导致部分客户端重新建立连接、缓存热度的变化,随后又回归稳定。Observer 本地读的本质和高性能读节点没有区别,只要不触发 GC 抖动,延迟不会因为换了角色而明显恶化。
还有一个比较容易被忽略的收益:所有 Observer 读流量都从 Leader 和 Follower 的负载中剥离后,写请求反而更稳了。因为 Leader 和 Follower 的网络和 CPU 资源被释放出来,写提案的 ACK 交互延迟更稳定,P99 写延迟在压测期间不升反降。这印证了前面说的:读流量侵占资源,最终会反噬写路径。
6. 读一致性边界:Observer 读到的数据新不新鲜?
6.1 为什么会有过期读
这个点我必须单独拿出来讲,因为它最容易引发事故。
ZooKeeper 客户端连接任意节点,读请求都由该节点的本地 DataTree 直接响应。Follower 和 Observer 都采用异步方式从 Leader 同步提交记录,因此严格来说,任何非 Leader 节点的本地读都有“可能读到旧值”的窗口。
Observer 因为不参与提案确认,这个窗口理论上比 Follower 更宽。我实测过正常网络环境下,Observer 和 Leader 之间的数据差异往往只有几毫秒到几十毫秒。但如果网络抖动或者事务量大,这个差异有可能被拉大。
很多人只看到“读扩展”就默认读到的数据一定是准确的,这是非常危险的误解。举个实际场景:分布式锁被释放后,业务方在很短的时间内重新读锁节点,如果请求落到了一个还没来得及同步释放事务的 Observer 上,就会误以为锁仍被占用。这种偶发问题排查起来极其隐蔽。
6.2 客户端侧如何规避
ZooKeeper 从 3.4.6 开始提供了sync()原语,它能强制客户端连接的节点与 Leader 的数据同步到调用时间点。在需要强一致读的关键路径上,可以先sync再执行getData。这会引入一次额外的往返,但比每次写请求都从 Leader 走一遍共识要便宜得多。
如果业务上有大量“写完立刻读”的场景,比如创建临时节点后马上检查它是否对别的客户端可见,建议把这类强一致读流量直接固定到 Leader。客户端 SDK 里可以设置server.x=host:port的优先级,或者干脆在业务代码里通过专用的连接池连接 Leader 地址。
如果业务绝大多数是读多写少且对秒级延迟不敏感,比如服务注册列表的拉取,那连 Observer 是没问题的,但心里要有数:注册列表可能不是最新状态,服务的上下线感知会有几十毫秒到几百毫秒的滞后。这取决于你的服务发现机制能不能容忍。
需要说明的是,Watcher 事件同样存在这个窗口。ZooKeeper 保证 Watcher 事件的触发顺序与事务提交顺序一致,但如果事件发生在 Leader 而客户端连接在 Observer,客户端会晚一步收到通知。如果你的业务有“必须第一时间感知节点变化”的强需求,读链路就不能全走 Observer,需要做分流。
7. 生产环境里那些坑(以及我怎么填的)
7.1 坑一:Observer 长时间离线后,重启变成“追日志的煎熬”
有一次我增加了一台 Observer 后,发现它重启时日志一直停留在TRACKING状态,事务差异始终追不上。后来查出来两个原因叠加:一是 Leader 端autopurge.snapRetainCount设置得太小,历史事务日志被清理得只剩最近几份;二是这台 Observer 因为网络隔离离线了半天,等到恢复时,Leader 已经没有它需要的完整日志段了。
这时候 ZooKeeper 只能切换为快照同步,把整份 DataTree 从 Leader 拉过来。如果集群数据量很大,这个过程慢不说,还会拉高 Leader 的磁盘和网络 IO,影响正常服务。
解法有两层。第一层是配置层面:调大autopurge.snapRetainCount和autopurge.purgeInterval,避免日志被过早清理;为 Observer 所在机器配置足够的磁盘空间。第二层是运维层面:Observer 长期离线后,不要直接接入生产流量,先观察它是否完成了快照同步,确认stat输出的 Zxid 与 Leader 差距收敛到合理范围再接流量。
7.2 坑二:动态 reconfig 导致 Observer 身份“漂移”
ZooKeeper 3.5 之后支持动态成员变更,通过reconfig命令可以替换节点、修改端口。这确实方便,但也埋过雷。有一次我用 reconfig 在线替换一台 Observer 的地址,写完新的成员列表后,这台节点重启时日志里显示的模式变成了 follower。
排查后发现根源是 reconfig 生成的成员配置里,server.X=host:2888:3888末尾没有保留:observer后缀。节点读到自己没有 observer 身份声明,本地也没配peerType=observer,自然就落回了参与投票模式。更危险的是,如果这种身份漂移发生在集群运行中,会导致法定人数计算模型改变,极端情况下可能破坏 Leader 选举的安全属性。
现在我的处理原则很简单:只要节点是 Observer,就同时在 zoo.cfg 的server.X声明里写:observer,并且在节点自己的配置里加peerType=observer。双重保险,任何一侧缺失都不会影响身份判断。涉及 reconfig 操作的模板脚本也会显式校验成员字符串末尾是否带:observer。
7.3 坑三:读流量无脑打满 Observer,引入单点新瓶颈
还有一次压测时,我把绝大部分读流量都引到一台 Observer 上,结果这台节点的网卡和线程池先扛不住了,客户端大面积超时。回头看,这其实不是 Observer 的问题,是我把流量分配策略设计错了。
Observer 的作用是“分担读压力”,不是“承接所有读压力”。它本质上还是一个普通节点,受 CPU、内存、文件描述符和网络带宽约束。如果你的集群只有一台 Observer,把它当成读流量的唯一入口,等于把多个节点的性能压力集中到单点上,还不如原来的分散方案稳健。
合理的做法是至少部署两个 Observer,并使用客户端侧的负载均衡策略按权重分发,给每个节点保留 30% 以上的容量冗余。同时要监控每个节点的文件描述符数量和连接数,ZooKeeper 对单连接的处理模型虽然轻量,但太多连接一样会让线程调度成为瓶颈。读扩展的是集群整体能力,不是让某一个旁听节点变成新的靶子。
7.4 坑四:把 Observer 当“读高可用”节点,忽略容灾角色
最后提醒一点,Observer 再轻巧,它也不属于投票法定人数的一部分。如果集群中所有参与节点都宕机了,只剩 Observer,集群照样不可用,因为 Observer 无法参与选举,无法产生新的 Leader,整个 ZooKeeper 服务对外表现为拒绝服务。
所以在规划容灾时,千万别把 Observer 数量算进可用性容量里。一个 3 Participant + 2 Observer 的集群,容灾能力依然是“允许挂掉 1 个 Participant”,跟 3 节点的容灾级别一致。如果需要提升容灾能力,正确的做法是增加参与节点,而不是观察者。
这也是为什么我一直建议团队用“观察者节点做读扩展,用参与节点做容灾计算”来区分两类资源的不同价值。读扩展让你活得轻松,容灾能力让你活得安稳,两者各司其职。