跳至主要內容

十万个why:在 Nacos 点了下线,为什么流量还是打到了停机的机器上?

程序员小富大约 6 分钟

大家好,我是小富~

前两天面试问了一个非常日常的问题:

你们线上发版的时候,怎么保证旧服务下线时,用户的请求不报错?

他挺自信地回答:这很简单啊,去 Nacos 控制台找到那个实例,点一下下线按钮。或者在发布脚本里直接 kill -15 杀进程,Nacos 收不到心跳自然就把节点剔除了,网关就不会往这台机器发流量了。

额~

在真实的生产环境下,如果发布流水线真的只是这么干,那每次发版的一两分钟里,你们的网关日志里一定会刷出一堆 Connection Refused 或者 Read Timeout 的报错。

如果刚好赶上晚高峰,用户的直观体验就是:点了一下按钮,界面弹出一个醒目的网络异常

1. 流量为什么停不住?

很多同学对注册中心有一个误解,觉得它是一个绝对实时的系统:只要 Nacos 知道某个节点下线了,所有依赖的微服务就瞬间都知道了。

但在分布式系统里,信息的传递是需要时间的。

服务调用方比如 Gateway 网关在发起 HTTP 请求前,需要知道目标服务的 IP。但为了性能,网关不可能每来一个请求都去 Nacos 查一次网络。

会用像 Ribbon 或者 SpringCloud LoadBalancer 这样的客户端负载均衡组件,会在自己的内存里维护一个 ServerList 服务 IP 列表缓存

坑就踩在这个缓存的更新机制上:

  1. 调用方默认会启动一个后台定时任务,每隔一段时间去 Nacos 拉取最新的实例列表。

  2. 虽然 Nacos 服务端也有 UDP 实时推送变更的机制,但在复杂的生产网络里,UDP 推送是有概率丢失的。

  3. 这就导致了一个长达几十秒的时间差。

在这几十秒里,虽然 Nacos 服务端已经把那个节点标记为下线了,但网关本地的缓存还没刷新,网关手里拿着的依然是旧名单。

现在回想一下你在控制台点下线后的操作:

你调用了下线,紧接着就触发了重启(进程被杀掉)。但此时网关还没更新缓存,依然把用户的请求往这台已经死掉的机器上送。

操作系统一看端口都没人监听了,直接回一个 RST 包,网关马上就报 Connection Refused 了。

2. 中小团队的解法

知道了原因,解决思路也就有了。很多中小团队的做法是:在 CI/CD 发布流水线里加一行 sleep 40

流程变成了这样:

  1. 主动摘流,脚本先调 Nacos 的 OpenAPI,把机器标记为下线。

  2. 脚本强行 sleep 40 秒。在这 40 秒里,机器还在正常运行,依然在处理请求,只是我们在这 40 秒里等待全网所有的网关更新完缓存。

  3. 优雅停机 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 钩子 + 透明重试 + 优雅停机 的大厂实战组合拳打出来,面试官绝对会对你刮目相看。

上次编辑于: