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

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

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

他挺自信地回答:这很简单啊,去 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 脚本控制,而应该与 K8sPod 生命周期深度绑定。

当 K8s 决定缩容或更新一个 Pod 时,它不会立刻发送 kill -15,而是会先触发一个生命周期前置钩子 PreStop Hook 。在 K8s 的 yaml 里这么写:

bash 复制代码
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 为例,只要加上:

yaml 复制代码
spring:
  cloud:
    loadbalancer:
      retry:
        enabled: true # 开启客户端重试

此时发生的奇迹是:

网关拿着旧 IP 发请求 -> 碰壁报错 Connection Refused -> 网关底层的 LoadBalancer 捕获到连接异常,默默把它掐掉,然后自动从缓存里换另外一个健康的 IP 重新发起请求。

整个过程在几毫秒内完成,最终用户在手机上看到的,就是一个正常的 200 OK,用户是无感知的。

第三步应用的优雅停机

有了前面两步,进入这台机器的新请求已经被完美挡住并重试了。最后是处理那些已经接进来了,正在工作的老请求。

在 Spring Boot 项目的 application.yml 里,加上这两行配置:

yaml 复制代码
server:
  shutdown: graceful # 开启优雅停机
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s # 最多等老请求 30 秒

配合 K8s 的停机机制,Java 进程在等待一段时间后,然后彻底释放资源。

4. 无损下线全流程

为了让你看得更直观,我画了这张涵盖了 K8s + Nacos + 客户端重试 的联动时序图

相关推荐
阳光是sunny1 小时前
LangGraph实战教程:一文搞懂图的状态(State)管理
前端·人工智能·后端
whyfail2 小时前
前端学 Spring Boot(5):从“你是谁”到“服务真的上线了”
前端·spring boot·后端
2601_953824612 小时前
【计算机毕业设计】基于Spring Boot的画师接稿平台设计与实现
java·spring boot·后端
环境栈笔记2 小时前
高性价比指纹浏览器推荐与选型:如何对照价格和实际可用功能筛选候选
前端·人工智能·后端·自动化
卷无止境2 小时前
Python虚拟环境江湖:从venv到uv,如何避开依赖冲突的坑
后端·python
卷无止境3 小时前
模块与包:Python 代码组织的两层逻辑
后端·python
明月_清风3 小时前
🛡️ Web3 安全入门:新手防骗完全指南
后端·web3
明月_清风3 小时前
🔐 Solidity 完全指南:关键语法解析与智能合约中的核心作用
后端·web3
geovindu4 小时前
CSharp: Iterative Algorithms
开发语言·后端·算法·c#·.net·迭代算法