首页 / 资讯中心 / 文章详情

Zookeeper核心机制与实战:会话、Watcher、ZAB与分布式锁

Zookeeper核心机制与实战:会话、Watcher、ZAB与分布式锁 ★ FEATURED ARTICLE
很多人第一次接触 Zookeeper都是被动物园管理员这个译名带偏的以为它是个带点花哨名字的缓存或者配置存储。真上手用一段时间才会发现Zookeeper 本质上是一个跑在集群上的分布式协调服务它卖的不是存储空间而是选主、通知、加锁、命名这一套协调原语。你在项目里写的配置中心、分布式锁、Master 选举、服务注册底层大概率都压在它身上你排查的临时节点莫名消失锁一直不释放集群选不出 Leader根因也大概率在它的会话session机制、znode 语义和 ZAB 协议上。这篇笔记不打算写成 API 手册而是把我在实际使用和排查中真正觉得当初要是有人这么讲就好了的那部分内容摊开来讲数据模型怎么选、Watcher 为什么会漏事件、集群参数怎么算、代码骨架怎么搭、日志里那些吓人的异常到底意味着什么。无论你是刚准备把它接进项目还是已经用了两年但一直没弄清过细节下面这些内容应该都能对上你的某个困惑。1. 先搞清楚它站在架构的哪个位置再谈怎么用1.1 从配置改完怎么让二十台机器同时知道说起假设你有一套服务二十个实例跑在不同机器上某个开关的值存在本地配置文件里。某天要紧急关掉一个功能你得挨个改配置文件再重启或者写个脚本批量推送推完了还得担心漏了哪台、哪台改了没生效。这个场景的痛点不在于存,而在于变了之后怎么让所有人几乎同时知道并且保证大家看到的是同一个值。这就是 Zookeeper 最原始的出发点。它把配置放在一个树形结构的节点上客户端在节点上挂一个监听Watcher节点数据一变服务端主动把通知推回来。整个过程不需要轮询不需要额外的消息通道客户端拿到通知后再去读一次最新值。读 监听 变更通知这个组合是它最核心的使用姿势后面所有的分布式锁、服务发现、Master 选举都是在这个姿势上做的变体。理解这一点很重要因为它决定了你对它的期待你期待的是低延迟的小数据强一致通知而不是高吞吐的读写。它的单节点写入是串行的所有写请求都要经过 Leader 走一遍原子广播所以吞吐量天生受限。拿它当配置中心、当协调层非常合适拿它当业务数据存储那就是用错了工具。1.2 它卖的是协调原语不是存储能力我习惯把 Zookeeper 提供的能力拆成四块来看这样在选型的时候思路会清晰很多能力靠什么机制实现典型用途强一致的少量数据存储ZAB 原子广播 过半写入成功配置项、元数据、路由表变更通知Watcher 机制配置热更新、节点上下线感知分布式互斥临时顺序节点 最小节点判定分布式锁、任务互斥存活感知临时节点 会话心跳服务注册、Master 选举、故障摘除表格里存活感知这一条最容易被忽略但它其实是最有价值的。临时节点的生命周期绑在会话上客户端进程挂了、网络断了超过会话超时节点自动消失。这个特性让你不需要额外写一套心跳检测和超时清理逻辑进程死掉这件事对集群里的其他人是自动可见的。很多团队自己造轮子做服务发现最后写出来的东西就是一套简化版的心跳加清理还不如直接用现成的。需要强调的是强一致这个词在这里是有严格范围的。Zookeeper 保证的是线性一致性的写和顺序一致性的读同一个客户端看到的写顺序和提交顺序一致但它不保证跨客户端的实时读一致——一个客户端刚写完另一个客户端可能还读到旧值除非前者写完后再做一次同步操作。这个细节后面第 3 节会展开这里先记住结论它的一致性比大多数系统强但不是写完立刻全球可见。1.3 三条不该用它的红线先划清楚在把 Zookeeper 接进架构之前有几条边界最好先想明白否则后面返工成本很高。第一条数据量必须小。单个 znode 默认上限是 1MB服务端有jute.maxbuffer参数控制默认 1048576 字节而且所有数据是常驻内存的。如果你打算往里面塞几十 KB 的 JSON 配置勉强可以如果打算塞几百 MB 的名单直接放弃。数据量一大全量快照和节点同步的耗时都会飙升集群恢复时间会变得不可接受。第二条写入频率必须低。所有写请求都要过 Leader走一遍两阶段提交磁盘落盘之后才算成功。这意味着写性能受限于单机磁盘的 fsync 速度通常每秒几百到几千次的量级。用它做秒杀库存扣减、做计数器都是灾难。真要高频写应该换成别的方案Zookeeper 只用来存变更不频繁但必须一致的那部分。第三条不要用它做消息队列。虽然它确实有顺序节点和通知机制理论上能拼出一个队列但那是南辕北辙。通知是一次性的、可能重复的、顺序也不保证与业务顺序对应一旦堆积起来节点数量和 Watcher 数量都会爆炸。真要做队列有专门的东西去做。这三条划清楚之后剩下的场景基本都能放心用。我在实际项目里见过最常见的误用是把业务订单状态机塞进 znode每个订单一个节点一天几十万单结果节点数暴涨、快照巨大、集群频繁 GC。这种坑提前想清楚就不会踩。2. znode 的类型和版本号决定了后面所有玩法2.1 四种基础类型选错等于给未来埋雷znode 的类型不是一个可有可无的选项它直接决定了节点的生命周期和语义选错了后面很难改。基础的四类是持久节点PERSISTENT创建后一直存在除非显式删除。适合存配置、存元数据。临时节点EPHEMERAL绑在创建它的会话上会话结束自动删除。不能有子节点。适合做存活标记、做锁。持久顺序节点PERSISTENT_SEQUENTIAL在持久节点基础上服务端自动在名字后面追加一个单调递增的 10 位序号。适合做队列、做全局唯一编号。临时顺序节点EPHEMERAL_SEQUENTIAL同时具备临时和顺序两个特性。分布式锁的标准实现就靠它。除了这四类新版本还引入了容器节点Container和 TTL 节点。容器节点用于配合锁场景做自动清理当它最后一个子节点被删除后服务端会在合适的时机把它也回收掉TTL 节点则允许给持久节点设置一个空闲存活时间超过这个时间没有修改且没有子节点就会被删除。这两个属于进阶用法日常用得不多但知道有这回事遇到锁目录下积攒了几万个空节点这类问题时能想起来。有个细节必须提临时节点不能有子节点。这个限制经常被忽略直到你写出在锁节点下面再挂个子节点的代码然后收到NoChildrenForEphemeralsException。原因也很直接——如果临时节点能有子节点那么父节点因为会话超时被删掉时子节点怎么办递归删除的语义和一致性维护成本太高干脆禁止。分布式锁的实现里之所以要一个持久父节点加一堆临时顺序子节点就是为了绕过这个限制。2.2 version、cversion、aversion乐观锁的全部家当每个 znode 都带一个 stat 结构里面有三个版本号很多人用了很久都没分清version节点数据的修改次数每次setData加一。cversion子节点的变更次数子节点新增或删除都会加一。aversion节点的 ACL 修改次数。这三个版本号是 Zookeeper 实现乐观锁的基础。所有写操作都可以带一个期望版本号版本不匹配就抛BadVersionException操作失败。这在你需要读-改-写原子性的时候非常有用。比如两个客户端同时想更新一个配置项各自先读到 version3然后都去写第一个成功的把版本推到 4第二个带 version3 去写就会失败它就知道自己被抢先了可以重新读取再决策。这套机制在跨进程协调里的价值很高但要注意它的局限版本号保护的是单节点多节点的原子性要靠multi事务。multi允许把多个创建、更新、删除操作打包成一个原子事务提交要么全成功要么全失败。这个能力在做同时注册服务并更新路由表这类操作时很有用但用的人不多因为大多数场景单节点操作就够。还有一个高频疑问临时节点的 cversion 有意义吗答案是没有因为临时节点压根不能有子节点cversion 永远是 0 或者 -1。而顺序节点的名字序号是由父节点的 cversion 派生出来的父节点每创建一个子节点cversion 加一序号就跟着增长。这也是为什么顺序节点的序号是单调递增但不连续——中间有删除操作会跳号别指望它当严格连续的自增 ID 用。2.3 节点数量和小大限制带来的设计约束前面说了单节点 1MB 的数据上限这里再补充两个更隐蔽的约束。第一个是节点总数。所有 znode 的元数据都常驻内存一个节点大概占几百字节再加上路径字符串。十万个节点问题不大上百万就开始吃内存了。而且每次全量快照都要把这棵树序列化写盘节点越多快照越慢重启恢复越久。如果一个设计需要在 znode 里存每个用户一个节点每个订单一个节点这种量级的数据那基本可以判定设计方向错了。第二个是监听器数量。每个客户端注册的 Watcher 都保留在服务端内存里一个节点上挂几千个监听器一旦这个节点变更服务端要挨个推送通知瞬间的 CPU 和网络开销会很明显。这也是大量客户端监听同一个配置节点这种朴素做法在实际中会出问题的原因。后面第 5 节讲配置中心时会给出更稳的替代方案。设计上的通用建议是把 Zookeeper 当成目录服务而不是数据服务。节点里存的是指针、状态、路径这类轻量信息真正的业务数据放在别的存储里通过节点里的信息去定位。这样节点数可控、数据量可控、变更频率也可控整个系统会稳很多。3. 会话、Watcher、ZAB三个最容易讲糊的概念3.1 会话超时之后临时节点到底是什么时候消失的会话是 Zookeeper 里最基础也最容易被误解的概念。客户端连上服务端后双方协商出一个会话超时时间sessionTimeout这个值不是客户端说了算服务端会把它限制在minSessionTimeout默认 2 倍 tickTime和maxSessionTimeout默认 20 倍 tickTime之间。客户端传 1 秒服务端可能给你 4 秒传 1 小时服务端给你 40 秒。会话活着的时候客户端定期发心跳ping维持。如果超过超时时间的 2/3 还没收到心跳服务端就认为这个会话挂了开始清理它名下的所有临时节点。注意这里有个关键的时间差判定会话失效和实际删除临时节点之间不是瞬间完成的服务端要通知所有相关的 Follower还要触发监听器中间可能有几百毫秒到几秒的延迟。这个延迟会带来一个很实际的问题。假设你用临时节点做服务注册服务 A 的进程卡死了比如长时间 Full GC心跳发不出去会话超时注册节点被删。这时候上游的服务发现逻辑会认为 A 下线了把流量摘走。但 A 的进程其实还活着GC 结束后它可能还在处理已经收到的请求甚至还在往外发请求。这就是典型的假死。要处理这种情况靠 Zookeeper 单方面是不够的业务侧必须配合拿到会话失效的通知后主动停止对外服务或者用注册节点 服务端主动探活双保险。还有一个更隐蔽的坑会话重连和会话恢复是两回事。网络短暂抖动客户端会自动重连到另一台服务器只要在原会话超时之前连上会话就延续临时节点还在这叫会话恢复。但如果抖动时间超过了超时时间服务端已经清理了会话客户端重连后拿到的是一个全新的会话之前的临时节点全部消失所有 Watcher 全部失效。代码里必须监听Expired状态并重新初始化所有注册和监听否则就会出现跑了几天之后服务发现莫名失效的问题而且极难复现。3.2 Watcher 一次性触发这句话坑过太多人Watcher 的语义可以用一句话概括一次注册一次触发触发即失效。你在节点上getData并传入 Watcher节点数据变化时你会收到一次通知然后这个 Watcher 就从服务端移除了。想继续监听得在回调里重新注册一次。这个设计的初衷是避免服务端为长期监听维护大量状态但对使用者来说就是一堆坑。最典型的是漏事件你在回调里先处理业务逻辑比如读一下新配置、重建连接池处理完之后才去重新注册 Watcher这中间的时间窗口里如果节点又变了一次这次变更你就收不到了。正确做法是在回调的第一行就重新注册把业务处理放到后面。第二个坑是事件类型不精确。Watcher 的回调里能拿到的事件类型有NodeCreated、NodeDeleted、NodeDataChanged、NodeChildrenChanged几种但它不告诉你变成了什么值只告诉你变了。而且某些操作会触发多种事件比如删除一个节点可能同时触发父节点的NodeChildrenChanged和自身的NodeDeleted。所以回调里必须重新读一次状态根据当前实际值做决策不能依赖事件本身携带的信息。第三个坑是Watcher 在不同服务器上的注册状态。客户端连在 A 服务器上注册的 WatcherA 挂了之后客户端重连到 BWatcher 会随之迁移因为会话还在但迁移过程有延迟。在迁移窗口内发生的变更通知可能丢失。这也是为什么对可靠性要求高的场景不建议裸用 Watcher而是用封装好的缓存组件比如 Curator 的 TreeCache它在内部处理了重注册、重连、事件补偿这些琐事。提示判断Watcher 是不是真的注册成功了不要看有没有报错要看回调有没有进来。注册动作本身是异步的返回成功只代表请求发出去了。3.3 ZAB 协议的两阶段提交和那个经典的假死复活ZABZookeeper Atomic Broadcast是保证数据一致性的核心协议可以粗略理解为带 Leader 选举的两阶段提交。所有写请求先发给 LeaderLeader 把它包装成一个提案proposal带上一个全局递增的 zxid广播给所有 Follower。Follower 收到后先写本地事务日志回一个 ACKLeader 收到过半 ACK 后提交再通知所有 Follower 提交。客户端收到成功响应的时候这个写操作已经在过半节点上落盘了。zxid 是个 64 位整数高 32 位是 epochLeader 任期号低 32 位是事务计数器。这个结构很巧妙比较两个 zxid 大小的时候先比 epoch 再比计数器就能天然判断出哪个 Leader 更新。选举的时候就是靠这个选出数据最新的节点当新 Leader。现在说那个经典场景。假设集群是 5 个节点Leader 因为网络分区或者长时间 GC 和其余节点失联了剩下的 4 个节点凑够过半重新选了个新 Leader继续对外服务。过了一段时间老 Leader 恢复了它还以为自己是 Leader会尝试继续处理写请求——这就是脑裂的雏形。ZAB 的处理方式是老 Leader 恢复后发现自己连不上过半节点因为其他节点已经认了新 Leaderepoch 更大会拒绝它的提案于是它主动降级重新加入集群。加入之前新 Leader 会检查它的数据把落后的部分补上把多余的未提交提案丢弃。整个过程对客户端是透明的最多表现为一段时间的写超时。理解这个过程的意义在于你要知道写成功意味着什么。写成功代表过半节点落盘了不代表所有节点都有。如果集群刚好在这个瞬间发生选举不同客户端可能短暂看到不同状态。这也是为什么 Zookeeper 强调顺序一致性而不是实时一致性——它保证的是所有客户端看到的变更顺序相同而不是所有客户端在同一时刻看到相同数据。4. 从单机到三节点那些参数不是随便填的4.1 tickTime、initLimit、syncLimit 之间的换算关系配置文件里这三个参数看着不起眼但它们是整个集群心跳和超时机制的骨架填错了会表现为各种莫名其妙选不出 Leader节点频繁掉线。tickTime是基本时间单位默认 2000 毫秒。它决定了心跳间隔也是所有其他超时参数的基准。initLimit是 Follower 和 Leader 完成初始数据同步允许的时长单位是 tickTime 的倍数默认 10也就是 20 秒。syncLimit是 Follower 和 Leader 之间心跳允许的最大延迟默认 5也就是 10 秒。这三个参数之间是有联动关系的。如果集群数据量很大快照几百 MBFollower 启动时同步数据可能超过 20 秒然后它就会被踢出集群反复重启。这时候要做的不是无脑调大 initLimit而是先看看是不是该拆分集群或者清理数据了。同理如果机器负载很高心跳延迟超过 syncLimitFollower 会被判定为失联这时候调大 syncLimit 是治标查负载是治本。还有maxSessionTimeout和minSessionTimeout前面提过默认是 tickTime 的 20 倍和 2 倍。这两个值决定了客户端能申请的会话超时范围。如果你业务里要求会话超时是 30 秒而 tickTime 是 2 秒那最大只能到 40 秒30 秒刚好在范围内但如果 tickTime 配成 1 秒最大只有 20 秒客户端申请 30 秒会被强制截断成 20 秒然后你会看到临时节点比预期更早消失。这个细节我在排查锁提前释放的问题时踩过最后发现是会话超时被静默截断了。4.2 myid、2888、3888 三个数字的分工搭集群时配置文件里那几行server.Nhost:port1:port2经常让人一头雾水。拆开看是这样的tickTime2000 initLimit10 syncLimit5 dataDir/data/zk/data dataLogDir/data/zk/logs clientPort2181 server.110.0.0.1:2888:3888 server.210.0.0.2:2888:3888 server.310.0.0.3:2888:3888server.1里的1就是 myid必须在对应机器的dataDir/myid文件里写上一模一样的数字。这个数字是节点在集群里的唯一标识重复了会出大问题。2888是数据同步端口Follower 连上 Leader 后用这个端口接收提案和数据3888是选举端口选 Leader 的时候大家用这个端口互相通信。两个端口必须对集群内其他节点可达对外不需要开放。还有个容易忽略的配置dataLogDir最好和dataDir分开放在不同的磁盘上。因为事务日志是顺序追加写的对磁盘的 fsync 性能要求极高而快照是随机写大文件两者混在一块盘上会互相拖累。生产环境里事务日志盘用 SSD、快照盘用普通盘是很常见的做法。如果只有一块盘那至少保证dataLogDir单独一个目录不要和系统盘的其他高 IO 应用抢。4.3 启动之后怎么确认真的选出了 Leader配置写完三台机器依次启动接下来最关键的一步是验证集群状态。不要凭进程起来了就认为集群正常很可能三个节点各自为战一个 Leader 都没选出来。最直接的办法是用zkServer.sh status# 在每台机器上执行 zkServer.sh status # 正常输出类似 # Mode: follower # 或者 # Mode: leader如果输出Mode: standalone说明这个节点以为自己是单机模式多半是配置文件里的 server 行没写全或者 myid 文件有问题。如果报错说连不上检查 clientPort 和防火墙。另一个更细的检查方式是用四字命令看集群视图# 需要先在配置文件里放开白名单3.5 之后默认关闭大部分四字命令 # 4lw.commands.whitelist* echo srvr | nc 127.0.0.1 2181 echo mntr | nc 127.0.0.1 2181srvr会输出当前节点的角色、zxid、连接数等概要信息。mntr输出的是监控指标包括平均延迟、待处理请求数、Follower 数量、待同步数等做监控采集的时候非常有用指标含义关注点zk_avg_latency平均请求延迟持续升高说明磁盘或负载有问题zk_outstanding_requests排队中的请求数长期不为 0 说明处理不过来zk_followersFollower 数量少于预期说明有节点掉线zk_pending_syncs待同步的请求数持续增长说明同步跟不上zk_max_file_descriptor_count文件描述符上限太小会导致连接被拒还有一个实用的验证方法在 Leader 上创建节点去 Follower 上读能读到说明同步正常然后 kill 掉 Leader观察剩下的节点是否在几秒内选出新 Leader客户端是否自动重连。这个演练在做上线前的验证时非常必要比看文档管用得多。5. 配置中心、分布式锁、Master 选举的代码骨架5.1 配置中心不要手写 Watcher用缓存组件最朴素的配置中心实现是读一次 注册 Watcher 回调里重新注册这个写法在 Demo 里能跑在生产里一定会出问题原因前面讲过了事件可能漏、重连可能丢、多节点配置要维护多个 Watcher代码很快就乱成一团。更稳的做法是用 Curator 的 TreeCache。它把整棵树或某个子树缓存到本地事件通过回调通知业务内部处理了重连、重注册、事件补偿。业务代码只需要关心哪些路径变了、变之后要做什么。CuratorFramework client CuratorFrameworkFactory.builder() .connectString(10.0.0.1:2181,10.0.0.2:2181,10.0.0.3:2181) .sessionTimeoutMs(30000) .connectionTimeoutMs(15000) .retryPolicy(new ExponentialBackoffRetry(1000, 3)) .build(); client.start(); TreeCache cache TreeCache.newBuilder(client, /config) .setCacheData(true) .build(); cache.getListenable().addListener((c, event) - { if (event.getData() null) { return; // 节点被删除或连接事件 } String path event.getData().getPath(); byte[] data event.getData().getData(); // 业务侧重新加载配置 reload(path, data); }); cache.start();这段代码里有两个点值得强调。一是setCacheData(true)它会缓存节点数据业务侧可以直接从本地缓存读避免每次读都走网络。二是异常处理TreeCache的监听器里抛异常会被吞掉业务出错必须自己记日志否则问题会被静默。还有一个设计层面的建议配置节点不要太大、不要太多。把配置按模块分组每个模块一个节点节点里放一个精简的 JSON 或者 properties 文本。不要一个配置项一个节点那样节点数会失控变更通知也会变得极其频繁。5.2 分布式锁顺序节点为什么能避免羊群效应分布式锁的经典实现思路是所有竞争者去一个固定父节点下创建临时顺序节点然后判断自己是不是序号最小的那个是就拿到锁不是就监听自己前一个节点。这个设计的关键在于只监听前一个而不是监听父节点。如果监听父节点那么父节点的任何子节点变化都会通知所有竞争者一百个客户端同时被唤醒然后九十九个发现没轮到自己又睡回去这就是羊群效应对服务端是巨大的压力。只监听前一个节点就变成了一条链各自的唤醒时间严格有序每次只有一个客户端被唤醒。CuratorFramework client ...; InterProcessMutex lock new InterProcessMutex(client, /locks/order-job); if (lock.acquire(10, TimeUnit.SECONDS)) { try { // 临界区 doSomething(); } finally { lock.release(); } } else { // 获取锁超时走降级逻辑 }Curator 的InterProcessMutex内部就是这个逻辑而且处理了不可重入、超时释放、会话失效后清理这些细节。自己手写的话最容易漏掉的是获取锁的过程中会话失效这时候临时节点已经没了你以为拿到了锁实际上锁已经释放其他客户端可能也拿到了互斥就破了。所以我的建议很直接锁不要自己写用成熟实现。还有个使用层面的坑acquire一定要带超时。不带超时的版本在网络有问题时会一直阻塞把线程池耗尽。而且超时之后要明确走什么逻辑——是快速失败、是排队重试、还是降级到本地处理这个必须在业务层面想清楚不能含糊过去。5.3 Master 选举临时顺序节点的极简实现Master 选举和分布式锁其实是同一个模式所有候选者创建临时顺序节点序号最小的当 Master其余的监听前一个节点等前面的挂了自己顶上。区别在于语义——锁是短时间的排他选举是长期的角色分配谁当 Master 谁就一直当直到它挂掉。String path /election/worker; String myNode client.create() .withMode(CreateMode.EPHEMERAL_SEQUENTIAL) .forPath(path -); // 判断自己是不是最小 ListString children client.getChildren().forPath(/election); Collections.sort(children); String smallest children.get(0); if (myNode.endsWith(smallest)) { becomeMaster(); } else { // 找到自己前一个节点并监听 String prev findPrevious(children, myNode); watchNode(prev, () - becomeMaster()); }这个骨架里最关键的两件事一是必须用临时顺序节点二是必须在会话失效后重新走一遍完整流程。因为会话失效之后节点没了你可能已经从 Master 降级成了普通节点如果不在ConnectionStateListener的Expired回调里重新竞选这个节点就永久失联了集群的可用节点数会越来越少。另外提一句临时顺序节点数量多的时候getChildren返回的列表可能很大排序和查找前一个节点的开销不能忽略。真实场景里最好在节点名字里编码一些信息比如机器 ID避免每次都全量排序。还有节点名里的序号是 10 位数字字符串排序和数字排序结果一致因为长度固定补零这一点可以放心用字符串比较。6. 几类高频异常日志和它们的真实成因6.1 ConnectionLossException 不等于操作失败KeeperException.ConnectionLossException是出现频率最高的异常而它最坑的地方在于它只表示客户端不知道结果不表示操作失败。请求发出去之后连接断了服务端可能已经执行了也可能没执行。这时候如果你无脑重试可能会创建出重复节点、重复加锁。正确的处理方式分两类。只读操作直接重试即可。写操作要么用幂等设计比如用带版本号的setData重复执行结果一样要么在异常后先做一次存在性检查确认状态再决定。比如创建锁节点异常后先看一遍自己刚才创建的节点是否存在存在就继续走后面的流程。这个异常还有一个诱因值得单独说客户端和某台服务器之间的连接断了但其他服务器还活着。Curator 的重试策略会自动切到其他服务器重试。如果你的connectString只配了一台机器那这台挂了客户端就彻底失联了所以一定要配全集群地址。6.2 SessionExpiredException 之后一定要重建全部状态会话过期的处理是所有坑里最严重的因为它会导致临时节点全丢、Watcher 全失效。日志里表现为KeeperException$SessionExpiredException或者连接状态回调里收到Expired。我建议在客户端初始化时就注册一个ConnectionStateListener把状态流转打全日志client.getConnectionStateListenable().addListener((c, state) - { log.warn(zk connection state changed to {}, state); if (state ConnectionState.LOST) { // 短暂失联客户端会自动重连业务侧可以开始预热降级 } else if (state ConnectionState.RECONNECTED) { // 重连成功会话可能延续也可能已经重建 } else if (state ConnectionState.EXPIRED) { // 会话彻底失效必须重建重新注册、重新竞选、重新加锁 reinitEverything(); } });状态是LOST的时候不要做危险动作因为客户端可能自动恢复。真正需要动作的是EXPIRED。很多线上事故的根因就是只处理了断线没处理过期结果服务在集群里消失了但本地还在跑流量分配全乱。另外一个细节会话过期是不可恢复的没有续上的可能。重建会话之后拿到的临时节点路径会变因为序号不同所以任何依赖节点路径的地方都要更新比如锁对象要重新创建选举结果要重新计算。6.3 磁盘、快照和事务日志相关的性能问题最后说一类不体现在异常日志里但影响巨大的问题磁盘相关的性能劣化。Zookeeper 的写路径是先写事务日志到磁盘再更新内存数据再返回成功。如果磁盘 fsync 慢整个写流程就被拖住表现是平均延迟上升、待处理请求堆积严重时 Follower 同步超时被踢出集群。观察mntr里的zk_avg_latency和zk_pending_syncs如果两者同时上涨基本可以断定是磁盘问题。有几个调优方向。第一把事务日志目录独立出来放在 IO 能力更强的盘上。第二检查forceSync参数默认是yes每次事务都强制落盘保证不丢数据如果对性能极端敏感又愿意承担少量丢失风险可以关掉但我不推荐。第三关注快照频率snapCount默认是每 10 万次事务做一次快照快照期间会有额外的 IO 开销数据量大的集群可以考虑调整这个值但要注意恢复时间和快照频率之间的权衡。还有一个常被忽略的点磁盘空间。事务日志不会自动清理成只保留最新而是通过快照和日志的配合来清理旧日志。如果快照频繁失败或者配置不合理事务日志可能一直堆积最终把磁盘写满然后整个集群挂掉。运维上必须给它配磁盘告警并且定期检查日志目录的大小。注意生产环境的 Zookeeper 集群不要和业务应用混部尤其是不要和高 IO 的应用放在同一块盘上。见过太多集群总是慢最后发现是隔壁进程在跑批量任务。最后分享一个我自己养成的小习惯任何用到 Zookeeper 的服务上线前都做一次杀 Leader和断网 30 秒的演练观察客户端状态机的变化、日志里出现的异常、业务是否正常降级。这两种故障能覆盖 80% 的实际问题演练一次比读十篇文档管用。做演练的时候记得把日志级别调成 DEBUG把每次状态切换都记下来事后复盘的时候这些日志就是最好的线索。
阅读完成 · 觉得有帮助?
咨询建站