7分钟
记一次线上排查:Nacos 节点下线失败(监听不生效)
一、问题背景
native-cloud-system 微服务体系中,SystemArgumentApplication(system-arg 分片 95)通过 DynamicRoutesCache 监听 Nacos 的 InstancesChangeEvent,用于动态维护下游业务子系统(如 credit-trade-system)的路由缓存,并在主备切换时更新主分片 URI。
现象:停止 CreditTradeApplication 后,system-arg 无法感知节点下线,路由缓存不更新,监听仿佛失效。
二、诡异的现象:三种场景相互矛盾
| 场景 | 表现 |
|---|---|
| run 启动 + do-while 注释 | 停止 CreditTrade,监听不生效 |
| run 启动 + do-while 保留(1s 延迟) | 监听正常生效 |
| debug 启动 + do-while 注释 | 监听正常生效 |
同样的代码、同样的操作,只差启动方式,结果截然不同——这是典型的时序 / 竞态问题,而不是简单的逻辑 bug。
三、排查过程
1. 初步怀疑:事件 serviceName 带 group 前缀,导致 name 匹配失败
run 模式日志显示 Nacos 客户端内部事件名格式为 native-cloud-system@@system-arg(带 group 前缀),而监听器里用的是纯名 equals 匹配,一度怀疑是匹配 bug。
[SUBSCRIBE-SERVICE] service:system-arg, group:native-cloud-system
init new ips(0) service: native-cloud-system@@system-arg -> []
2. 关键一步:加探测日志
在 onEvent 入口加无条件探测日志,用 run 模式复测,日志给出了决定性证据:
[ncesChangeEvent] =======探测: 收到 InstancesChangeEvent, serviceName=system-arg, hosts.size=0=======
[ingWorkerThread] init new ips(1) service: native-cloud-system@@credit-trade-system -> [8881 实例]
结论一:事件 serviceName 是纯名 system-arg(无 group 前缀),name 匹配正常,之前的怀疑作废。
结论二:onEvent 全程只被调用了一次,且是 hosts.size=0 的空实例事件! 之后 system-arg 上线(0→1)、credit-trade 下线(removed ips(1) 已到达客户端)均无任何探测日志、无异常——典型的"事件分发线程被无限循环卡死“特征。
四、根因分析
时序重建
- 启动早期
updateDynamicRoutes()中discoveryClient.getInstances("system-arg")触发订阅,此时本系统自己还没注册(日志:订阅在 39.867,注册在 40.816),Nacos 立即推送 0 实例 的初始事件。 registerSubscriber注册全局监听后(39.920),第一个到达的事件就是这个system-arg空实例事件(39.932)。- 该事件进入场景 1:过滤后实例列表仍为空,走到下方自旋:
URI masterURI = dynamicArbHandler.getMasterURI(key);
// 每个分片第一个节点上线的时候因为轮询机制存在延时,这里做一个 loop
while (masterURI == null || !isMasterAlive(instances, masterURI)){
masterURI = dynamicArbHandler.getMasterURI(key);
}
- 轮询器首轮尚未出结果 →
masterURI == null→ 条件成立; - 即便有值,
isMasterAlive(空列表, uri)内部用anyMatch判断,对空列表恒返回 false → 条件依旧成立; - 循环内无 sleep、无超时 → 无限忙等死循环。
为什么后续事件全部被丢弃
Nacos 的 NotifyCenter 是单线程分发 InstancesChangeEvent。这个 ncesChangeEvent 线程被卡死在空事件的自旋里后,事件队列不再被消费,之后 system-arg 上线、credit-trade 上下线等所有事件全部堆积丢弃——表现为"监听失效”。
三个现象的闭环解释
| 场景 | registerSubscriber 时机 | 第一个到达的事件 | 结果 |
|---|---|---|---|
| run + do-while 注释 | 早(39.920) | system-arg 空事件(39.932) | 卡死,监听失效 |
| run + do-while 存在 | 晚(+1s) | system-arg 上线事件(1 实例) | 躲过空事件,正常 |
| debug + do-while 注释 | 更晚(启动慢) | 跳过空事件,首个是上线事件 | 正常 |
do-while 的 1s 延迟真正的作用不是"等 masterHost",而是碰巧让注册晚于空事件发布、躲过了致命空事件——所以它存在时"生效",删掉就"失效"。debug 启动慢同理。
五、代码修改前后对比
修改前(onEvent 场景 1)
String name = instancesChangeEvent.getServiceName();
if (name.equals(applicationName)){
// 场景 1,下游业务子系统收到本分片的上下线事件
List<Instance> instances = instancesChangeEvent.getHosts();
// 筛选当前子系统中本分片的实例,比如 credit-trade-system子系统的 52分片的所有实例(后续可用于主备选举)
instances = instances.stream()
.filter(instance -> {
return instance.getServiceName().endsWith(applicationName);
})
.filter(instance -> {
return partitionNo.equals(Integer.valueOf(instance.getMetadata().get("partitionNo").toString()));
})
.collect(Collectors.toList());
// serviceName#partitionNo 作为缓存唯一键
String key = applicationName.concat("#").concat(partitionNo.toString());
// 下游业务子系统开启 EnableDynamicRoutes 说明是多分片主备模式,也必须开启 EnableDynamicArb 进行动态选举
// 可以直接从 DynamicArbHandler 获取当前应用分片的主节点
URI masterURI = dynamicArbHandler.getMasterURI(key);
// 每个分片第一个节点上线的时候因为轮询机制存在延时,这里做一个 loop
while (masterURI == null || !isMasterAlive(instances, masterURI)){
masterURI = dynamicArbHandler.getMasterURI(key);
}
log.info("维护{}的主分片 URI:{}", key, masterURI.toString());
routes.put(key, masterURI);
partitionInstances.put(key, instances);
String instanceList = instances.stream().map(vi -> {
return vi.getIp() + ":" + vi.getPort();
}).collect(Collectors.joining(","));
log.info("更新应用程序{}的路由,新的路由列表{}", key, instanceList);
}
修改后(onEvent 场景 1)
String name = instancesChangeEvent.getServiceName();
if (name.equals(applicationName)){
// 场景 1,下游业务子系统收到本分片的上下线事件
List<Instance> instances = instancesChangeEvent.getHosts();
// 筛选当前子系统中本分片的实例,比如 credit-trade-system子系统的 52分片的所有实例(后续可用于主备选举)
instances = instances.stream()
.filter(instance -> {
return instance.getServiceName().endsWith(applicationName);
})
.filter(instance -> {
return partitionNo.equals(Integer.valueOf(instance.getMetadata().get("partitionNo").toString()));
})
.collect(Collectors.toList());
// serviceName#partitionNo 作为缓存唯一键
String key = applicationName.concat("#").concat(partitionNo.toString());
// ====== 修复:空实例事件(启动初期本系统尚未注册时 Nacos 会推送 0 实例)绝不能进入下方自旋,
// 否则 isMasterAlive(空列表) 恒为 false,无限忙等会把 Nacos 唯一的事件分发线程卡死,
// 导致后续所有上下线事件全部丢失(表现为"监听失效")======
if (instances.isEmpty()){
// 启动初期缓存本就没有该分片,无需清理;真正全部下线时同样走这里,仅打印即可
log.info("收到本分片{}的空实例事件,当前无存活节点,本轮跳过路由更新", key);
return;
}
// 下游业务子系统开启 EnableDynamicRoutes 说明是多分片主备模式,也必须开启 EnableDynamicArb 进行动态选举
// 可以直接从 DynamicArbHandler 获取当前应用分片的主节点
URI masterURI = dynamicArbHandler.getMasterURI(key);
// 每个分片第一个节点上线的时候因为轮询机制存在延时,这里做一个 loop,加退避与超时上限,防止卡死事件分发线程
long deadline = System.currentTimeMillis() + 15000L;
while ((masterURI == null || !isMasterAlive(instances, masterURI)) && System.currentTimeMillis() < deadline){
try {
Thread.sleep(200L);
} catch (InterruptedException e){
Thread.currentThread().interrupt();
return;
}
masterURI = dynamicArbHandler.getMasterURI(key);
}
if (masterURI == null || !isMasterAlive(instances, masterURI)){
log.error("等待{}主分片 URI 超时,本轮路由缓存暂不更新", key);
return;
}
log.info("维护{}的主分片 URI:{}", key, masterURI.toString());
routes.put(key, masterURI);
partitionInstances.put(key, instances);
String instanceList = instances.stream().map(vi -> {
return vi.getIp() + ":" + vi.getPort();
}).collect(Collectors.joining(","));
log.info("更新应用程序{}的路由,新的路由列表{}", key, instanceList);
}
修改点归纳
- 根治:空实例事件直接
return——启动初期缓存本就没有该分片,无需清理;真正全部下线时同样安全,只打日志跳过。 - 防御:自旋加 200ms 退避 + 15s 超时,并处理中断——即使轮询器长时间未就绪等同类场景,也不会再卡死事件分发线程。
- 删除探测日志。
关于 do-while
它只是"碰巧用 1s 延迟躲过了那个致命空事件"才让人误以为有用。修复后空事件已不再致命,do-while 可以放心删除(当前保持注释状态即可)。
六、验证
保持 do-while 注释,run 启动 SystemArgumentApplication,等完全启动后停止 CreditTradeApplication,日志应出现:
收到本分片system-arg#95的空实例事件,当前无存活节点,本轮跳过路由更新 ← 启动早期正常跳过
-------收到额外监听的服务程序事件,服务程序名:credit-trade-system----- ← 停止 CreditTrade 后
检测到应用程序credit-trade-system已无存活节点,缓存路由表:...将全部下线!
出现这两条,说明监听链路已完全恢复。
七、经验总结
- Nacos 的
NotifyCenter事件分发是单线程的,任何一个监听器的死循环都可能拖垮所有事件分发——监听器代码必须保证不阻塞、不忙等。 - 空实例事件(0 实例)是合法的、必然会出现的(订阅后尚未注册、或集群全部下线时),业务代码必须显式处理,绝不能把空列表当作"还没有数据"直接进入依赖
anyMatch的逻辑(空列表anyMatch恒为 false)。 - 所有自旋循环都必须带退避与超时上限,否则一旦前置条件永不满足就是灾难。
- “加个延迟就能好"往往不是真正修复——延迟只是改变了竞态窗口,掩盖了真实的根因,去掉延迟后问题必定复现(debug/run 差异同理)。
- 排查此类"偶发失效"问题时,在事件入口加无条件探测日志是最快的一步定位手段:有日志 → 看匹配分支;无日志 → 看线程是否被卡死、事件是否根本没派发。