Spring Boot 优雅停机实战:30 秒内完成流量排干 + 任务收尾 + 资源释放

去年大促前一晚,我们做例行发布。CI/CD 流水线一把梭:kubectl rollout restart,30 个 Pod 滚动更新,5 分钟搞定。

结果第二天复盘时,运维同学甩过来三张截图:

  • 网关日志:滚动更新期间 472 个 502 Bad Gateway,全是打在被重启的实例上
  • 业务日志:3 笔订单状态更新了一半,扣了库存没写流水,DBA 手工对账到凌晨两点
  • MQ 后台:某 Topic 出现 2000+ 条重复消费,下游幂等兜底差点没扛住

根因说起来特别简单:K8s 给 Pod 的默认宽限期是 30 秒,超时后直接 SIGKILL。我们的应用既没开优雅停机,也没配 preStop,JVM 收到 SIGTERM 后根本没机会"把手头的事做完"——在途请求直接断、定时任务跑一半、MQ 消息没提交 offset。

这不是个例。只要你的服务跑在 K8s 上,滚动更新、扩缩容、节点驱逐,每一刻都可能触发"杀进程"。优雅停机就是给应用一个"临终遗嘱"的执行机会:先摘流量 → 再处理完在途请求 → 然后停任务 → 最后释放资源,全程 30 秒内干净退出。

这篇文章把我们踩过的坑和最终落地的方案完整讲一遍,覆盖:Spring Boot 开关、K8s preStop、在途请求排干、定时任务与 MQ 消费先停、DB/Redis 连接池关闭顺序,文末附完整时序图、验证方法和 8 个高频坑。


一、先想清楚:进程被杀的那一刻,到底丢了什么?

一次粗暴的停机,损失分三类,严重程度递增:

损失类型现象影响
流量损失已到网关/负载均衡的请求打到正在关闭的实例 → 502/503/Connection refused用户侧直接报错,支付类场景是事故
数据损失事务执行到一半 JVM 消失:扣了库存没写流水、本地缓存没落库、文件写一半对账修复成本极高,可能资损
重复/错乱MQ offset 没提交 → 重启后被再次推送;分布式任务锁没释放 → 双实例并发跑幂等没做好的场景直接翻车

而"优雅停机"要做的就是给这三类损失各上一道锁:

流量损失  ──→  流量排干:先摘除路由,再停入口,等在途请求跑完
数据损失  ──→  任务收尾:定时任务/异步任务跑完再退
重复错乱  ──→  资源释放:offset 提交、锁释放、连接池有序关闭

核心矛盾只有一个:K8s 只给你有限的宽限期(默认 30s),Spring 默认"收到信号立刻退"。我们的全部工作,就是把这 30 秒切成几段,让每类事情按正确顺序发生。


二、最小可用配置:Spring Boot 侧两个参数

从 Spring Boot 2.3 开始,优雅停机是一等公民,两个参数搞定(Boot 3.x 同样适用):

server:
  shutdown: graceful   # 默认 immediate:收到信号立刻关闭,不再等请求

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s   # 默认 30s,优雅关闭每个 phase 的最长等待时间

这两行的含义:

  • server.shutdown=graceful:Web 服务器(Tomcat/Netty/Undertow)收到关闭信号后,不再接受新请求,但继续处理已收到的请求,直到全部完成或超时
  • timeout-per-shutdown-phase=30s:等待在途请求的最长时限。超时后不再等待,直接进入下一阶段(避免一个卡死请求拖住整个进程)

对应的 properties 写法:

server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s

它背后发生了什么?(SmartLifecycle 机制)

Spring Boot 内部靠 SmartLifecycle 接口编排所有"有生命周期"的组件:

public interface SmartLifecycle extends Lifecycle {
    int getPhase();                    // 决定 start/stop 的顺序:stop 时 phase 大的先停
    void stop(Runnable callback);      // 优雅停止,干完活再调 callback
    boolean isAutoStartup();           // 容器启动时是否自动 start
    boolean isRunning();               // 当前是否运行中
}

开启 graceful 后,Spring Boot 注册了 WebServerGracefulShutdownLifecycle,进程收到 SIGTERM(Spring 容器 close)时的执行顺序是:

  1. Readiness 状态切换ApplicationAvailabilityReadinessStateACCEPTING_TRAFFIC 切到 REFUSING_TRAFFIC/actuator/health/readiness 开始返回 503
  2. Web 服务器优雅排干TomcatGracefulShutdownpause Connector(OS 层不再受理新连接),然后轮询活动请求数,等它归零,最长等 timeout-per-shutdown-phase
  3. 超时兜底:时间到了还有请求没完?记录告警日志,强制进入销毁阶段
  4. 销毁单例 Bean:按依赖逆序执行 SmartLifecycle.stop()@PreDestroyDisposableBean.destroy()

理解这个顺序,后面"定时任务先停、MQ 先停、连接池最后关"的设计就顺理成章了——全是围绕 phase 和销毁逆序做文章。


三、完整时序:一次优雅停机的 30 秒都花在哪

先给出我们生产最终采用的完整时间线(K8s + Spring Boot,全部参数对齐),后面每节拆开讲:

kubectl delete pod / 滚动更新触发
  │
  ├─ T+0s     Pod 标记 Terminating
  │           ├─ EndpointController 异步把 Pod 从 Service Endpoints 摘除
  │           │   (iptables/IPVS 规则传播到全部节点,实测 1~5s)
  │           └─ kubelet 启动 preStop hook:sleep 10   ← 给摘除传播留时间
  │
  ├─ T+10s    preStop 结束 → kubelet 向容器发 SIGTERM
  │
  ├─ T+10s    Spring 收到 SIGTERM,graceful shutdown 开始:
  │           ├─ Readiness → REFUSING_TRAFFIC(/actuator/health/readiness 变 503)
  │           ├─ MQ 消费者停止(phase 大,先停):停拉取 → 处理完在途消息 → 提交 offset
  │           ├─ 定时任务:关闭"接活开关" → 等正在执行的任务收尾
  │           └─ Tomcat:pause Connector,不再接受新连接
  │
  ├─ T+11s    在途请求继续跑(DB/Redis 连接池此时还活着,业务无感知)
  │           …… 最长等 timeout-per-shutdown-phase = 30s
  │
  ├─ T+30s    在途请求全部完成 → 单例 Bean 按依赖逆序销毁:
  │           ├─ @PreDestroy:刷业务缓存/日志、释放分布式锁
  │           ├─ Redis 连接池 close(Lettuce 等 shutdown-timeout,在途命令跑完)
  │           └─ HikariCP 连接池 close(停止发放新连接,等已借出连接归还)
  │
  └─ T+31s    JVM 退出,exit code 0 ✓
   (若到 T+terminationGracePeriodSeconds 还没退 → kubelet 发 SIGKILL,前功尽弃)

看到关键点了吗:K8s 的宽限期必须覆盖 preStop + Spring 优雅关闭全程。参数之间的匹配关系是这条链上最容易翻车的地方,先记住结论:

terminationGracePeriodSeconds ≥ preStop 耗时 + timeout-per-shutdown-phase + 缓冲 5s
我们生产取值:45s ≥ 10s + 30s + 5s ✓

四、K8s 侧:preStop 摘流量 + 宽限期兜底

4.1 为什么 preStop 里要 sleep?——最容易被忽视的"摘除延迟"

很多人以为 Pod 收到 SIGTERM 时,流量就已经停了。错。

删除 Pod 时,K8s 是并行做两件事的:

  1. EndpointController 把 Pod IP 从 Endpoints 里删掉 → kube-proxy 在各节点刷新 iptables/IPVS → 客户端新请求不再路由到该 Pod
  2. kubelet 启动 preStop hook,执行完才发 SIGTERM

第 1 步是异步传播的:从 Endpoints 变更到"全集群所有节点的转发规则都更新",实测要 1~5 秒(节点多、网络插件慢的话更久)。如果应用收到 SIGTERM 立刻 pause Connector,这 1~5 秒窗口内新到的请求会直接被拒——这就是 472 个 502 的来源。

所以 preStop 的第一要务是

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  template:
    spec:
      terminationGracePeriodSeconds: 45     # ① 宽限期 ≥ preStop + spring timeout + 5s
      containers:
        - name: app
          image: registry.example.com/order-service:1.8.0
          lifecycle:
            preStop:
              exec:
                # ② 等 Endpoints 摘除传播完,摘除期间应用照常服务,不影响流量
                command: ["/bin/sh", "-c", "sleep 10"]
          readinessProbe:                   # ③ 探针指到 actuator 的 readiness 组
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 20
            periodSeconds: 5
            failureThreshold: 3

三个配置各有讲究:

  • ① terminationGracePeriodSeconds=45:默认 30s 不够用。preStop 花了 10s,Spring 只剩 20s 处理在途请求——如果你的接口有导出报表这种 1 分钟的长请求,直接 SIGKILL,白配
  • ② preStop sleep 10:给路由摘除传播留时间。这 10 秒里应用还在正常服务,不算浪费
  • ③ readiness 探针:指向 actuator 的 readiness 探针组,配合 Spring 的 REFUSING_TRAFFIC 状态,让 K8s 在应用半关闭期间也能准确感知

4.2 更精细的方案:preStop 里主动"摘自己"

sleep 是"等摘除自己生效",更主动的做法是让应用先自报下线,等 K8s 确认摘除再继续:

lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "curl -X POST http://localhost:8080/actuator/shutdown-ready && sleep 8"]

应用侧自己维护一个开关(比如往某个 AtomicBoolean 写 false),副作用是:

  • 自定义逻辑可以挂进来:把本实例从本地注册中心(Nacos/Eureka 客户端缓存)反注册、通知网关
  • 适合注册中心直连场景:K8s Endpoint 摘除只管 Service 转发,管不住客户端从 Nacos 拿到的旧实例列表

取舍:sleep 方案零代码改动,覆盖 90% 场景;主动摘除多写几行代码,解决服务发现缓存的死角。我们的实践:K8s Service 入口的流量用 sleep,Nacos 直连的内部 RPC 额外反注册。

4.3 Spring Boot 开启 readiness 状态输出

management:
  endpoints:
    web:
      exposure:
        include: health
  endpoint:
    health:
      probes:
        enabled: true        # 开启 /actuator/health/liveness 和 /readiness
  health:
    livenessstate:
      enabled: true
    readinessstate:
      enabled: true

验证效果:

# 正常时
$ curl -i http://localhost:8080/actuator/health/readiness
HTTP/1.1 200
{"status":"UP"}

# 发 SIGTERM 后(graceful 排干期间)
$ curl -i http://localhost:8080/actuator/health/readiness
HTTP/1.1 503
{"status":"OUT_OF_SERVICE"}

五、在途请求排干:Tomcat 到底怎么"等"

5.1 机制拆解

开启 server.shutdown=graceful 后,Tomcat 的排干流程:

  1. Connector.pause():监听端口还在,但不再接受新连接(accept 队列冻结)
  2. 轮询活动请求数:Spring 的 TomcatGracefulShutdown 周期性检查 Tomcat 内部的活跃请求统计
  3. 归零即成功:所有在途请求处理完,立刻进入下一阶段(不会傻等满 30s,实际通常 1~2 秒就结束)
  4. 超时强退:30s 还有卡死的请求?打 WARN 日志后强制关闭

实测一次典型输出的日志:

INFO  o.s.b.w.e.t.GracefulShutdown - Commencing graceful shutdown. Waiting for active requests to complete
INFO  o.s.b.w.e.t.GracefulShutdown - Graceful shutdown complete

两行日志之间就是在途请求的收尾时间。压测里我们观察到:QPS 3000、平均 RT 80ms 的服务,这两行日志间隔约 150ms——代价极低。

5.2 两个必须知道的行为

① pause 期间既有长连接不会立刻断。已经建立的 keep-alive 连接会被关闭,但已到达的 HTTP 请求会完整处理。客户端(浏览器/Feign/OkHttp)一般会自动重连到其他实例,无感知。

② 超时后的请求会被硬中断timeout-per-shutdown-phase 到期后强关,在途请求的客户端会收到 Connection reset。所以这个值的设置依据是:覆盖你 P999 接口的耗时。我们按下面公式定:

timeout-per-shutdown-phase ≈ P999 RT × 2(并留日志观察余量)
我们 P999 = 800ms,取 30s 已非常充裕;有异步长任务的服务单独评估

5.3 自定义组件也要"插进"这个机制

如果你的代码里有自己的"入口"(比如 WebSocket 会话、自建 TCP 服务),实现 SmartLifecycle 加入编排,别自己监听 JVM shutdown hook:

@Component
public class WebSocketSessionLifecycle implements SmartLifecycle {

    private final AtomicInteger activeSessions = new AtomicInteger();
    private volatile boolean running;

    @Override
    public void start() { running = true; }

    @Override
    public void stop(Runnable callback) {
        // 1. 停止接受新连接
        running = false;
        // 2. 给在途会话发下线通知,最长等 25s
        long deadline = System.currentTimeMillis() + 25_000;
        while (activeSessions.get() > 0 && System.currentTimeMillis() < deadline) {
            LockSupport.parkNanos(100_000_000);
        }
        callback.run();
    }

    @Override
    public boolean isRunning() { return running; }

    @Override
    public int getPhase() {
        // phase 越大,stop 越早。Web 服务器排干在 phase 更小的一层,
        // 先清 WebSocket,再让 HTTP 排干
        return SmartLifecycle.DEFAULT_PHASE + 100;
    }
}

六、定时任务:先停"接活",再等"干活"

6.1 默认行为哪里不够

@Scheduled 任务由 ThreadPoolTaskScheduler 驱动,Spring 关闭时会 shutdown 线程池——但注意两点:

  1. 正在执行的任务会被等待吗? 取决于线程池配置,默认 waitForTasksToCompleteOnShutdown=false,直接 interrupt 正在跑的任务。跑到一半的"每日对账"就此夭折
  2. 极端情况下还会"临终再跑一次":shutdown 与下次调度触发存在竞态窗口

6.2 标准解法:给任务加"总开关" + 等待收尾

@Component
public class JobGracefulShutdown implements SmartLifecycle {

    private static final Logger log = LoggerFactory.getLogger(JobGracefulShutdown.class);

    /** 所有定时任务执行前检查这个开关,false 则直接跳过本轮 */
    public static final AtomicBoolean JOB_ENABLED = new AtomicBoolean(true);

    /** 当前正在执行的任务计数,任务开始 +1,finally 里 -1 */
    public static final AtomicInteger RUNNING_JOBS = new AtomicInteger();

    private volatile boolean running = true;

    @Override
    public void stop(Runnable callback) {
        log.info("[优雅停机] 停止定时任务:关闭接活开关,等待在途任务收尾...");
        // 1. 先关开关:新触发的调度直接跳过(不领新活)
        JOB_ENABLED.set(false);
        running = false;

        // 2. 等正在跑的任务结束,给 25s(在 30s 总预算内,给连接池关闭留余量)
        long deadline = System.currentTimeMillis() + 25_000;
        while (RUNNING_JOBS.get() > 0 && System.currentTimeMillis() < deadline) {
            LockSupport.parkNanos(100_000_000);
        }
        int remaining = RUNNING_JOBS.get();
        if (remaining > 0) {
            log.warn("[优雅停机] 仍有 {} 个定时任务未结束,将被中断", remaining);
        } else {
            log.info("[优雅停机] 定时任务全部收尾完成");
        }
        callback.run();
    }

    @Override public boolean isRunning() { return running; }
    @Override public int getPhase() { return SmartLifecycle.DEFAULT_PHASE + 200; } // 比 Web 排干更早停
}

业务侧配合,一个注解解决:

@Scheduled(cron = "0 */5 * * * ?")
public void syncInventory() {
    if (!JobGracefulShutdown.JOB_ENABLED.get()) {
        return;   // 停机期间不再领活
    }
    JobGracefulShutdown.RUNNING_JOBS.incrementAndGet();
    try {
        doSyncInventory();     // 正常业务
    } finally {
        JobGracefulShutdown.RUNNING_JOBS.decrementAndGet();
    }
}

两段代码配合起来的行为:停机时新任务一律不领,正在跑的给足 25 秒收尾(提交事务、写完流水),然后才允许连接池关闭

6.3 分布式定时任务额外注意

用 XXL-Job / Elastic-Job / ShedLock 的同学补一条:优雅停机时要主动释放/转移任务锁

  • ShedLock:锁本身带过期时间(默认配置下自动过期),但可以停机时主动 unlock 让备实例立刻接管,避免出现 5~10 分钟的任务空窗
  • XXL-Job:执行器优雅下线后调度中心会摘除节点,但要确认执行中的任务能通过"续传/告警"机制被重新调度,否则停机窗口内的触发会丢

七、消息消费:先停拉取,再提交 offset

MQ 是最容易"重复消费"的地方,原则一句话:先让 MQ 端不再给你推消息,再把已拉到的消息处理完、提交 offset,最后才关连接

7.1 Spring Kafka 的正确姿势

好消息:Spring Kafka 的 listener container 默认 phase 就很大Integer.MAX_VALUE - 100 级别),SmartLifecycle stop 时天然先于 Web 服务器停,方向是对的。要做的是把"停容器"的等待时间调够:

@Configuration
public class KafkaConsumerConfig {

    @Bean
    public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory(
            ConsumerFactory<String, String> consumerFactory) {
        var factory = new ConcurrentKafkaListenerContainerFactory<String, String>();
        factory.setConsumerFactory(consumerFactory);
        var props = factory.getContainerProperties();

        // 关键:停容器时等待"已 poll 但未处理完"的记录处理完 + offset 提交
        // 该值必须 < spring.lifecycle.timeout-per-shutdown-phase,否则进程先被掐了
        props.setShutdownTimeout(25_000L);
        return factory;
    }
}

停机时的内部动作(KafkaMessageListenerContainer.doStop):

  1. 停止 poll() 循环 —— 不再从 broker 拉新消息
  2. 处理完本轮已 poll 的记录 —— listener 里的业务跑完
  3. 提交 offset —— 这一步是防重复消费的关键,进程死之前必须让 broker 记账
  4. 等待最多 shutdownTimeout,然后关闭消费者连接

7.2 RocketMQ 同理

rocketmq-spring-boot-starterDefaultRocketMQListenerContainer 同样实现了 SmartLifecycle,stop 时会先注销消费者再等队列清空。需要自查两点:

  • 消费逻辑里不要在停机阶段还在发新消息(比如处理完 A 消息后顺手发 B 通知)——停机排干期下游可能已下线,发出去的消息可能无人消费。稳妥做法:发送失败降级到本地表重试
  • 广播模式消费者停机无 offset 提交问题(本地持久化),集群模式必须确认停机日志里出现 offset 提交成功

7.3 一个隐蔽坑:消费与生产的"错位"

停机期间 MQ 消费者停了,但别的实例还在往同一个 Topic 发消息——这没问题,消息会被健康实例消费。真正的坑是:你的应用是"既消费又生产"的链路中间节点,停机瞬间上下游吞吐会抖动。压测时要把"滚动更新中"当作常态流量的一部分去观察,而不是单独压"全量健康集群"。


八、连接池优雅关闭:DB 和 Redis 的最后一件衬衫

8.1 原理:依赖逆序,天然正确

Spring 销毁单例 Bean 的顺序是创建顺序的逆序。你的 Controller/Service 依赖了 DataSource/RedisConnectionFactory,所以销毁时一定是:

先销毁 Controller / Service / 自定义 Lifecycle 组件(stop 回调)
    ↓
后销毁 RedisConnectionFactory
    ↓
最后销毁 HikariDataSource

只要你是正常注入(@Autowired/构造器),连接池一定最后关,这个顺序不用你操心。会翻车的是两种写法:

  1. 静态工具类里自己 new 的连接池(Spring 管不到,进程退出时被硬杀)
  2. @PreDestroy 里做耗时收尾却依赖了"更早被销毁"的组件(比如手动 new 的 Redis 客户端)

8.2 DB 连接池(HikariCP)

Spring Boot 关闭 HikariDataSource 时,HikariPool 的行为:

  1. 标记 pool 为 shutdown 状态,不再发放新连接
  2. softEvict 所有连接,关闭空闲连接
  3. 等待已借出(in-use)的连接归还后关闭——这就是排干期间在途请求能正常查库的原因

需要做的只有一件事:确认排干等待时间 < Hikari 等待上限,我们按第二节的总预算自然满足。如果想显式观察,加个日志:

@Component
public class DataSourceShutdownLogger {

    public DataSourceShutdownLogger(HikariDataSource dataSource) {
        // 无需额外代码:Spring 关闭时会调 dataSource.close()
        // 这里只演示确认销毁顺序 —— 打日志观察,别在这里做耗时操作
    }
}

更常见的坑反而在线上配置:connection-timeout业务等连接的超时(默认 30s)。停机排干期间如果连接池已关闭,业务代码还在跑,取连接会抛异常——所以排干阶段不要新启事务,这也是第六节"先停任务"的原因之一。

8.3 Redis 连接池(Lettuce / Jedis)

Lettuce 默认的 shutdown 行为很急:Spring Boot 的等待超时只有 100ms,在途命令大概率被掐。生产建议显式调大:

spring:
  data:
    redis:
      lettuce:
        shutdown-timeout: 3s     # 默认 100ms,停机时等待在途命令完成的时间

Lettuce 关闭时会等待"已发出但未收到响应"的命令返回,3 秒足够覆盖 P999 的 Redis RT(正常 <10ms)。用 Jedis 连接池的话,JedisPool.close() 语义类似:归还中的连接等完再断。

8.4 @PreDestroy 的正确用法

@PreDestroy 适合放短平快的收尾:

@Component
public class CacheFlushHook {

    private final LocalBuffer buffer;      // 业务自建的本地缓冲

    @PreDestroy
    public void flush() {
        // ✅ 合适:刷缓冲、释放分布式锁、打下线日志 —— 都是毫秒级
        buffer.flushToRemote();
        distLockService.release("order-sync-lock");
        log.info("[优雅停机] 本地缓冲已落盘,锁已释放");
    }
}

反面教材(真实见过的事故代码):

@PreDestroy
public void badFlush() {
    // ❌ 同步调用 200ms×1000 条的下游接口 = 200 秒,K8s 宽限期 45s 早就到了
    buffer.getAll().forEach(item -> remoteClient.post("/sync", item));
}

@PreDestroy 里只做"点尾巴"的事,大块头收尾(批量落库、长任务)交给 SmartLifecycle 的 stop 等待阶段。同理,不要在 @PreDestroy 里再发 RPC/MQ——你的下游可能也正在下线。


九、验证:别等线上翻车才第一次测试

优雅停机配置完必须压测验证,方法很简单:

9.1 本地验证(2 分钟)

# 1. 启动应用
mvn spring-boot:run &

# 2. 压测流量(简单版:循环 curl)
while true; do
  curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/api/orders >> codes.log
  sleep 0.1
done

# 3. 发 SIGTERM(注意:不是 kill -9)
kill -15 <pid>

# 4. 观察日志出现:
#    Commencing graceful shutdown. Waiting for active requests to complete
#    Graceful shutdown complete
# 5. 检查 codes.log:最后 3 秒内不应该有 502/000(连接失败)

关键区别必须刻在脑子里:kill -15(SIGTERM)触发优雅停机,kill -9(SIGKILL)直接跳过一切。检查你的 CI/CD 脚本、docker stop(默认就是 SIGTERM + 10s 后 SIGKILL)、kubectl delete 的信号链路。

9.2 K8s 滚动更新验证

# 1. 后台持续压测(用一个不重启的服务当压力源)
hey -z 10m -q 20 -c 20 http://order-svc:8080/api/orders > hey.log &

# 2. 触发滚动更新
kubectl rollout restart deployment/order-service

# 3. 等 10 分钟,统计失败率
grep -c "5[0-9][0-9]" hey.log
# 我们的验收标准:10 分钟滚动 30 个 Pod,5xx < 3 个(容许极端竞态)

同时观察两个信号:

# Pod 退出耗时(优雅停机是否被 SIGKILL 截胡)
kubectl get pod -w | grep order

# 事件里有没有 "Killing container ... failed grace period"
kubectl describe pod <pod-name> | grep -A2 Killing

如果反复出现 "failed grace period",说明宽限期不够或应用卡死,回到第三节对时间线。


十、8 个高频坑(血泪清单)

#症状修复
1K8s 宽限期 30s 不够describe pod 出现 failed grace period,请求半截断terminationGracePeriodSeconds ≥ preStop + timeout + 5s
2preStop 没 sleep滚动更新瞬间一小波 502,之后恢复正常preStop 加 sleep 8~10,等 Endpoints 摘除传播
3timeout 覆盖不住长请求导出/报表接口偶发 Connection reset调大 timeout-per-shutdown-phase,或把长任务异步化+状态轮询
4CI/CD 里 kill -9所有优雅配置全部失效统一 SIGTERM;Docker stop 别用 -t 0
5定时任务跑一半对账任务中断、状态机卡在中间态总开关 + RUNNING_JOBS 计数 + 等待收尾(第六节模板)
6MQ offset 没提交重启后重复消费,幂等兜底告警listener setShutdownTimeout,停机日志确认 offset 提交
7@PreDestroy 耗时超长退出耗时超过宽限期,被 SIGKILL@PreDestroy 只做毫秒级收尾,重活放 SmartLifecycle.stop
8静态工具类自持连接池Spring 不管,进程退出硬杀,在途操作丢连接池一律交给容器管理,或工具类自己注册 shutdown hook 且保证顺序

再补一条容易被忽略的:优雅停机不等于万能等待。如果应用卡死(比如死锁、连接池耗尽),排干阶段的 30 秒就是白等。给 Web 服务器排干阶段加上监控(GracefulShutdown 日志间隔异常告警),才能区分"正常收尾"和"卡死陪跑"。


十一、总结:一张配置速查卡

┌─────────────────────── Spring Boot 侧 ───────────────────────┐
│ server.shutdown=graceful                # 开启优雅停机        │
│ spring.lifecycle.timeout-per-shutdown-phase=30s              │
│ management.endpoint.health.probes.enabled=true               │
│ spring.data.redis.lettuce.shutdown-timeout=3s                │
│ Kafka: containerProperties.setShutdownTimeout(25s)           │
│ 自定义组件: 实现 SmartLifecycle,phase 大的先停               │
├─────────────────────── K8s 侧 ───────────────────────────────┤
│ terminationGracePeriodSeconds: 45       # ≥ 10+30+5          │
│ preStop: sleep 10                       # 等摘除传播          │
│ readinessProbe: /actuator/health/readiness                   │
├─────────────────────── 顺序心法 ─────────────────────────────┤
│ 摘流量 → 停消费入口(MQ/定时任务) → HTTP 排干 → 刷缓存/释放锁 │
│       → 关 Redis → 关 DB → JVM exit                          │
└──────────────────────────────────────────────────────────────┘

上线前三步验证(记住这个顺序,比背参数有用):

  1. 本地 kill -15 + 循环 curl:无 502/000
  2. 本地模拟慢请求(接口 sleep 5s)+ kill -15:确认请求完整返回后才退出
  3. K8s 滚动更新 + hey 压测 10 分钟:5xx < 3,无 failed grace period 事件

优雅停机不是什么高深技术,两行配置 + 一个 preStop 就能覆盖 80% 的收益。但它又是典型的"不配置永远没事,出事就是资损级事故"的能力——很多团队都是像我们一样,用一次凌晨两点的对账换来这一课。希望这篇文章能让你把这个学费省掉。


🗨️ 互动话题

你们团队的发布窗口是怎么做停机保护的? 评论区聊聊:

  1. 你们配 terminationGracePeriodSeconds 了吗?有没有算过它和 preStop、timeout 的匹配关系?
  2. 长任务(导出报表、批量计算)你们是靠"调大 timeout"硬扛,还是异步化+进度轮询?
  3. 有没有见过"停机瞬间 MQ 重复消费"的灵异事件?最后怎么定位的?

点赞 + 在看 + 留言超过 50 条,下期写 《K8s 滚动更新全链路:从 Endpoints 摘除到 iptables 传播,为什么总有一波 502》,把 preStop 那 10 秒背后的网络细节彻底讲透,不见不散~


作者简介:服务端技术精选主理人,10 年后端老兵,前大厂技术专家。专注 Java / Spring 生态 / 分布式系统 / K8s 生产实践。更多深度文章欢迎订阅公众号「服务端技术精选」或访问我的博客 jiangyi.space。


标题:Spring Boot 优雅停机实战:30 秒内完成流量排干 + 任务收尾 + 资源释放
作者:jiangyi
地址:http://jiangyi.space/articles/2026/08/29/1787965534287.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消