ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ZooKeeper 会话超时机制:从租约协商到临时节点清理的完整链路

ZooKeeper 会话超时机制:从租约协商到临时节点清理的完整链路 先说一个很容易让人困惑的事实ZooKeeper 的会话超时并不等于“连接断了多久才算超时”。它本质上是一份租约——客户端只要能在约定的超时周期内和服务端完成一次有效交互会话就继续有效连续超过一个周期没有任何交互服务端就会把会话判死并把该会话名下所有临时节点连根拔掉。很多线上事故比如 HBase RegionServer 集体下线、分布式锁瞬间全部失效、Kafka 频繁触发 rebalance追到根上往往都是三件事同时出错有人把 sessionTimeout 设成了服务端根本不接受的值有人忽略了协商后的真实生效值还有人没搞懂过期之后到底谁会删你的临时节点。这篇文章我会把 ZooKeeper 会话超时机制从配置、协商到过期处理的完整链路拆开讲清楚顺带附上我在生产环境里踩过的坑和排查思路。适合正在维护 ZooKeeper 集群的工程师、用 Kafka / HBase / Hadoop HA 的同学以及准备做分布式协调组件调优的人。文章不绕弯子直接按“建连→协商→过期→踩坑”的顺序来。1. 建连背后的完整链路会话超时到底在等谁的“心跳”1.1 从 new ZooKeeper() 到 ConnectRequest超时参数走了哪条路客户端代码里最常见的写法是ZooKeeper zk new ZooKeeper(zk1:2181,zk2:2181,zk3:2181, 10000, watcher);第二个参数 10000 就是我们传给客户端的 sessionTimeout单位是毫秒。这里很多人会混淆一个概念这个值并不是 TCP 的连接超时也不是“多少毫秒连不上就算失败”的网络超时。它只是一个等待协商的初始请求值最终生效值要等服务器回应之后才知道。构造函数内部会创建 ClientCnxn然后把 connectString 解析成一个包含多个 InetSocketAddress 的服务器列表同时启动 SendThread 和 EventThread 两个线程。SendThread 负责建连、发送心跳和请求EventThread 负责把 Watcher 回调串行化执行。首轮连接时SendThread 会向服务器发送一个 ConnectRequest里面携带了 sessionTimeout、上次的 sessionId 和 passwd。如果是全新会话sessionId 为 0。服务端收到 ConnectRequest 后会走 ZooKeeperServer.createSession() 这条链路先检查请求的 sessionTimeout 是否落在服务端允许的范围内然后生成全局唯一的 sessionId最后把“会话创建成功”的 ConnectResponse 返回给客户端。整个过程中客户端传入的 10000 只是一个“期望值”服务端完全有权修改它。1.2 会话活着的条件不是连接而是“有效交互”在 ZooKeeper 的模型里会话是否存活不取决于客户端进程是否存活也不取决于 TCP 连接是否保持着而是取决于“在一个超时周期内客户端和服务端之间是否发生过有效交互”。什么叫有效交互最常见的就是心跳 Ping。客户端在连接建立之后如果一段时间内没有发送任何请求SendThread 会主动发送一个 Ping 请求。服务端收到 Ping 后会刷新这个会话的租约期限把过期时间往后再推一个 timeout 周期。只要这个动作周期发生客户端即使一个业务请求都不发会话也能一直活着。反过来理解就很重要了如果客户端进程卡住了比如 JVM 长时间 Full GCTCP 连接还在服务端却收不到任何心跳那么经过一个 timeout 周期后服务端照样会把会话判死删除临时节点。这就是为什么很多分布式锁会在“进程假死”时失效——它本来就是设计成会失效的lease 机制的核心目的就是防止客户端崩溃后锁永远无法释放。1.3 谁在计时客户端与服务端的双轨时钟会话超时不是单方面计时而是客户端和服务端各自维护一条时间线。服务端的视角以最后一次收到该会话的请求通常为心跳为基准往后推 timeout 周期。如果到点前没有新的交互会话过期。客户端的视角以连接成功建立或最后一次收到服务端响应为基准往后推 timeout 周期。如果到点前既没有重连成功也没有收到任何响应客户端会主动把本地状态置为 EXPIRED不再继续重连。这两条时间线是独立的。也就是说服务端可能还在等你的下一次心跳客户端自己却已经认为会话过期了或者反过来客户端还认为自己活着服务端却早已把它清理干净。实际排障时最忌讳“只盯着一边看日志”必须两边时间戳对齐才能还原真相。2. 客户端想设3秒服务端却给了60秒协商机制的核心细节2.1 minSessionTimeout 和 maxSessionTimeout 是怎么“钳制”你的请求值的服务端在创建会话时会对客户端请求的 sessionTimeout 做一次强制处理逻辑非常简单粗暴sessionTimeout Math.min(maxSessionTimeout, Math.max(minSessionTimeout, requestedTimeout));也就是说你传入的值会被夹在 minSessionTimeout 和 maxSessionTimeout 之间。这两个配置项写在服务端的 zoo.cfg 里默认规则是配置项默认值含义tickTime2000msZooKeeper 全局时间粒度会话检查周期的基准minSessionTimeout2 * tickTime允许的最小会话超时低于此值会被强行抬高maxSessionTimeout20 * tickTime允许的最大会话超时高于此值会被强行压低按默认配置计算min 就是 4000msmax 就是 40000ms。所以如果你在客户端传了一个 3000ms 的 sessionTimeout服务端最终会把它改成 4000ms传一个 60000ms最终生效值是 40000ms。很多初学同学用zk.getSessionTimeout()一看返回值和自己传的不一样就以为是 bug其实这就是协商机制在起作用。需要注意minSessionTimeout 和 maxSessionTimeout 这两个显式配置项是较新版本才支持的。老版本里默认规则固定为 2 倍和 20 倍 tickTime没法通过配置文件覆盖。如果你维护的是老集群升级配置前要先确认版本支持情况。2.2 tickTime 才是幕后大佬被忽略的全局时间粒度tickTime 是 ZooKeeper 服务端最核心的时间基准远不止影响会话超时上下限。Leader 选举、SessionTracker 的检查周期、数据同步的心跳等等都围绕这个参数展开。默认 tickTime 是 2000ms。这意味着服务端的会话过期检查并不是精确到毫秒的而是每 2 秒批量扫一次看看哪些会话该死了。我用一个不太严谨但容易理解的类比哨兵每隔 2 分钟巡逻一次值班室看看谁超过下班时间还没打卡而不是每毫秒都盯着每个人。所以即使你的 sessionTimeout 精确设置了 9999ms服务端实际判定过期的时间也会落在 9999ms 到 9999ms tickTime 之间的某个检查点上。社区里经常有人问“为什么我设置了 10 秒超时结果 12 秒左右才触发过期事件”答案就在这里。这个延迟不是 bug而是 tick 机制天然带来的近似性。做超时敏感的分布式锁时心里要有一笔这种账。2.3 协商结果最终落谁的账本客户端收到 timeOut 之后服务端完成钳制后会把最终生效的 timeout 放到 ConnectResponse 里返回给客户端。客户端会在本地记录这个 negotiatedSessionTimeout之后所有的本地倒计时、Ping 频率策略都以这个值为准而不是最初传给构造函数的那个值。这里有个非常隐蔽的坑如果你通过 JMX 或运维脚本去查“客户端配置的 sessionTimeout”看到的是最初的请求值而通过getSessionTimeout()拿到的才是协商后的生效值。我曾经遇到过某团队排查超时事故时拿着两端配置对不上号折腾了半天才发现是协商机制造成的。下面这张表把涉及超时的关键配置项汇总一下排查时按这个清单去对基本不会漏配置项所在位置典型值作用tickTime服务端 zoo.cfg2000ms全局时间粒度决定 min/max 默认值minSessionTimeout服务端 zoo.cfg4000ms默认会话超时下限maxSessionTimeout服务端 zoo.cfg40000ms默认会话超时上限sessionTimeout客户端构造参数10000ms会话超时请求值会被钳制getSessionTimeout()客户端运行时 API协商后的值实际生效的会话超时zookeeper.session.timeout.msKafka/HBase 等客户端各不相同上层组件传入的会话超时请求值3. 过期不是“到时候才动手”服务端的定时判定与清理链路3.1 SessionTrackerImpl 和 ExpiryQueue 的排班机制服务端负责会话管理的是 SessionTrackerImpl它内部维护了一张会话与过期时间的映射。每当客户端发来一个心跳服务端就会调用 touchSession(sessionId, timeout)把这个会话的过期时间往后拨并放入新的到期队列位置。具体的数据结构不展开说太多但有一个设计值得讲ZooKeeper 用了一个“按时分桶”的 ExpiryQueue而不是一个全局的大顶堆。每个会话根据过期时间落到对应的桶里检查线程每隔 tickTime 醒一次只处理当前时间点之前的所有桶效率很高也避免了高精度定时器的大量开销。为什么这么设计因为 ZooKeeper 定位是一个高吞吐的协调服务它不可能为每个会话都维护一个精确的毫秒级定时器。会话数量一多精确计时器的开销是不可接受的。用固定 tick 批量检查是典型的“以一点延迟换整体吞吐”工程取舍。3.2 一次过期会话引发的连锁处理临时节点、Watcher 与 ACL当 SessionTracker 判定某个会话过期后会交给 ZooKeeperServer.expireSession() 处理。这里牵涉的清理工作比很多人想象得要多第一把这个 sessionId 从会话集合中移除所有已注册的 Watcher 清掉第二遍历 DataTree 中属于该 session 的所有临时节点逐个删除第三临时节点被删除后会触发相关 Watcher 事件比如注册了 exists 或 getData 监听的客户端会收到 NodeDeleted第四清理与该会话相关的 ACL 引用和缓存。临时节点删除那一步最为关键。举个例子你用 ZooKeeper 实现分布式锁在 /lock 下创建了临时顺序节点。服务端判定会话过期后会直接把 /lock/xxx 这个节点删掉。其他客户端对这个节点注册的 watcher 会立刻触发拿到 NodeDeleted 事件后开始抢锁。看起来一切正常但问题在于你的客户端进程可能还活着只是网络抖动或者 GC 停顿了一会儿。等它恢复过来发现自己持有的锁已经被剥夺了。这正是“会话过期导致分布式锁提前失效”的典型剧本。3.3 集群模式下为什么只有 Leader 能给会话“盖章”很多人以为每个 ZooKeeper 节点都是独立管理会话的实际上不是。集群模式下会话过期的判定权集中在 Leader 手里。客户端连上任意一个 Follower 后创建会话的请求都会被转发给 LeaderLeader 生成全局唯一 sessionId 并提交事务。正常情况下客户端的心跳 Ping 由所连接的 Follower 直接处理并 touchSession不需要每次都转发到 Leader。但是当某个会话需要被判定过期时只有 Leader 有权限“盖章”。Leader 判定过期后会通过事务把 expireSession 同步给集群里的所有 Learner各节点根据这个事务统一清理自己的会话缓存和临时节点。为什么非要这样因为临时节点的删除本质上是一次数据变更必须经过事务提交才能保证集群数据树一致。如果每个 Follower 都按照自己的节奏去判死会话、删除临时节点那么 Leader 和 Follower 的数据树很快就会分叉。让 Leader 统一裁决是 ZooKeeper 保证“临时节点是否存在”这个事实全局一致的基本前提。这也给我们一个排障启示如果生产环境出现“某客户端认为会话还活着但服务端早已过期”的情况日志里最先出现过期记录的节点大概率是 Leader。先看 Leader 的日志比翻所有节点更高效。4. 客户端视角的“自杀”机制本地过期检测与重连陷阱4.1 ClientCnxn 的三线程分工与本地倒计时客户端侧的核心类是 ClientCnxn它内部有三个线程SendThread、ReadThread 和 EventThread。SendThread 负责所有对外请求和心跳发送ReadThread 负责从 socket 上读取服务端响应EventThread 负责按顺序执行 Watcher 回调。会话超时的本地倒计时并不依赖独立定时器线程而是在 SendThread 的主循环里顺带检查每次循环时算出“最近一次有效响应时间 negotiatedTimeout”是否已经早于当前时间如果是就把客户端状态切到 EXPIRED并给 EventThread 扔一个状态为 Expired 的 WatchedEvent。这里有个容易被忽略的点客户端的本地过期判定只关心“读响应”不关心“发请求”。也就是说如果网络已经断了SendThread 发出的心跳和请求都只是丢进黑洞没有任何响应返回本地倒计时会照常走。这也是为什么不能靠“不断重连”来防止会话过期——倒计时一到客户端会主动放弃。4.2 服务端明明活着客户端却进了 EXPIRED 的几种场景场景一网络分区。客户端所在的机器和服务端之间的网络断了但服务端进程本身非常健康。客户端这边SendThread 不断尝试重连但全部失败本地倒计时归零客户端主动进入 EXPIRED。服务端那边由于收不到心跳也在差不多同一时间把会话判死。两边都认为是对方“失联”这就是分布式系统里经典的双边判死。场景二客户端 GC。客户端 JVM 发生长时间 Full GCSendThread、ReadThread 全部被暂停。GC 结束后线程恢复调度发现离本地超时已经只剩几毫秒或者已经归零于是立刻进入 EXPIRED。服务端呢在 GC 这段时间里同样没有收到任何心跳也会判会话过期。客户端 GC 导致的会话丢失在自研客户端里尤其常见很多人排查时只盯着 ZooKeeper 服务端日志却忽略了自己进程里的 GC 停顿。场景三连接被中间设备静默切断。比如防火墙或 LB 把空闲连接清掉了但 TCP 层没有触发 RST。客户端 ReadThread 一直阻塞在 read 上看起来连接还在实际上什么响应都等不到。本地倒计时一旦归零客户端就自己先“死”了。这种现象可以通过在客户端侧开启 TCP keepalive 值来缓解但本质上还是要靠“一个超时周期内有响应”这个机制兜底。针对场景一和场景三一个实用的调优思路是在客户端主动开启 TCP keepalive默认内核参数通常是 7200s太长并适当缩短重连间隔。很多自研封装的 ZooKeeper 客户端只配置了业务请求超时却忽略了 socket 层面的保活参数导致连接被静默回收后才开始重连白白浪费大量时间窗口。4.3 重连不是万能的SessionMovedException 与陈旧连接客户端在断线后会尝试重连重连的目标不一定是原来的服务器而是 connectString 里所有可达服务器中的任意一台。如果重连成功时本地倒计时尚未归零客户端会带着旧的 sessionId 和 passwd 发起 ConnectRequest服务端会恢复这个会话客户端重新进入 CONNECTED 状态。但如果服务端已经判定该会话过期或者客户端一直用同一个 sessionId 在不同连接上反复重连比如旧连接还没完全关闭新连接又建立成功就可能碰到 SessionMovedException。这个异常的本质是服务端发现同一个 sessionId 从两个不同连接上发来了请求新连接覆盖了旧连接。实际场景里更常见的问题是旧连接的读线程还阻塞在 socket 上新连接已经恢复结果两个线程同时处理数据业务层收到重复的 watcher 事件。解决思路很简单但有讲究客户端主动关掉旧 ZooKeeper 实例前要确保它的连接已经完全释放不要一边 close 一边又用同一个 sessionId 去新建连接。自研封装层里尤其容易踩这个坑。5. 生产环境超时事故复盘GC 停顿、网络分区与调优选择5.1 Full GC 引发的会话集体猝死一套完整的爆炸链我曾经参与过一起典型的 ZooKeeper 会话超时事故过程很有代表性。某业务集群的 ZooKeeper 节点堆内存设置偏小高峰时段发生了一次长达 20 秒以上的 Full GC。GC 期间服务端无法处理任何请求更不可能 touchSession。20 秒意味着什么对于大量使用默认 10 秒会话超时的客户端来说已经超过两个完整周期了。GC 恢复后服务端在 SessionTracker 的 tick 检查中一次性标记了数千个过期会话然后开始批量执行临时节点删除和 Watcher 下发。与此同时那些客户端在 GC 期间本地倒计时也已经归零纷纷进入 EXPIRED 并开始重连。爆炸链从这里开始大量客户端同时重连给 ZooKeeper 造成瞬时连接风暴大量临时节点被删除触发所有依赖临时节点的业务去抢锁、重建资源抢锁过程又创建新的临时节点进一步放大服务端压力。最要命的是部分上层组件比如 HBase RegionServer把 ZooKeeper 会话丢失视为“自己已经失联”的信号会启动自杀式重启。于是 ZooKeeper 的 GC 恢复后整个上层集群反而进入连环重启的混乱状态。这个事故给我们的教训是会话超时事故的根因往往不在 ZooKeeper 本身而在于“GC 停顿 超时过短 上层组件自杀策略”三者叠加。排查时不要只在 ZooKeeper 日志里找答案还要去看客户端 JVM 的 GC 日志。5.2 网络分区下的各判各死如何通过日志还原真相网络分区是另一类高频事故。当 ZooKeeper 集群内部出现分区或者客户端与部分节点之间网络隔离时客户端可能连上一个已经被分区出去的 Follower而这个 Follower 无法与 Leader 同步数据。这种情况下客户端向这个 Follower 发心跳时碰到的表现很反直觉连接能建立Ping 可能也有响应至少在分区刚发生的一小段时间内但服务端对这个会话的“总台账”却掌握在 Leader 手里。等 Leader 判定该会话过期并广播 expireSession 后客户端拿到的就是一个过期 sessionId。如果上层应用没有监听 Expired 事件往往会继续尝试写数据然后收到 KeeperState.SessionExpiredException一脸茫然。还原这种事故现场时我的建议是以 sessionId 为线索做全链路日志追踪。客户端日志里搜索 sessionId 和 “Expired” 关键字服务端日志里搜索同一个 sessionId 对应的 expireSession 记录。把两边的系统时间对齐基本就能判断出到底是“客户端先过期”还是“服务端先判死”。很多自研框架喜欢把 sessionId 打进业务日志就是因为排障时这个字段太有用了。5.3 会话存活率调优清单先别急着调大超时遇到会话超时事故大多数人的第一反应是把 sessionTimeout 调大比如从 10 秒调到 30 秒。这个操作有时候管用但很多时候只是掩盖问题甚至带来新的麻烦超时越长客户端进程崩溃后临时节点残留的时间就越久分布式锁和Leader选举的“故障切换窗口”就被拉得越长。下面是我在实际维护中总结的调优顺序建议按这个优先级来第一先看服务端 GC 是否健康。ZooKeeper 对 GC 停顿极度敏感如果 JVM 堆配置不合理调大超时只是把“今晚炸”变成“下个月炸”。第二确认 tickTime 是否被改动过。如果 tickTime 被调成了 3000msminSessionTimeout 和 maxSessionTimeout 的默认值就变成了 6s 和 60s原本合理的 5 秒超时会直接被改掉。第三统一客户端的 sessionTimeout。不要让每个业务线各传各的否则服务端要同时维护多种超时档位出现问题时很难评估全局影响。第四显式配置 minSessionTimeout 和 maxSessionTimeout。比如把 min 设为 10000ms可以防止开发同学随手传一个 1 秒超时的“自杀式”配置。第五客户端要监听 Expired 事件并做本地清理。很多分布式锁丢失问题的根源是业务层没监听过期事件锁被服务端删了本地还认为自己持有锁。调优方向推荐做法避免的做法服务端 JVM预留足够堆内存监控 Full GC用超时掩盖 GC 问题tickTime维持默认 2000ms除非全局需要单独为了会话超时改 tickTime客户端超时统一 10s~30s与 max 对齐各业务线各设各的min/max显式设置保护不合理的请求值完全依赖默认值应用侧监听 Expired清理本地锁标记忽略 Watcher 状态最后分享一个我个人的经验如果你不确定当前集群到底接受了什么范围的会话超时去服务端 JMX 里直接看 MinSessionTimeout 和 MaxSessionTimeout 这两个属性一目了然比翻配置文件更快。还有一点排查超时问题时一定要顺手看客户端的 GC 日志。很多次我以为 ZooKeeper 出了问题最后追根溯源都落在客户端进程自己的停顿上。记住这个教训能帮你省下大量排查时间。
返回列表