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