基于本文回答

播面 播面

文图音视,全方位拆解八股文
0
评论

在微服务发版更新时,如何实现平滑发布(优雅停机),避免正在处理的请求被强制中断?

知识点图片

在微服务架构中,实现平滑发布(优雅停机,Graceful Shutdown)是一个涉及多层组件协同的系统性工程。如果仅仅在代码层面上配置停机,往往无法避免请求报错。

要实现真正的零掉单、零报错,需要从应用层、注册中心、网关层、容器编排(K8s)以及异步任务五个维度进行配合。

以下是实现微服务优雅停机的标准方案和最佳实践:


一、 核心逻辑与时序(标准流程)

优雅停机的核心原则是:先切断新流量,再处理老流量,最后释放资源。

完整的停机时序如下:

  1. 触发停机:系统或容器(如 K8s)发出停机指令。
  2. 服务下线:主动向注册中心(如 Nacos/Eureka)注销自己,告诉其他服务不要再路由过来。
  3. 等待同步:等待一段“缓冲时间”,让网关和其他服务刷新路由列表。
  4. 拒绝新请求:Web 容器(如 Tomcat)或 RPC 框架停止接受新连接。
  5. 处理存量:等待正在处理的请求和消息队列任务执行完毕。
  6. 释放资源:关闭数据库连接池、Redis 连接、线程池等。
  7. 彻底退出:进程安全结束。

二、 各层的具体实现配置

1. 应用层:开启 Web 容器的优雅停机

以最常用的 Spring Boot 为例,默认情况下 Tomcat 是直接强制关闭的。需要开启配置让其等待存量请求处理完。

application.yml 配置:

yaml
server:
  # 开启优雅停机(默认是 IMMEDIATE)
  shutdown: graceful 
spring:
  lifecycle:
    # 设定等待存量请求处理的最大超时时间(如 30 秒)
    # 如果 30 秒后还有请求没处理完,也会被强制关闭
    timeout-per-shutdown-phase: 30s 

2. 注册中心层:解决“服务已停,但流量还来”

当应用触发停机时,虽然 Web 容器准备优雅退出了,但注册中心(如 Nacos)的实例状态同步是异步且有延迟的。如果直接停机,网关和上游服务可能还没收到下线通知,依然会把流量打过来,导致 Connection Refused

解决方案:在准备停机前,先主动注销,并休眠等待。

在 Spring Boot 中可以监听 ContextClosedEvent

java
@Component
@Slf4j
public class GracefulShutdownHook implements ApplicationListener<ContextClosedEvent> {

    @Autowired
    private NacosRegistration registration;
    @Autowired
    private NacosServiceRegistry nacosServiceRegistry;

    @Override
    public void onApplicationEvent(ContextClosedEvent event) {
        log.info("准备优雅停机,先从 Nacos 注销实例...");
        // 1. 主动从 Nacos 注销
        nacosServiceRegistry.deregister(registration);
        
        // 2. 核心:休眠等待!让网关和上游服务有时间刷新本地缓存的路由表
        try {
            Thread.sleep(10000); // 建议休眠 5-10 秒,根据你的注册中心同步延迟决定
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        log.info("Nacos 注销并等待完毕,开始关闭 Spring 容器...");
    }
}

3. 容器编排层(Kubernetes):解决 K8s 路由延迟

如果你使用 K8s 部署微服务,K8s 停止 Pod 的流程是:

  1. 向 Pod 发送 SIGTERM 信号。
  2. 同时(异步)从 Service 的 Endpoints 中摘除该 Pod 的 IP。

由于这两个动作是并行的,Pod 收到 SIGTERM 开始执行 Java 的优雅停机了,但 K8s 的 iptables/IPVS 还没更新完,这时候集群内的流量还是会打到正在停机的 Pod 上。

解决方案:利用 K8s 的 preStop 钩子,让 Pod 收到停机指令后先“挂起”一段时间。

deployment.yaml 配置:

yaml
lifecycle:
  preStop:
    exec:
      # 在接收到 SIGTERM 信号前,先睡眠 10-15 秒
      # 留出时间让 K8s master 把该 Pod 的 IP 从 Service Endpoints 中彻底剔除
      command: ["/bin/sh", "-c", "sleep 15"]

注意: K8s 默认的强制杀进程时间(terminationGracePeriodSeconds)是 30 秒。如果你配置了 preStop 休眠 15 秒,Spring Boot 优雅停机等待 30 秒,总时间超过了 30 秒,K8s 会在 30 秒时无情地发送 SIGKILL 杀死进程。
因此,必须调大 K8s 的宽限期:

yaml
spec:
  containers:
    ...
  # 调大至 60 秒 (preStop 15s + Spring Boot 30s + 冗余)
  terminationGracePeriodSeconds: 60 

4. 异步任务层(MQ 消费者、定时任务)

HTTP 请求好处理,但如果是 Kafka/RabbitMQ 消费者或 @Scheduled 定时任务呢?如果在执行一半时被中断,会导致数据不一致。

  • MQ 消费者:Spring AMQP (RabbitMQ) 和 Spring Kafka 默认支持优雅停机。它们会在容器关闭时停止拉取新消息,并等待当前正在执行的 @RabbitListener / @KafkaListener 方法执行完毕。
  • 线程池:如果你自定义了 ThreadPoolTaskExecutor必须配置优雅停机参数,否则线程池会直接被销毁。

自定义线程池的正确配置:

java
@Bean
public ThreadPoolTaskExecutor myThreadPool() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    // ... 其他配置 ...
    
    // 开启线程池优雅停机
    executor.setWaitForTasksToCompleteOnShutdown(true);
    // 设置线程池最大等待时间(需小于 Spring Boot 的总等待时间)
    executor.setAwaitTerminationSeconds(20); 
    
    return executor;
}

三、 发布策略的配合(部署层面)

实现了代码和容器的优雅停机后,还需要配合正确的发布策略(平滑发布):

  1. 滚动更新(Rolling Update):这是 K8s 默认的策略。

    • 保证 maxUnavailable 不能为 100%(例如设置为 25% 或 1),确保旧 Pod 停止时,有足够的新 Pod 在服务。
    • 配置 readinessProbe(就绪探针):非常关键! 只有新启动的 Pod 就绪探针返回 200 后,K8s 才会把流量导入新 Pod,并开始销毁旧 Pod。
  2. 蓝绿发布 / 灰度发布

    • 通过网关(如 Spring Cloud Gateway / APISIX / Nginx)控制权重。先将旧版本的流量权重调为 0,观察日志确认无流量后,再进行下线替换。

四、 避坑指南总结(Checklist)

实施优雅停机时,核对以下关键点:

  1. 🚫 绝对禁止使用 kill -9kill -9SIGKILL 信号,进程会被立刻强制回收,任何优雅停机代码都不会执行。必须使用 kill -15 (SIGTERM)。
  2. ⏱️ 时间配置梯队(最重要):各层的超时配置必须有大小关系,否则会互相打断。
    • 公式:K8s宽限时间 > preStop休眠 + SpringBoot停机时间 > 线程池停机时间 > 网关/RPC超时时间
    • 举例:K8s terminationGracePeriodSeconds: 60 > sleep 10 + timeout-per-shutdown-phase: 30s > 线程池 20s
  3. 🔄 服务幂等性兜底:即便做到了极简的优雅停机,也无法保证 100% 绝对不中断(例如物理机突然断电)。因此,核心接口必须设计幂等性,配合网关层的自动重试(Retry)机制(只针对 GET 请求或幂等请求重试),做到客户端对发版无感知。
00:00
00:00