1. 读写请求的一次完整旅程
1.1 从客户端视角梳理请求生命周期
先抛一个问题:当你用客户端对 ZooKeeper 执行一次getData()或者setData()时,这个请求从发出到拿到结果,中间到底发生了什么?
很多同学背过八股,知道"写请求走 Leader,读请求随便哪台都能处理",但再往下追问一句——为什么写请求必须经过 Leader?为什么读请求看似"随便"却有时候读到旧数据?请求在服务端内部又是怎么被一步步拆解、落盘、广播、提交的?能讲清楚的人就不多了。
这篇文章我打算把 ZooKeeper 读写请求的完整链路拆开揉碎,从客户端发起、网络层接入、处理器链流转、事务日志落盘、ZAB 协议提交,一直到响应返回,把每个环节的职责边界和设计原因都说清楚。
适合谁看?两类人:一是正准备深入 ZooKeeper 源码,却被PrepRequestProcessor、SyncRequestProcessor、FinalRequestProcessor这些类名绕晕的读者;二是生产环境里已经用上 ZooKeeper,遇到"写超时""读到旧数据""磁盘 IO 飙高"等问题但不知道怎么定位的运维或后端开发。读完你至少能建立起一条完整的请求链路地图,遇到问题知道往哪个环节排查。
先说一个大前提:ZooKeeper 的读写请求并不是对称的。读请求可以在任意节点本地处理,写请求则必须经过 Leader 协调并完成持久化后再返回。这个不对称设计决定了整个 ZooKeeper 的性能特征和一致性模型,后面所有细节都围绕它展开。
1.2 单机处理链与选主机制的分工边界
ZooKeeper 集群里每个节点都运行着同一套请求处理逻辑,但角色不同(Leader / Follower / Observer)时,请求的流转路径会分叉。
在进入处理器链之前,所有请求都会先经过NettyServerCnxn或NIOServerCnxn这一层网络接入,完成反序列化,封装成Request对象。这个对象里有两个关键字段:sessionId和zxid。sessionId标识客户端会话,zxid则是请求在 ZooKeeper 全局事务顺序中的编号——注意,读请求在没有真正处理之前是没有zxid的,只有写请求被 Leader 分配后才会获得一个全局递增的zxid。
从职责边界来说,ZAB(ZooKeeper Atomic Broadcast)协议只负责一件事:保证写请求的全局顺序和故障恢复时的数据一致性。读请求压根不进入 ZAB 的提交流程,它只是在本地的DataTree上做一次查询,然后返回结果。
所以你可以这样理解:ZooKeeper 的单机处理链是一条生产流水线,写请求是"需要质检、盖章、存档"的正式文件,读请求则是"看一眼仓库库存"的临时查询。前者要过全套流程,后者只查不写。
这个分工在源码里体现得非常清晰:写请求会依次经过PrepRequestProcessor(预处理器,负责校验和分配 zxid)、ProposalRequestProcessor(提案处理器,发起广播)、SyncRequestProcessor(同步处理器,落盘事务日志)、AckRequestProcessor(应答处理器,Leader 自己回 Ack)、CommitRequestProcessor(提交处理器,通知所有人应用内存)、FinalRequestProcessor(最终处理器,真正操作 DataTree 并响应),整整六七个环节;而读请求在绝大多数实现里,直接在FinalRequestProcessor里完成DataTree查询后就可以返回了,中间只经过很少的预处理环节。
这两条链路的分工,看下面的图会更直观:
客户端请求 │ ├── 读请求(getData / getChildren / exists) │ └── 直接进入 FinalRequestProcessor 查询 DataTree 本地数据并返回 │ └── 写请求(create / delete / setData / setACL / multi) ├── PrepRequestProcessor:校验、ACL 检查、分配 zxid ├── ProposalRequestProcessor:向所有 Followers 广播 Proposal ├── SyncRequestProcessor:Leader 本地持久化事务日志 ├── AckRequestProcessor:Leader 给自己回 Ack(进入投票统计) ├── CommitRequestProcessor:收到多数派 Ack 后广播 Commit └── FinalRequestProcessor:应用内存变更、触发 Watcher、返回响应注意这里我只画了单台节点视角。实际上在广播阶段,Follower 也有一套自己的处理链来处理 Leader 发来的 Proposal 和 Commit 消息,但核心链路就是上面这条。后面我会逐个环节拆细节。
2. 读请求:本地执行的“快”,与顺序一致性的“慢”
2.1 读请求为何可以绕过 Leader
先解释那个最常被问到的问题:为什么读请求不用走 Leader?答案其实不复杂——ZooKeeper 的读请求本质上就是查一棵内存树(DataTree),不修改任何状态,所以不需要经过 ZAB 的提案和提交流程。
但这里埋着一个重要的代价:因为读请求不经过 Leader 协调,它就没有一个全局统一的"执行时刻"。读请求在任何节点上执行时,看到的数据版本,取决于那个节点当前已经提交到了哪个zxid。如果某个 Follower 的提交进度落后,你从它那里读到的就是旧数据。
这正是 ZooKeeper 文档里明确说明的一点:ZooKeeper 保证的是"顺序一致性"(sequential consistency),而不是"线性一致性"(linearizability)。从同一个客户端发起的请求,严格按照发送顺序执行;但从不同客户端甚至同一客户端在不同时刻发起的读请求,读到的最新值可能有延迟。
举一个非常典型的例子:客户端 A 对节点/config执行了一次setData(),写入新配置v2,写请求返回成功意味着这个值已经被多数派接受并提交。但这个时候如果客户端 B 紧接着从某个 Follower 读取/config,它可能还是读到v1。
我第一次在生产环境遇到这个问题时还挺意外的,明明是写成功了,读却读到旧值。后来才明白:写成功只代表"Leader 已经提交了这个事务",不代表"所有 Follower 都已经应用了这个事务"。被提交的事务会通过 Commit 消息继续扩散到各个 Follower,但扩散需要时间。
如果你对一致性有硬需求,方案很简单:把读请求也发给 Leader,或者使用 ZooKeeper 的sync()接口在读取前强制同步一次。但代价是牺牲读性能,把 ZooKeeper 变成单点读。实际项目中怎么取舍,后面第 5 章我会单独说。
2.2 从 Zxid 到版本号:读请求背后的数据视图
那读请求到底是怎么拿到数据的?进FinalRequestProcessor之后,根据请求类型分别走DataTree的不同查询方法:getData()走getData()方法返回数据和Stat(包含version、czxid、mzxid、pzxid等元信息),getChildren()走getChildren()方法返回子节点列表,exists()走statNode()只返回Stat。
这里有一个特别容易忽略的细节:读请求在查询DataTree的时候,是直接读取内存中的节点对象,不会在本地加读锁吗?
实际上 ZooKeeper 的读请求在绝大多数情况下确实是无锁的,或者更准确地说,是用ReentrantReadWriteLock做了读写分离。DataTree内部用了一个读写锁,写请求持有写锁来修改节点,读请求持有读锁来查询。因为写请求是低频的(所有写请求都要经过 Leader 串行提交),读锁几乎不会被写锁长时间阻塞,所以读并发能力非常强。
Stat里的版本号和zxid是读请求最需要关注的信息。比如getData()返回的Stat.version,就是当前节点数据的版本号。这个版本号有什么用?它是 ZooKeeper 实现乐观锁的关键:
// 伪代码,展示带版本校验的更新 public Stat setData(String path, byte[] data, int version) { if (version != -1 && version != node.stat.getVersion()) { throw new BadVersionException(); // 版本不匹配直接拒绝 } // 版本匹配才执行更新 }这个机制保证了"比较并交换"的原子性。实际应用里最常见的用法是分布式锁的释放:客户端删除锁节点时带上自己创建时的version,如果版本号对不上说明节点被别人改过了,拒绝删除,避免误删别人的锁。
所以读请求的核心价值不只是把数据拿回来,更重要的是把Stat元信息带回来,让客户端有能力做后续的并发控制。很多人写分布式锁只盯着create()的临时节点,忽略了getData()时返回的version,结果在释放锁的时候只能靠"比对数据内容"来判断所有权,这种做法在高并发下很容易出问题。
2.3 Watcher 的注册与读请求中的“隐藏副作用”
读请求还有一个容易被忽略的副作用:注册 Watcher。
当你调用getData(path, true)或者getChildren(path, true)时,第二个参数就是是否注册 Watcher。这个注册动作是在读请求处理过程中完成的——FinalRequestProcessor在查询完数据之后,会检查请求里是否带了 Watcher 标记,如果有,就把 Watcher 注册到DataTree的对应节点上。
源码里这个过程是在读请求处理结束后做的,代码大概长这样:
// DataTree.getData 伪代码 public DataRecord getData(String path, Stat stat, Watcher watcher) { // 先从节点表里找到节点 // 填充 stat 信息 if (watcher != null) { // 把 watcher 注册到节点的 watch 管理器里 dataWatches.addWatch(path, watcher); } return data; }这个"读请求 + Watcher 注册"的组合是 ZooKeeper 最经典的监听模式:你先注册一个 Watcher,然后当节点的数据发生变化时,服务端会主动推送一个通知给你。但注意,ZooKeeper 的 Watcher 是一次性的——触发一次之后自动失效,你要继续监听就必须在收到通知后重新注册。
这里藏着一个非常实用的排查点:如果一个节点同时注册了大量 Watcher(比如几百上千个客户端都在 watch 同一个配置节点),那么该节点每次变更都会触发一次"通知风暴"。因为写请求最终在FinalRequestProcessor里提交时,会遍历该节点的 Watcher 列表并逐个触发通知。
我经历过一次事故:某个配置中心把 ZooKeeper 当推送通道用,几千个客户端 watch 同一个节点,每次配置变更,ZooKeeper 就要往几千个连接上发送通知。单次变更看起来还好,但如果配置频繁更新,瞬间就能把网络带宽打满。后面我们的方案是改成客户端定时轮询 + 本地缓存,把 Watcher 数量降了两个数量级,问题才缓解。
所以在设计基于 ZooKeeper 的监听体系时,一定要控制单节点的 Watcher 数量,并且做好"通知风暴"的防护。ZooKeeper 默认对单个连接和整体连接的 Watcher 数量没有硬性上限(只受到jute.maxbuffer等传输限制),真正的瓶颈在服务端触发时的 CPU 和网络。
3. 写请求:从客户端到磁盘的完整提交链路
3.1 写请求为什么必须交给 Leader
现在进入重头戏——写请求。ZooKeeper 里所有修改数据状态的写请求(create、delete、setData、setACL、multi),都必须提交给 Leader 处理。
为什么?因为 ZAB 协议要保证事务提交的全局顺序。如果两个客户端分别向两个不同的节点发起写请求,两个节点各自独立执行,那"谁先谁后"就没有一个全局一致的说法了。而 ZAB 通过单一 Leader 来分配zxid,保证了所有写事务在全集群范围内有一个字典序唯一的编号,这个编号同时决定了事务的提交顺序。
可以把它类比成银行柜台:所有存款、取款业务都必须经过柜员(Leader)操作,因为柜员手上有一本唯一编号的流水账(zxid),不管客户从哪个门进来,账目顺序都是清晰的。如果每个保安(Follower)都能自己记账,那账最后肯定对不上。
当客户端把写请求发给了 Follower 而不是 Leader 时,Follower 会通过LearnerHandler把请求转发给 Leader。这个过程对客户端是透明的:客户端只看到"我发了请求,然后收到了响应",但请求实际在集群内部多跳了一次。
这也是 ZooKeeper 官方文档里建议客户端直连 Leader 的原因——少一跳,延迟更低。但 ZooKeeper 的客户端基于 IP 列表做负载均衡,没法保证一定连到 Leader,所以通常的优化是让客户端优先连接 Leader,或者接受这一跳的延迟。
3.2 Proposal 广播与事务日志落盘:把“内存操作”变成“持久化事实”
写请求到达 Leader 后,会进入正式的提交流程。我按源码里的顺序一步步拆:
第一步,PrepRequestProcessor做预处理。这个环节做的事情很多:检查会话是否有效、检查节点路径合法性、执行 ACL 权限校验、检查版本号。如果校验通过,就为这个写请求分配一个新的zxid(全局递增),并把请求序列化成Proposal。这一步相当于银行柜员先核对客户身份和账户余额,然后在流水账上编一个号。
第二步,ProposalRequestProcessor发起广播。Leader 把Proposal以"提议"的形式发送给所有 Follower。注意这时候事务还没有生效,只是"提议"。
第三步,SyncRequestProcessor把事务日志写到磁盘。这一步是 ZooKeeper 持久化的关键:Leader 和每个 Follower 在收到 Proposal 后,都会先把事务追加到本地的事务日志文件(txnlog)里,然后执行fsync强制刷盘。只有日志落盘成功,这个事务才不算丢失。
第四步,Ack 收集。每个节点在完成日志落盘后,向 Leader 发送 Ack。Leader 自己在落盘之后也会通过AckRequestProcessor给自己回一个 Ack。当收到多数派(quorum)的 Ack 后,这个 Proposal 就被"法定确认"了。
第五步,CommitRequestProcessor广播 Commit。Leader 向所有节点发送 Commit 消息,各节点收到后把事务应用到内存DataTree中。
第六步,FinalRequestProcessor返回响应。每个节点在应用完内存变更后,如果是客户端连接的本节点,就负责把响应结果通过连接发送回去。
这一整套流程,其实就是 ZAB 协议在两阶段提交(2PC)基础上的一个变体。它和传统 2PC 的区别在于:ZAB 的 Commit 是异步的,而且即使某个 Follower 宕机了,只要多数派还活着,事务依然可以提交成功。
有一个值得特别强调的点:事务日志的fsync是写请求延迟的最大来源。fsync意味着数据必须真正写到物理磁盘(或者至少写入持久化存储介质)才算完成,这个操作通常要花费几毫秒到几十毫秒。ZooKeeper 的写吞吐量,很大程度上取决于磁盘的fsync性能。
这时候有一个性能优化参数需要了解:tickTime和syncLimit是用来控制会话超时和节点间通信超时的;而和事务日志真正相关的参数是fsync.windows.times(Windows 上的时间窗口)以及日志目录的选择。生产环境里一块独立的 SSD 专门给事务日志用,和把日志放在系统盘上混用,写性能差距可以到数倍。
3.3 请求处理链中的三类处理器:Prep / Sync / Final
ZooKeeper 的请求处理链是一个典型的责任链模式。每一类处理器只做一件事,做完之后把请求交给下一个处理器。我把最核心的几类处理器列一下:
| 处理器 | 主要职责 | 在哪里运行 |
|---|---|---|
PrepRequestProcessor | 请求合法性校验、ACL 检查、分配 zxid、序列化事务体 | Leader 和 Follower 都会执行(Follower 转发前也会做一次) |
ProposalRequestProcessor | 把写请求封装为 Proposal 并发送给所有 Learner | 仅 Leader |
SyncRequestProcessor | 将事务写入 txnlog 并 fsync;同时负责生成快照 | Leader 和 Follower 都会执行 |
AckRequestProcessor | Leader 对自己落盘后的 Proposal 回 Ack | 仅 Leader |
CommitRequestProcessor | 收到多数派 Ack 后广播 Commit;对 Follower 来说是处理 Leader 发来的 Commit | Leader 和 Follower 都执行 |
FinalRequestProcessor | 应用内存变更、触发 Watcher、构造响应并写回连接 | 所有节点 |
这个表看起来很简单,但实际在源码里,处理器的组装顺序决定了请求的流转路径。以 Leader 为例,处理链是这样串起来的:
PrepRequestProcessor → ProposalRequestProcessor → SyncRequestProcessor → AckRequestProcessor → CommitRequestProcessor → FinalRequestProcessor而 Follower 的链路则是:
FollowerRequestProcessor(接收客户端请求,转发给 Leader)→ CommitRequestProcessor(处理 Leader 发来的 Commit)→ FinalRequestProcessor注意区别:Follower 自己对客户端写请求做的第一件事,不是执行写入,而是转发给 Leader。只有等 Leader 广播 Commit 下来之后,Follower 的 CommitRequestProcessor 才会把事务交给 FinalRequestProcessor 去执行内存变更。
SyncRequestProcessor还有一个容易忽略的职责:生成快照(Snapshot)。ZooKeeper 的数据是内存数据库,但为了避免事务日志无限膨胀,它会定期把内存中的DataTree全量序列化到磁盘快照文件里。恢复数据时,先加载最近一次快照,再重放快照之后的事务日志,就能恢复到最新状态。
这个设计非常精妙:事务日志是增量、连续的,快照是全量、间断的。两者配合使用,既避免了每次恢复都从零开始重放全部日志,又保证了数据不丢失。
3.4 写吞吐相关的参数与调优思路
说完链路,说说跟写性能相关的几个关键参数。这些参数都是在zoo.cfg里配置的,每个参数背后的权衡逻辑都值得展开:
forceSync参数
forceSync默认值是yes,表示每次事务日志写入后都执行fsync。如果改成no,ZooKeeper 会跳过强制刷盘,把fsync交给操作系统去调度。
改成no会显著提升写吞吐,但代价是节点宕机时可能丢失最近一段时间的事务日志(因为数据还在操作系统页缓存里,没落到磁盘)。这是一个典型的一致性和性能的取舍。我个人的建议是:默认保持yes,只有当你明确知道"丢一点最近的写操作可以接受"时才改,比如用在非关键缓存场景。
批量提交与maxBatchSize
SyncRequestProcessor在把事务写入日志时,会做一个批量优化:它会尝试从队列里多取几个Proposal一起写入磁盘文件,然后一起fsync。这样一次fsync就能确认为多个事务落盘,大幅摊薄了fsync的开销。
这个批量行为在源码里是一个while循环,它会从传入的队列中尽可能多地取出待处理事务,直到队列为空或者积攒了足够多的事务才停止。生产环境里如果写请求密集,这个机制能明显提升吞吐;如果写请求稀疏,效果就不明显。
jute.maxbuffer参数
这个参数很多人没注意,但它直接影响能处理的单个请求大小。默认值是 1MB(确切说是 1048575 字节),如果写入的数据超过这个值,服务端会直接拒绝连接或者报错。
某个项目里出现过一次线上故障:某个团队往 ZooKeeper 写入一个超过 1MB 的配置内容,结果整个节点不停地打印异常,客户端连接全部超时。排查了很久才定位到是jute.maxbuffer的问题。这个参数不仅影响请求体大小,还会影响响应体大小——如果你getData()返回的数据超过限制,同样会出问题。
调大jute.maxbuffer能解决大对象问题,但要注意它同时增加了内存占用和网络传输压力。ZooKeeper 不是一个适合存大对象的系统,如果你发现需要频繁调整这个参数,说明设计上可能有问题。
autopurge.snapRetainCount和autopurge.purgeInterval
事务日志和快照都会随时间增长,如果不清理,磁盘迟早被撑爆。这两个参数控制自动清理:snapRetainCount保留最近多少个快照,purgeInterval是清理间隔(小时)。默认snapRetainCount=3, purgeInterval=0,0 表示不自动清理。
说实话,我建议业务系统里把这个参数打开,至少设成snapRetainCount=5, purgeInterval=24,不然运维会被磁盘告警搞疯。某次我看到一个线上集群跑了一个月没开自动清理,事务日志文件占了 200 多 GB,节点启动时恢复数据花了半小时起步。
4. 请求分类与核心代码逻辑对照
4.1 请求类型与处理器链路对照表
ZooKeeper 的请求类型非常多,但核心的读写请求可以按下面这张表分类。搞清楚每个请求走哪条链路,排查问题时会非常有帮助。
| 请求类型 | 操作 | 是否写请求 | 是否分配 zxid | 处理链路 |
|---|---|---|---|---|
create | 创建节点 | 是 | 是 | 完整写链路 |
delete | 删除节点 | 是 | 是 | 完整写链路 |
setData | 更新节点数据 | 是 | 是 | 完整写链路 |
setACL | 更新节点权限 | 是 | 是 | 完整写链路 |
multi | 批量执行多个操作 | 是(整体视为一个事务) | 是 | 完整写链路(内部会拆分为多个子操作) |
getData | 读节点数据 | 否 | 否 | 本地查询 + 可能注册 Watcher |
getChildren | 读子节点列表 | 否 | 否 | 本地查询 + 可能注册 Watcher |
exists | 判断节点是否存在 | 否 | 否 | 本地查询 + 可能注册 Watcher |
getACL | 读节点 ACL | 否 | 否 | 本地查询 |
sync | 同步等待 Leader 数据 | 否(特殊) | 否 | 转发给 Leader 等待其提交进度追平 |
第一次看这个表,你可能会疑惑:为什么multi请求只分配一个 zxid?因为它内部把多个子操作打包成了一个事务,在 ZAB 层面来看,这个"事务"是一个整体,要么全部生效,要么全部不生效。这一点在实现分布式批量更新时非常有用——比如你要同时更新多个配置节点,如果分多次setData(),中间一旦失败就可能出现"半更新"状态;用multi一次提交,就能保证原子性。
sync是一个特殊请求,它不是写请求,但为了让读取方看到最新数据,会要求 Leader 等待自身的提交进度追平到最新zxid之后才返回。最典型的用法就是上文提到过的"强一致读取"——先sync再getData,保证读到 Leader 已提交的最新数据。
4.2 Watcher 的注册与触发:读请求中的“隐藏副作用”
Watcher 的机制值得单独拆开说,因为他横跨读写两个链路:读请求负责注册,写请求负责触发。
注册环节:客户端调用带 Watcher 的读接口,请求到达FinalRequestProcessor后,处理器在完成 DataTree 查询的同时,把 Watcher 对象注册到对应节点的WatchManager里。ZooKeeper 的WatchManager内部维护了两个映射:一个是"节点路径 → Watcher 集合",另一个是"Watcher → 节点路径列表"。
这样设计的目的在于:当节点发生变更时,服务端能快速找到所有需要通知的 Watcher,然后发送WatcherEvent。事件本身不携带变更后的数据,只包含事件类型(NodeDataChanged、NodeChildrenChanged、NodeCreated、NodeDeleted等)和节点路径。客户端收到事件后,如果需要最新数据,必须重新发起一次读请求。
触发环节:写请求在FinalRequestProcessor里应用内存变更时,会调用DataTree的相应方法。比如setData()成功后,会调用:
dataWatches.triggerWatch(path, EventType.NodeDataChanged);这个调用会遍历该节点关联的所有 Watcher,为每个 Watcher 生成一个事件,然后通过连接管理器异步发送给客户端。
为什么是"异步发送"而不是同步发送?因为触发 Watcher 不能阻塞写请求的提交链路。如果每个 Watcher 都同步发送,那一个节点有 1 万个 Watcher 时,一次写请求要花时间发送 1 万个网络包,写请求的延迟会爆炸。ZooKeeper 的做法是把事件投递给每个连接对应的发送队列,由独立的 IO 线程去真正发送,写完的响应先返回给客户端。
这个异步机制带来一个很有意思的现象:客户端收到 Watcher 通知的时候,可能早于也可能晚于其他客户端收到写响应。具体顺序不保证,只保证同一个客户端上,事件的顺序和事件触发的顺序一致。
4.3 大请求、ACL 与 jute.maxbuffer:最容易被忽略的隐性瓶颈
深入源码之后你会发现,ZooKeeper 的请求处理链路在大部分环节都是内存操作,真正的瓶颈其实集中在这几个点:
第一个是请求/响应的大小。jute.maxbuffer限制的是发布到网络协议栈的单个消息最大长度。很多线上问题表现为"某个请求发出后客户端一直超时",排查到最后发现是响应包超过了这个限制,服务端把连接断开了。
这个参数不仅在服务端zoo.cfg里要配,客户端里也有对应的配置。也就是说,服务端和客户端对这个参数必须一致,如果服务端调大了,客户端没调,客户端可能用自己的限制拒绝了响应包。
第二个是 ACL 检查。PrepRequestProcessor会对每个写请求执行权限校验。ACL 的设计是"路径 → ACL 列表"的映射,校验时要用请求里的身份信息(Id和Scheme)和节点的 ACL 列表逐一比对。如果组织内 ACL 规则特别多,且路径层级很深,这个校验的 CPU 开销是不能忽略的。
实际项目里最常见的 ACL 问题是:所有节点都用openAclUnsafe(全开放权限),看似省事,但一旦有节点被误删或误改,没有任何审计信息。从运维角度,至少要对关键节点设置带鉴权的 ACL。
第三个是内存占用。DataTree里每个节点由DataNode表示,包含路径、数据、ACL、Stat、子节点引用。节点数据多了之后,内存增长非常快。一个只有几百万节点的 ZooKeeper,内存占用轻松超过 2GB。所以从容量规划的角度,ZooKeeper 适合存配置、锁、元数据,不适合存大块数据或海量数据。
5. 生产环境里的常见问题与排查实录
5.1 事务日志与快照的磁盘占用问题
上线 ZooKeeper 之后,最先遇到的一定是磁盘问题。事务日志是追加写的,快照是定期全量生成的,两者都在日志目录下堆积。
我见过一个环境:ZooKeeper 跑业务注册中心,每天产生几十个快照文件和几百个事务日志文件,如果不清理,一个月磁盘就满了。更麻烦的是,节点重启时要加载快照并重放日志,日志文件越多,重启越慢。
排查和解决方案分几步走:
第一步,看日志目录:
ls -lh /data/zookeeper/version-2/这个目录里会有两类文件:snapshot.*是快照,log.*是事务日志。观察文件大小和数量增长趋势。
第二步,确认自动清理是否开启。检查zoo.cfg里的autopurge.snapRetainCount和autopurge.purgeInterval。前面说过,purgeInterval=0表示不自动清理,必须改成正数。
第三步,如果磁盘已经告急,手动清理:
# 保留最近的 5 个快照,其余删除 ls -t snapshot.* | tail -n +6 | xargs rm -f手动清理时注意:删除快照之前,要确保对应的事务日志已经被包含在更早的快照里,否则恢复数据时可能因为日志缺失而出问题。最稳妥的方式是保留快照的同时,把早于最老快照的日志也一起清掉,但实际上 ZooKeeper 的恢复逻辑是从最新快照开始重放,更早的日志已经没用了,可以直接删。
5.2 请求长时间无响应:从网络、队列到 fsync 的排查顺序
写请求超时是 ZooKeeper 最头疼的问题之一。这里我整理一个自己的排查顺序,从外到内层层剥离:
第一层:网络。先看客户端到 ZooKeeper 节点的 TCP 连接状态,有没有重传、丢包。用ss -ti查看连接的重传计数,或者用tcpdump抓包看是否有异常的 RST。很多"写超时"其实是网络抖动导致的,尤其是跨机房部署时,机房间的网络质量直接影响写请求延迟。
第二层:连接数和队列。查看 ZooKeeper 节点上的连接数:
echo mntr | nc localhost 2181关注num_alive_connections和outstanding_requests这两个指标。outstanding_requests表示等待处理的请求数,如果持续偏高,说明处理线程跟不上请求速度,或者某个环节卡住了。
第三层:fsync。如果磁盘性能不行,fsync会非常慢,直接拖垮写请求吞吐。用iostat看磁盘的%util和await指标。如果磁盘await持续超过 20ms,事务日志的fsync就是瓶颈了。
某次线上问题就是这么定位的:我们的 ZooKeeper 集群和别的应用共用一台云主机,隔壁应用疯狂写日志把磁盘 IO 打满,我们的 ZooKeeper 写请求延迟从 2ms 飙升到 300ms。后来把 ZooKeeper 迁移到独立的 SSD 云盘上,问题直接消失。
第四层:GC。ZooKeeper 是 Java 应用,堆内存如果持续增长触发频繁 Full GC,请求处理线程会长时间停顿。看 GC 日志,如果 Full GC 频繁,就要检查DataTree大小是不是超预期了,或者调整 JVM 堆大小。
5.3 读多写少场景的架构选型与优化
现实业务里,ZooKeeper 绝大多数场景是"读多写少":配置读取、服务发现、分布式锁的exists检查,都是高频读操作。针对这种场景,有几个优化思路:
第一个思路:增加 Observer 节点。Observer 不参与投票,只负责处理读请求。它和 Leader 保持数据同步,但不参与 ZAB 的 Ack 和投票,因此扩容 Observer 不会降低写入性能,却能水平扩展读能力。
我在一次大促前给注册中心扩容过:原来 3 个节点(1 Leader + 2 Follower)扛不住几千个服务实例的每秒数万次心跳读,后面加了 3 个 Observer,读压力分散后,主集群的 CPU 和网络立刻降下来了。Observer 的配置非常简单:
server.4=obs-server-ip:2888:3888:observer peerType=observer注意最后那个:observer后缀和peerType=observer都要写对,否则节点不会以 Observer 角色加入集群。
第二个思路:客户端本地缓存 + 失效通知。这是最有效也最容易被忽略的优化。与其每次读都打 ZooKeeper,不如在客户端本地缓存一份数据,通过 Watcher 监听变更,只在收到变更通知时刷新缓存。
我用这个方案把某个配置服务的 QPS 从每分钟几十万次降到了几乎为零:客户端启动时读一次配置并注册 Watcher,之后配置不更新就永远不访问 ZooKeeper,配置更新时由 Watcher 通知客户端主动重新拉取。
第三个思路:合理使用multi。如果业务上需要同时操作多个节点,尽量打包成一个multi请求,减少网络往返和事务数量。比如分布式锁的"创建锁节点 + 注册 Watcher",就可以拆成两次操作,但如果用multi,一次提交就能完成。
5.4 会话过期与重连:为什么客户端连接突然全部断开
最后再说一个非常容易踩的坑:会话过期导致的请求异常。ZooKeeper 的会话(Session)有超时时间,如果客户端在超时时间内没有发送任何请求(或者服务端没收到心跳),会话就会被服务端判定为过期,所有在这个会话上创建的临时节点都会被自动删除。
这里有个容易误解的地方:ZooKeeper 的心跳不是客户端主动定时发的,而是客户端发送的任何请求(包括读请求)都会被服务端视为一次会话保活。所以如果客户端线程因为某种原因长时间阻塞(比如 GC 停顿、网络中断),超过了会话超时时间,服务端就会认为会话死亡。
排查手法是看zoo.cfg里的minSessionTimeout和maxSessionTimeout。默认值是tickTime的 2 倍和 20 倍,即如果tickTime=2000,会话超时范围就是 4 秒到 40 秒。客户端请求的 session timeout 如果设得太短,比如 4 秒,那么一次网络抖动就可能触发会话过期。
我们的经验是:业务量大的系统,客户端会话超时设置不低于 10 秒;如果网络环境不稳定,建议 15 到 20 秒。设置太短,临时节点频繁重建,分布式锁频繁失效;设置太长,服务端要维护的会话状态更多,而且节点故障后客户端发现异常的延迟变长。
一旦发生会话过期,客户端会收到SessionExpiredException,这时候不能简单地重试,因为会话已经不存在了,所有临时节点都已被清掉。必须重新创建会话,重新执行初始化逻辑(比如重新注册临时节点、重新注册 Watcher)。
6. 对读写链路的一些个人体会
写到这里,我对 ZooKeeper 读写请求链路做一个收尾式的体会分享,不做什么系统性总结,就聊聊实际用下来的感受。
第一个体会是:ZooKeeper 的写性能和读性能不是一个量级。写请求的延迟取决于磁盘fsync,读请求的延迟取决于内存查询。生产环境里如果对写性能有极高要求(比如每秒数千次写),ZooKeeper 很可能不是最优选择;但它作为元数据协调者,写入频率天然是低的,所以这个短板在大多数场景下并不致命。
第二个体会是:理解读写链路的核心价值在于定位问题,而不在于背诵类名。我见过很多人能把PrepRequestProcessor、SyncRequestProcessor背得滚瓜烂熟,但线上出问题了一样无从下手。真正的理解是:当写请求超时时,你知道先查磁盘fsync还是先查网络包;当读到旧数据时,你知道是 Follower 提交延迟还是客户端缓存没刷新;当 Watcher 没触发时,你知道是一次性的特性还是代码 bug。这些判断能力,比记住几十个类名有用得多。
第三个体会是:读多写少的场景下,ZooKeeper 是一个非常好的协调者,但不是好用的"存储"。把 ZooKeeper 当数据库用的项目,最后基本都栽在容量、大对象、频繁写这三个坑里。正确姿势是:用 ZooKeeper 管"小、少、关键"的数据——配置、锁、集群元数据、服务注册信息,把真正的大数据交给专门的存储系统。
最后再分享一个小技巧,也是我排查 ZooKeeper 问题最常用的命令:echo mntr | nc localhost 2181。这个命令能输出当前节点的实时状态,包括zk_outstanding_requests、zk_avg_latency、zk_num_alive_connections等关键指标。遇到任何 ZooKeeper 异常,先跑一遍这个命令,看outstanding_requests和avg_latency是否异常,再决定往哪个方向深入排查。很多看似诡异的问题,其实在这两个指标里早有征兆。