深蓝黑背景封面。左侧是一条带圆点的竖向时间线,五个步骤自上而下:第一步青色圆点「服务节点上线:在 /servers 下创建临时节点」;第二步「管理端注册 Watcher,监听子节点变化」;第三步「节点宕机或网络分区,会话心跳中断」;第四步紫色圆点「会话超时,ZK 自动删除临时节点」;第五步「Watcher 触发,管理端收到下线通知」。底部一句话「注册即上线标记,删节点即下线信号」。右侧大标题「Zookeeper 怎么感知集群里节点的上下线」,副标题「临时节点 + Watcher + 会话超时,三件套协同」。左上标注「面试经典 · 分布式协调」,右上圆角胶囊标注「Day 09 · 11-26」。 图 1:本文封面,节点上下线感知的五步时间线。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「Zookeeper 怎么感知节点上下线」表面是集群管理题,实际考的是三个原语的协同:临时节点、Watcher、会话超时。单个讲都不难,面试官要的是它们怎么拼成一套完整的存活性检测机制。推理服务的节点注册发现、训练任务的主节点选举,用的都是这套原语组合。

第一步:上线即注册,注册用临时节点。

每个服务节点启动后,先与 ZooKeeper 建立一个会话(session),然后在约定路径(比如 /servers)下创建一个代表自己的子节点,比如 /servers/node-1,节点里可以存自己的地址和端口。关键在节点类型的选择:必须创建临时节点(ephemeral node)。按 ZooKeeper 官方文档的定义,临时节点的生命周期绑定在创建它的会话上——会话结束,节点自动删除,而且临时节点不允许有子节点。这个「绑定会话」的语义就是天然的存活性标记:节点在,说明服务在;节点没了,说明服务没了。如果用持久节点,进程宕机后节点还留在树上,管理端根本分不出死活。

第二步:管理端挂 Watcher,变更是推过来的。

管理端(调度器、负载均衡器、监控系统等)对 /servers 调用 getChildren 并同时注册一个 Watcher。Watcher 是 ZooKeeper 的变更通知机制:被监听的节点数据或子节点列表发生变化时,服务端主动向注册方推送事件,管理端不用轮询。要特别注意 Watcher 的一个经典语义——一次性触发:事件送达后这个 Watcher 就被移除,想继续收下一次变更,必须在处理回调里重新注册。很多人背了「Watcher 监听变化」却答不出一次性,这正是面试官爱追的坑。

第三步:宕机的本质是会话断掉。

服务节点与 ZooKeeper 之间靠心跳维持会话。节点宕机、进程被杀或网络分区,心跳就停了。ZooKeeper 服务端为每个会话设了超时时间,超时未收到心跳就判定会话结束,然后做两件事:删除这个会话创建的所有临时节点,并触发这些节点上的 Watcher。于是 /servers/node-1 消失,管理端收到 NodeChildrenChanged 事件,重新 getChildren 拿到最新列表,下线感知完成。整条链路里管理端没有主动问过任何一次「你还活着吗」,全是事件驱动的。

一个必须讲清的边界:感知有延迟,也可能误判。

会话超时通常配置在秒级到几十秒级,所以「下线感知」不是实时的,而是「超时后可知」。网络抖动时节点可能其实活着但被判死,随后它重连上新会话、重新注册临时节点,又会触发一次上线通知。工程上要给管理端的列表刷新逻辑做好幂等,并容忍这种短暂的假死。能把这层权衡讲出来,说明你不是只背了流程图。

用代码把三件套的协同跑一遍。

下面这段 Node.js 22.15.0 程序(node zk_sim.js)用几十行代码模拟整个机制:临时节点绑定会话、Watcher 一次性触发、超时删除与通知。

const zk = {
  nodes: new Map(),        // path -> { ephemeral, sessionId }
  watchers: [],            // 一次性 Watcher 列表
  create(path, meta) { this.nodes.set(path, meta); this.fire(); },
  delete(path) { this.nodes.delete(path); this.fire(); },
  getChildren(dir, cb) {
    if (cb) this.watchers.push(cb);           // 挂一次性 Watcher
    return [...this.nodes.keys()].filter(p => p.startsWith(dir) && p !== dir);
  },
  fire() { const cbs = this.watchers; this.watchers = []; cbs.forEach(cb => cb()); },
};
const sessions = new Map();                    // sessionId -> 最后心跳时间
function heartbeat(id) { sessions.set(id, Date.now()); }
function checkTimeouts(timeoutMs) {
  for (const [id, t] of sessions) {
    if (Date.now() - t > timeoutMs) {
      sessions.delete(id);
      console.log(`[ZK] 会话 ${id} 心跳超时,删除其临时节点`);
      for (const [p, n] of [...zk.nodes]) if (n.ephemeral && n.sessionId === id) zk.delete(p);
    }
  }
}
const refresh = () => {
  console.log('[管理端] Watcher 触发,刷新列表:', zk.getChildren('/servers'));
  zk.getChildren('/servers', refresh);        // 一次性,要重新注册
};
zk.getChildren('/servers', refresh);
heartbeat('s1'); zk.create('/servers/node-1', { ephemeral: true, sessionId: 's1' });
heartbeat('s2'); zk.create('/servers/node-2', { ephemeral: true, sessionId: 's2' });
const alive = setInterval(() => heartbeat('s2'), 20);   // node-2 持续心跳;node-1 失联
setTimeout(() => checkTimeouts(60), 100);
setTimeout(() => { checkTimeouts(200); clearInterval(alive); }, 250);

实测输出:

[管理端] Watcher 触发,刷新列表: [ '/servers/node-1' ]
[管理端] Watcher 触发,刷新列表: [ '/servers/node-1', '/servers/node-2' ]
[ZK] 会话 s1 心跳超时,删除其临时节点
[管理端] Watcher 触发,刷新列表: [ '/servers/node-2' ]

node-1 心跳停止后被判超时、节点删除、管理端收到通知;持续心跳的 node-2 安然无恙——三件套的分工一目了然。

深蓝黑背景概念图。左侧是一张三生命线时序图:服务节点、ZooKeeper、管理端三条竖虚线。消息自上而下:服务节点向 ZooKeeper 发「创建会话,注册临时节点 /servers/node-1」;管理端向 ZooKeeper 发「getChildren 并挂 Watcher」,ZooKeeper 返回当前节点列表;随后服务节点生命线旁标注紫色「宕机,心跳停止」;ZooKeeper 内部自循环标注「会话超时,删除临时节点」;ZooKeeper 再向管理端推送「NodeChildrenChanged 事件」;管理端自循环标注「刷新可用节点列表」。右侧三张原语卡片:临时节点——绑定会话,断开自动删除,天然存活性标记;Watcher——一次触发,变更主动推送,免去轮询;会话超时——心跳维持,超时即判离线。底部总结条:「组合语义:注册即上线标记,删节点即下线信号,Watcher 负责送达」。 图 2:临时节点、Watcher、会话超时三件套的协同时序。

面试现场的答题结构。

先给场景:集群管理要回答「谁在线」;再讲三件套各自语义——临时节点绑定会话、Watcher 一次性推送、会话超时判死;然后串成五步时序:注册、监听、失联、删节点、收通知;最后补边界:感知延迟与误判幂等。这套「原语组合解决场景问题」的答法,同样适用于分布式锁、配置管理等其他 ZK 考点。你在生产里用 ZooKeeper 做过服务发现吗?会话超时一般配多少?欢迎聊聊。