有一次做服务滚动发布,运维同事在 Nacos 控制台点了一个旧节点的“下线”,然后照例把这个进程直接 kill 掉。结果监控上出现一个让所有人愣住的现象:Nacos 服务列表里明明已经没有这台机器了,下游服务的流量却还在不停往这台已经停机的机器上打,后台日志全是 connect refused。注册中心下线后流量不摘除的问题,远比想象中常见。尤其是当服务停机和注册中心状态变更之间隔着好几层缓存时,这个“下线不生效”的窗口会被拉得很长,严重的时候甚至能造成好几分钟的接口故障。
这篇文章就以这次故障为引子,把 Nacos 从“点下线”到“流量实际上不再进入”这条完整链路拆开讲清楚:为什么会出现这个时间差?注册中心、消费者客户端、负载均衡缓存各自扮演了什么角色?以及后来我们沉淀下来的一套摘流标准动作。
1. 事故现场:一台已经“下线”的机器,为什么还在被持续调用
1.1 一次普通发布,换来五分钟线上故障
那是一个再普通不过的晚间发布窗口。order-service 版本从 v2.3.1 滚动升级到 v2.3.2,总共四台机器,IP 分别是 10.0.0.21 到 10.0.0.24。发布到第三台 10.0.0.23 时,新版本内存曲线异常,决定先回滚这台机器。
运维的操作顺序是:先在 Nacos 控制台的服务列表里找到 10.0.0.23,点击“下线”,看到实例状态变成灰色,然后执行 kill -9 把 Java 进程杀掉,准备重新启动旧版本。
真正的麻烦从 kill 之后开始。监控面板显示,10.0.0.23 这台机器在进程被 kill 之后,仍然持续收到来自各消费方的 RPC 请求。请求集中在 order-service 的几个消费方应用上,表现为连接超时和 TCP 层 reset。这个过程并不是几秒就结束,而是断断续续持续了大约四分钟,直到部分消费方进程因为重试次数达到上限抛异常,触发集群侧的降级和切换,流量才彻底干净。
1.2 三个视角看同一台机器,状态完全对不上
当时我们几乎同时查了三处状态,发现每一处给出的答案都不一样。
第一处是 Nacos 控制台和开放 API。服务列表里已经看不到 10.0.0.23,至少从注册中心服务端的视图来看,这个实例已经不存在了。第二处是消费方应用的内存状态。我在还在持续报错的消费方机器上打印了当前可用服务提供者列表,发现 10.0.0.23 赫然在列,而且被标记为健康。第三处是这台“已下线”的物理机本身。ps 确认 Java 进程已经不存在,ss -lntp | grep 8080 也没有任何进程监听。
这三个状态放在一起,答案就水落石出了:服务端认为实例已经下线,消费者认为实例仍然健康可用,而真实进程已经死亡。故障的根源,就是这三个状态从“不一致”到“收敛一致”所需要的那段时间。
1.3 第一步取证:在消费方机器上打印本地实例列表
发生这种问题,我的建议永远是先别急着重启消费方,也别先去翻 Nacos 服务端日志,而是先到报错的消费方机器上,把本地 JVM 里缓存的实例列表打出来看一眼。
java复制// 在消费方应用所在环境执行,不要在本机连生产 Nacos
NamingService naming = NamingFactory.createNamingService("127.0.0.1:8848");
List<Instance> instances = naming.selectInstances("order-service", true);
for (Instance in : instances) {
System.out.printf("%s:%d healthy=%s enabled=%s weight=%.2f%n",
in.getIp(), in.getPort(), in.isHealthy(), in.isEnabled(), in.getWeight());
}
注意 selectInstances 的第二个参数传的是 true,表示只要健康实例。如果这台消费方本地列表里还能打印出 10.0.0.23,而且 healthy=true,那就说明客户端侧的缓存没有被 Nacos 服务端的“下线”事件及时纠正。
再对比服务端的实例列表:
bash复制curl "http://${NACOS_SERVER}:8848/nacos/v1/ns/instance/list?serviceName=order-service"
服务端返回的 JSON 里已经没有 10.0.0.23 的踪迹。到这一步,问题链路基本可以锁定为:服务端状态已经变更,但变更事件没有及时到达消费方,或者到达了但没有驱动消费方内部的负载均衡缓存刷新。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Nacos “下线”操作的真实语义,和你想的可能不太一样
2.1 临时实例靠心跳生存,不是靠控制台按钮删除
很多团队把 Nacos 控制台的“下线”按钮理解为“立刻把实例从注册表中删掉”。这个理解不能说完全错误,但对临时实例(ephemeral=true)来说,它其实没有那么强的删除语义。
Nacos 里最常见的实例类型是临时实例。临时实例的生命周期由客户端心跳维持,客户端每隔几秒向服务端上报一次心跳,服务端如果在超时时间内没有收到心跳,才会把这个实例标记为不健康,再过一段时间才彻底摘除。控制台点击“下线”,本质是修改实例的元数据状态并触发一次事件推送,而不是代替客户端完成一次“反注册”。
如果实例进程在点击下线后仍然活着,它还可能继续上报心跳。在某些版本和配置下,一个被标记为 disabled 的实例一旦心跳恢复,服务端视图会认为它仍然是注册表中的一员。也就是说,你点“下线”,可能只是把它从“路由候选”里暂时推开,而不是真正从注册中心抹掉。
2.2 “通知订阅方”才是下线的关键路径
真正要让流量不再打到一个实例上,只改 Nacos 服务端的视图没有用,必须让所有订阅该服务的消费方都收到“这个实例不可用了”的事件,并刷新本地缓存。
完整链路是这样的:服务提供方实例停止或反注册,或者被服务端判定心跳超时;Nacos Server 更新服务列表;Server 向所有订阅该服务的消费方推送实例变更事件;消费方收到事件后,更新本地缓存的服务列表;负载均衡器在下次选择节点时,把旧实例从候选集合里排除。
这条链路上任何一环出现延迟,流量都会继续打到旧实例上。控制台点“下线”没有任何魔法,它只是这条链路的起点。生产环境里最典型的情况是:服务端的实例列表已经更新了,但消费方的客户端因为各种原因没有及时收到推送,或者收到了推送却没有触发下游负载均衡器刷新缓存。
2.3 Nacos 2.x 已经把推送链路做扎实了
很多团队在 2023 年后已经升级到 Nacos 2.x,但如果你还在用 1.x 或者客户端与服务端版本不统一,推送链路的可靠性差异非常明显。
| 对比项 | Nacos 1.x 老链路 | Nacos 2.x gRPC 链路 |
|---|---|---|
| 注册通道 | HTTP 接口,注册后靠心跳续约 | gRPC 长连接,连接即注册 |
| 推送通道 | UDP 推送 | gRPC 长连接双向流 |
| 丢包后的兜底 | 依赖客户端定时轮询 | 长连接有序可靠,断线自动重连 |
| 端口要求 | 需要 8848 等 HTTP 端口 | 需要额外开放 9848 等 gRPC 端口 |
| 控制台下线生效速度 | 可能受 UDP 丢包影响,明显变慢 | 正常情况下秒级生效 |
如果你的 Nacos Server 已经是 2.x,但业务服务还在用 1.x 的 nacos-client,那么推送链路仍然可能走老的 HTTP + UDP 模式。UDP 推送有一个臭名昭著的问题:丢包不重试。一旦那次“实例下线”的推送在网络传输中丢了,消费方就只能靠自己的定时轮询去服务端拉最新列表,而轮询周期通常是秒级到十秒级,这中间的窗口足够让流量打到停机实例上。
所以我一直强调:Nacos 服务端升级到 2.x 只是第一步,客户端 nacos-client 以及 Spring Cloud Alibaba / Dubbo 适配层的版本也要一起对齐,否则新链路根本跑不起来。
3. 三个叠加的时间窗口:从 Provider 死亡到 Consumer 切换
3.1 窗口一:kill -9 与心跳超时的空窗
先看这次故障里最容易被忽视的一步——运维直接用了 kill -9。
Java 应用正常退出时,Spring 容器会执行销毁逻辑,Spring Cloud Alibaba 的 NacosServiceRegistry 会在 @PreDestroy 阶段调用反注册接口,告诉 Nacos Server“这个实例要走了”。但如果直接 kill -9,进程没有机会执行任何清理逻辑,Nacos 客户端自然不会上报反注册请求。
这时,Nacos Server 只能靠心跳超时来判断实例死亡。默认配置下,心跳超时时间通常是 15 秒左右。也就是说,即使你在控制台点击了下线,在 15 秒内服务端视图可能仍然认为实例是存在的,只是处于不健康或待淘汰状态。
更麻烦的是,消费方客户端在拿到实例列表时,很多框架并不严格过滤“不健康”状态,尤其是当这个实例只是被标记为 disabled 而进程本身仍在心跳续约时,消费方会认为它“活着且可用”,继续向它发起调用。
3.2 窗口二:消费者本地缓存刷新有延迟
即便 Nacos Server 已经把实例状态更新并推送了事件,消费方也不是瞬间就能完成切换。
Nacos 1.x 客户端订阅服务变更依赖 UDP 推送加轮询兜底。UDP 丢包后,消费方要等下一次轮询才能发现服务列表已经变化。如果团队对 nacos-client 没有做额外调优,这个轮询间隔可能在 5 到 10 秒左右。
而 Spring Cloud 体系的消费方还有第二层缓存:Spring Cloud LoadBalancer 会缓存服务实例列表,默认缓存时间在某些版本里甚至超过 30 秒。Nacos 客户端收到事件后通知 LoadBalancer 刷新,这个刷新动作如果没接好,负载均衡器仍然会闷头使用旧列表。
这次事故里的消费方恰好是老版本 Spring Cloud Alibaba,Ribbon 的 ServerList 刷新策略没有针对 Nacos 做动态监听。结果就是 Nacos 客户端已经收到了服务端推送,但 Ribbon 的 ServerListCache 还抱着 10.0.0.23 不放。
3.3 窗口三:已建立的长连接还会继续转发
还有一种更容易被忽略的情况:服务列表其实已经更新了,但流量还是打到了旧机器上。这往往是因为消费
