一、问题背景

native-cloud-system 微服务体系中,SystemArgumentApplicationsystem-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) 已到达客户端)均无任何探测日志、无异常——典型的"事件分发线程被无限循环卡死“特征。

四、根因分析

时序重建

  1. 启动早期 updateDynamicRoutes()discoveryClient.getInstances("system-arg") 触发订阅,此时本系统自己还没注册(日志:订阅在 39.867,注册在 40.816),Nacos 立即推送 0 实例 的初始事件。
  2. registerSubscriber 注册全局监听后(39.920),第一个到达的事件就是这个 system-arg 空实例事件(39.932)。
  3. 该事件进入场景 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);
}

修改点归纳

  1. 根治:空实例事件直接 return——启动初期缓存本就没有该分片,无需清理;真正全部下线时同样安全,只打日志跳过。
  2. 防御:自旋加 200ms 退避 + 15s 超时,并处理中断——即使轮询器长时间未就绪等同类场景,也不会再卡死事件分发线程。
  3. 删除探测日志

关于 do-while

它只是"碰巧用 1s 延迟躲过了那个致命空事件"才让人误以为有用。修复后空事件已不再致命,do-while 可以放心删除(当前保持注释状态即可)。

六、验证

保持 do-while 注释,run 启动 SystemArgumentApplication,等完全启动后停止 CreditTradeApplication,日志应出现:

收到本分片system-arg#95的空实例事件,当前无存活节点,本轮跳过路由更新   ← 启动早期正常跳过
-------收到额外监听的服务程序事件,服务程序名:credit-trade-system-----   ← 停止 CreditTrade 后
检测到应用程序credit-trade-system已无存活节点,缓存路由表:...将全部下线!

出现这两条,说明监听链路已完全恢复。

七、经验总结

  1. Nacos 的 NotifyCenter 事件分发是单线程的,任何一个监听器的死循环都可能拖垮所有事件分发——监听器代码必须保证不阻塞、不忙等。
  2. 空实例事件(0 实例)是合法的、必然会出现的(订阅后尚未注册、或集群全部下线时),业务代码必须显式处理,绝不能把空列表当作"还没有数据"直接进入依赖 anyMatch 的逻辑(空列表 anyMatch 恒为 false)。
  3. 所有自旋循环都必须带退避与超时上限,否则一旦前置条件永不满足就是灾难。
  4. “加个延迟就能好"往往不是真正修复——延迟只是改变了竞态窗口,掩盖了真实的根因,去掉延迟后问题必定复现(debug/run 差异同理)。
  5. 排查此类"偶发失效"问题时,在事件入口加无条件探测日志是最快的一步定位手段:有日志 → 看匹配分支;无日志 → 看线程是否被卡死、事件是否根本没派发。