十万个why:在 Nacos 点了下线,为什么流量还是打到了停机的机器上?
大家好,我是小富~
前两天面试问了一个非常日常的问题:
你们线上发版的时候,怎么保证旧服务下线时,用户的请求不报错?
他挺自信地回答:这很简单啊,去 Nacos 控制台找到那个实例,点一下下线按钮。或者在发布脚本里直接 kill -15 杀进程,Nacos 收不到心跳自然就把节点剔除了,网关就不会往这台机器发流量了。
额~
在真实的生产环境下,如果发布流水线真的只是这么干,那每次发版的一两分钟里,你们的网关日志里一定会刷出一堆 Connection Refused 或者 Read Timeout 的报错。
如果刚好赶上晚高峰,用户的直观体验就是:点了一下按钮,界面弹出一个醒目的网络异常。
1. 流量为什么停不住?
很多同学对注册中心有一个误解,觉得它是一个绝对实时的系统:只要 Nacos 知道某个节点下线了,所有依赖的微服务就瞬间都知道了。
但在分布式系统里,信息的传递是需要时间的。
服务调用方比如 Gateway 网关在发起 HTTP 请求前,需要知道目标服务的 IP。但为了性能,网关不可能每来一个请求都去 Nacos 查一次网络。
会用像 Ribbon 或者 SpringCloud LoadBalancer 这样的客户端负载均衡组件,会在自己的内存里维护一个 ServerList 服务 IP 列表缓存。
坑就踩在这个缓存的更新机制上:
调用方默认会启动一个后台定时任务,每隔一段时间去 Nacos 拉取最新的实例列表。
虽然 Nacos 服务端也有 UDP 实时推送变更的机制,但在复杂的生产网络里,UDP 推送是有概率丢失的。
这就导致了一个长达几十秒的时间差。
在这几十秒里,虽然 Nacos 服务端已经把那个节点标记为下线了,但网关本地的缓存还没刷新,网关手里拿着的依然是旧名单。

现在回想一下你在控制台点下线后的操作:
你调用了下线,紧接着就触发了重启(进程被杀掉)。但此时网关还没更新缓存,依然把用户的请求往这台已经死掉的机器上送。
操作系统一看端口都没人监听了,直接回一个 RST 包,网关马上就报 Connection Refused 了。
2. 中小团队的解法
知道了原因,解决思路也就有了。很多中小团队的做法是:在 CI/CD 发布流水线里加一行 sleep 40。
流程变成了这样:
主动摘流,脚本先调 Nacos 的 OpenAPI,把机器标记为下线。
脚本强行
sleep 40秒。在这 40 秒里,机器还在正常运行,依然在处理请求,只是我们在这 40 秒里等待全网所有的网关更新完缓存。优雅停机 40 秒一过,没人往这发请求了,再发送
kill -15杀进程。
这套方案确实管用。但如果你拿着这个方案去大厂面试,依然是不及格的。原因很简单:太慢了且不具备普适性。
如果一个核心服务有 500 个节点,滚动发布时每个节点都要干等 40 秒,发一次版要好几个小时。
而且单纯靠等,根本无法应对物理机突然宕机、网络抖动等突发情况,因为机器意外宕机时,你根本没机会去 sleep。
3. 大厂的无损下线方案
第一步编排层摘流
正规一点的公司下线动作不该由 Jenkins 脚本控制,而应该与 K8s 的 Pod 生命周期深度绑定。
当 K8s 决定缩容或更新一个 Pod 时,它不会立刻发送 kill -15,而是会先触发一个生命周期前置钩子 PreStop Hook。在 K8s 的 yaml 里这么写:
lifecycle:
preStop:
exec:
command:
- /bin/sh
- -c
- |
# 1. 立即向 Nacos 发送下线请求,主动摘除自己
curl -X PUT "http://nacos-server:8848/nacos/v1/ns/instance?serviceName=my-service&ip=${POD_IP}&port=8080&enabled=false"
# 2. 象征性地短暂停顿 3~5 秒,给 Nacos 的 UDP 主动推送一点时间
sleep 5
这一步把主动下线的逻辑封装在了容器内部,只要 Pod 要死,临死前第一件事就是去注册中心销户。
第二步客户端重试
即使有 PreStop,依然无法 100% 避免在那几秒钟里,有残余的请求顺着网关的旧缓存打过来。
当这台机器停止接收新请求时,网关打过来的请求会立刻收到操作系统的 Connection Refused。注意这个极其关键的细节:ConnectException 发生在 TCP 三次握手阶段,此时 HTTP 业务数据根本还没有发出去!
既然业务没执行,那这个请求就是绝对安全、绝对幂等的。
所以我们在网关或上游微服务里,必须开启底层的重试机制。以 SpringCloud 为例,只要加上:
spring:
cloud:
loadbalancer:
retry:
enabled: true # 开启客户端重试
此时发生的奇迹是:
网关拿着旧 IP 发请求 -> 碰壁报错 Connection Refused -> 网关底层的 LoadBalancer 捕获到连接异常,默默把它掐掉,然后自动从缓存里换另外一个健康的 IP 重新发起请求。
整个过程在几毫秒内完成,最终用户在手机上看到的,就是一个正常的 200 OK,用户是无感知的。
第三步应用的优雅停机
有了前面两步,进入这台机器的新请求已经被完美挡住并重试了。最后是处理那些已经接进来了,正在工作的老请求。
在 Spring Boot 项目的 application.yml 里,加上这两行配置:
server:
shutdown: graceful # 开启优雅停机
spring:
lifecycle:
timeout-per-shutdown-phase: 30s # 最多等老请求 30 秒
配合 K8s 的停机机制,Java 进程在等待一段时间后,然后彻底释放资源。
4. 一图看懂大厂无损下线全流程
为了让你看得更直观,我画了这张涵盖了 K8s + Nacos + 客户端重试 的联动时序图。

总结
微服务架构就像是一个庞大且精密运转的齿轮组。当你想要抽走其中一个齿轮时,如果只关注“注册中心”,那是远远不够的。
真正的解决方案是立体的:
- 编排层(K8s PreStop):负责第一时间主动向 Nacos 报告下线,尽可能缩短信息差。
- RPC 层(透明重试):这是兜底的核心。接受“状态必然有延迟”的现实,用连接异常触发重试,把错误在系统内部消化掉,绝不暴露给用户。
- 应用层(优雅停机):负责善后,确保已经接进来的业务数据完整落库。
下次面试如果再被问到服务如何下线,不要再说“点一下下线”或者“sleep 30秒”了。把这套 K8s 钩子 + 透明重试 + 优雅停机 的大厂实战组合拳打出来,面试官绝对会对你刮目相看。
