Skip to content

Spring 优雅关闭与 Shutdown Hook 实战

提出问题

线上服务重启时,如果直接 kill -9 杀掉进程,正在处理的请求会中断,数据库连接池来不及归还,MQ 消息可能丢失,事务中的写操作变成"写入一半"的状态。这些问题的根源是同一个:没有优雅关闭

2026 年的生产环境,大部分 Spring Boot 应用跑在 K8s 上。Pod 滚动更新、扩缩容、节点维护——每一次 Pod 销毁都是一次"被迫关闭"。如果应用不配合优雅关闭,每一次上线都是"先断网再关机"式的蛮力操作,用户端的表现就是连接重置、500 错误、超时重试。

Spring Boot 2.3+ 提供了内置的优雅关闭支持,但很多人只是配了 server.shutdown=graceful 就以为万事大吉,结果发现 K8s 里照样断连。这背后的坑在哪?怎么配才能真正做到无损关闭?

分析问题

优雅关闭到底在关什么?

说优雅关闭之前,先搞清楚"关闭"这个动作要处理哪些东西:

  1. HTTP 请求——正在处理的请求要放行完成,新来的请求直接拒绝
  2. 线程池任务——已经在执行的任务要等它跑完
  3. 数据库连接池——归还连接,避免 MySQL 端残留 TIME_WAIT
  4. MQ 消息——消费中的消息确认提交,避免重复消费
  5. Spring Bean 销毁——执行 @PreDestroy、DisposableBean.destroy()
  6. 外部注册中心——从 Nacos/Eureka 摘除节点,避免服务调用方继续发流量

任何一个环节不到位,都不算"优雅"。

Spring Boot 内置优雅关闭的原理

Spring Boot 2.3+ 引入的优雅关闭,核心开关就两个配置:

yaml
server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

底层发生了什么?来看源码链路:

Application 收到 SIGTERM
  → SpringApplicationShutdownHook 触发
    → Tomcat 的 Connector.pause() 停止接收新连接
    → 等待已提交的请求在 timeout 内完成
    → Spring 容器调用 destroyBeans()
      → @PreDestroy → DisposableBean.destroy()
    → 线程池 shutdown()
    → 进程退出

关键代码在 TomcatWebServershutDownGracefully() 方法中:

java
// TomcatWebServer.tomcatGracefulShutdown() 简化版
public void shutDownGracefully() {
    // 1. 暂停连接器,新请求返回 503
    for (Connector connector : this.tomcat.getService().findConnectors()) {
        connector.pause();
    }
    // 2. 等待活跃请求完成
    this.tomcat.getEngine().getPipeline().addValve(new GracefulShutdownValve());
    // 3. 等待超时或全部完成
    this.activeRequests.await(timeout);
    // 4. 停止 Tomcat
    this.tomcat.stop();
}

Connector.pause() 是关键一步——它把 Tomcat 的 acceptor 线程暂停,不再接受新连接。已经建立连接但尚未处理完的请求,会被 GracefulShutdownValve 拦截,检查是否在等待队列中,如果在就继续等待,如果超时就直接强制关闭。

完整时序:K8s 滚动更新中一次 Pod 关闭的全过程

下面是一次生产环境中的 Pod 关闭时间线,以秒为单位:

T+0s     → K8s 决定销毁旧 Pod,从 Service Endpoint 中摘除该 Pod IP
T+0s~3s  → kube-proxy 更新 iptables/ipvs 规则,负载均衡器(Nginx/ALB)刷新路由
           ⚠️ 这 3 秒窗口内,流量可能仍然打到即将关闭的 Pod
T+0s     → K8s 执行 PreStop hook(如果配置了)
T+0s~15s → PreStop 中先调用 /actuator/shutdown 触发关闭流程
           → Connector.pause() 停止接收新连接,已接收的请求继续处理
           → 新请求在这 15 秒内被拒绝(返回 503),因为负载均衡器路由还没完全刷新
T+15s    → K8s 发送 SIGTERM 给 Pod(1 号进程)
T+15s~45s→ SpringApplicationShutdownHook 执行
           → 等待活跃请求完成(最多 30s,受 timeout-per-shutdown-phase 控制)
           → 销毁 Bean、关闭线程池、归还连接池
T+45s    → terminationGracePeriodSeconds 到期
           → K8s 发送 SIGKILL,强制终止进程

关键数字

  • terminationGracePeriodSeconds = 45s(其中 15s 给 PreStop,30s 给 Spring 优雅关闭)
  • timeout-per-shutdown-phase = 30s(必须 ≤ terminationGracePeriodSeconds - PreStop 耗时)
  • PreStop 中 sleep 15s 是一个经验值,基于 Nginx + Alibaba Cloud SLB 的实测摘除延迟

配置了 graceful 就够了吗?——生产环境的残酷真相

远远不够。这是最大的坑。

原因很简单:K8s 的滚动更新流程和 Spring Boot 的优雅关闭是两套独立的逻辑,缺一不可。K8s 滚动更新大致的流程是:

  1. 新 Pod 启动 → 通过就绪探针(ReadinessProbe)→ 加入 Service Endpoint
  2. 旧 Pod 收到 SIGTERM → 开始关闭
  3. 等待 terminationGracePeriodSeconds(默认 30s)→ 收不到 SIGTERM 响应就强制 SIGKILL

问题出在第 2 步和第 3 步之间:K8s 发送 SIGTERM 的同时,会把 Pod 从 Endpoint 中摘除。但摘除操作是异步的,等 kube-proxy 更新 iptables 规则、负载均衡器刷新路由表,需要几秒到十几秒的时间。如果旧 Pod 在收到 SIGTERM 后立刻拒绝新连接(connector.pause()),但这期间负载均衡器还在把流量发过来,请求就会直接失败。

正确的做法是:先摘除节点,再关闭服务。

不同负载均衡器的摘除延迟实测

负载均衡器类型摘除延迟(典型值)建议 PreStop 多等时间
kube-proxy (iptables)1-3s5s
kube-proxy (ipvs)0.5-1s3s
AWS ALB / NLB5-10s15s
阿里云 SLB5-15s15s
自建 Nginx + upstream1-2s(需配 health_check)5s

如果使用云厂商的负载均衡器,建议 PreStop 之后等 15 秒。如果自建集群用 iptables 模式,5 秒就够了。不要不管负载均衡器类型就抄一个固定的 sleep 时间

生产环境优雅关闭的完整方案

一个经过了线上验证的 K8s + Spring Boot 优雅关闭流程如下:

1. PreStop hook 执行:curl http://localhost:8080/actuator/shutdown
   → 触发 Spring Boot 优雅关闭
   → 同时等待 10-15 秒,让负载均衡器完成摘除
2. 15 秒后,K8s 发送 SIGTERM
3. Spring Boot 收到 SIGTERM,开始优雅关闭
4. 30 秒内完成所有请求 + 资源释放
5. 如果超时,K8s 发 SIGKILL 强制终止

对应的 K8s Deployment 配置:

yaml
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      # 告诉 K8s 等待 45 秒再强制杀死
      terminationGracePeriodSeconds: 45
      containers:
        - name: app
          lifecycle:
            preStop:
              exec:
                command:
                  - /bin/sh
                  - -c
                  - |
                    curl -X POST http://localhost:8080/actuator/shutdown \
                      --connect-timeout 5 \
                      --max-time 10 || true
                    sleep 15
                    # 这里 sleep 15 是为了给负载均衡器时间摘除节点

Spring Boot 侧需要配合:

yaml
server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s
  # 启用 Actuator shutdown 端点
management:
  endpoint:
    shutdown:
      enabled: true
  endpoints:
    web:
      exposure:
        include: health,shutdown,info

注意:/actuator/shutdown 端点默认是关闭的,需要显式开启。PreStop 中先调用 shutdown 端点触发 Spring Boot 的优雅关闭流程,然后 sleep 15s 作为缓冲——因为 shutdown 端点触发后,Tomcat 的 connector.pause() 会立即停止接收新连接,但负载均衡器(Nginx / K8s Service)还需要时间感知到 Pod 不可用。

Actuator shutdown 端点的常见陷阱

陷阱 1:shutdown 端点被防火墙或安全组拦截

PreStop 中 curl 访问的是 localhost:8080,所以走的是 lo 回环接口。如果 K8s Pod 内配置了网络策略(NetworkPolicy)或安全组限制了 lo 口——这种场景很少见,但确实遇到过。可以用 curl -v 调试,如果返回 Connection refused,确认 management.server.port 是否和 server.port 一致,或有没有被其他端口覆盖。

陷阱 2:shutdown 端点被 Spring Security 拦截

如果项目中启用了 Spring Security,/actuator/shutdown 端点默认被保护,需要显式放行:

java
@Configuration
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth -> auth
            .requestMatchers(antMatcher("/actuator/shutdown")).permitAll()
            .anyRequest().authenticated()
        );
        return http.build();
    }
}

陷阱 3:PreStop 中 curl 超时导致整个 PreStop 被跳过

curl 的 --max-time 参数必须设置,否则 shutdown 端点如果因为某些原因阻塞(比如连接池耗尽、死锁),curl 会一直等,PreStop 超了 45s 被 K8s 强制 SIGKILL,等于没有优雅关闭。

数据库连接池的正确关闭

数据库连接池的关闭经常被忽略。Spring Boot 默认使用 HikariCP,它的关闭逻辑在 HikariDataSource.close() 中:

java
// HikariCP 关闭流程
public void close() {
    // 1. 关闭连接池,所有连接被标记为"驱逐"
    // 2. 等待正在使用的连接归还(默认 5 秒超时)
    // 3. 物理关闭所有连接
    this.hikariPool.shutdown();
}

HikariCP 默认的关闭超时是 5 秒。如果业务线程持有数据库连接超过 5 秒,HikariCP 会强制关闭连接,导致业务线程后续操作拿到一个已关闭的连接。对于长事务场景,建议调大这个超时时间:

yaml
spring:
  datasource:
    hikari:
      # 与 graceful shutdown 的超时匹配
      shutdown-timeout: 30000
      # 连接最大存活时间,配合优雅关闭
      max-lifetime: 1800000

踩坑实录:某次线上发布,一个定时任务刚好在关闭时跑了一个 10 秒的 SQL 查询。HikariCP 默认 5 秒超时,直接关闭了连接,SQL 查询异常退出,导致这批数据丢失。把 shutdown-timeout 改为 30 秒后解决。

线程池的优雅关闭

业务代码中自己创建的线程池,Spring 容器不会自动关闭。需要手动在 @PreDestroy 中处理:

java
@Component
public class OrderProcessExecutor {
    private final ExecutorService executor = Executors.newFixedThreadPool(10);

    @PreDestroy
    public void shutdown() {
        executor.shutdown();  // 不再接受新任务
        try {
            if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
                // 超时了还有未完成任务,强制中断
                executor.shutdownNow();
            }
        } catch (InterruptedException e) {
            executor.shutdownNow();
            Thread.currentThread().interrupt();
        }
    }
}

很多团队漏掉这一步,导致优雅关闭时业务线程池还在跑,等到容器关闭了,线程池里的任务被 JVM 直接中断,数据状态不一致。

从注册中心摘除节点

如果使用 Nacos 作为注册中心,Spring Cloud 的 @EnableDiscoveryClient 已经内置了关闭时自动摘除的逻辑。但有一个细节:摘除是异步的。Nacos 客户端在收到关闭信号后,发送注销请求到服务端,服务端更新注册表,然后通知其他订阅者。这个过程中,其他服务可能会在短暂的窗口期内调用到正在关闭的实例。

推荐的做法是:在 PreStop 中先调用 Nacos 的 de-register API,等待 2-3 秒,再触发 Spring Boot 关闭:

yaml
# application.yml 中配置 Nacos 关闭时立即注销,而不是优雅等待
spring:
  cloud:
    nacos:
      discovery:
        # 关闭时立即注销
        deregister-on-shutdown: true

Eureka 的机制则不同:Eureka 采用心跳 + 自我保护模式,client 关闭时不会主动发注销请求,而是等心跳超时后 server 自动剔除。所以 Eureka 必须配合 eureka.instance.lease-expiration-duration-in-seconds 调小(比如 3 秒)来加速摘除。

关闭策略对比表

策略配置方式无损吗?K8s 兼容吗?适用场景
直接 kill -9❌ 全损❌ 不兼容快速杀掉异常进程
server.shutdown=graceful一行配置⚠️ 部分有损❌ 断连非 K8s 环境
graceful + PreStop + sleep多行配置✅ 基本无损✅ 兼容大多数 K8s 生产环境
graceful + PreStop + sleep + 注册中心预摘除完整配置✅ 接近零损✅ 兼容高可用敏感服务(支付、订单)
蓝绿部署 + 流量预热独立部署流程✅ 零损✅ 但复杂核心服务,不能容忍任何错误

总结

Spring Boot 的优雅关闭不是一个开关就能搞定的事。从单机角度看,配置 server.shutdown=graceful 加上 timeout-per-shutdown-phase 确实能处理 Tomcat 层和 Spring 容器层的问题。但到了 K8s 生产环境,必须补齐三层:

  1. K8s 层terminationGracePeriodSeconds 给足时间(建议 45-60s),PreStop hook 中先触发关闭再 sleep 等待负载均衡器摘除
  2. Spring Boot 层:配置优雅关闭、Actuator shutdown 端点、数据库连接池超时
  3. 业务层:自定义线程池的 @PreDestroy 关闭逻辑、注册中心摘除

推荐的最小配置清单

yaml
# Spring Boot 配置
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s
spring.datasource.hikari.shutdown-timeout=30000
management.endpoint.shutdown.enabled=true
yaml
# K8s Deployment 配置
terminationGracePeriodSeconds: 45
lifecycle.preStop: "先调 /actuator/shutdown → sleep 15s"

别踩的坑

  • 不要只配 graceful 就上线,K8s 环境必须配合 PreStop + sleep
  • 不要忘记自定义线程池的关闭逻辑
  • 不要忽略数据库连接池的归还超时
  • 不要在 PreStop 中直接 sleep 等 30s 而不调用 shutdown——这样等于浪费了 30s 什么事都没做
  • 不要让 terminationGracePeriodSeconds 小于 timeout-per-shutdown-phase + 15s
  • 不要忘了 Actuator 端点被 Spring Security 拦截的问题
  • 不要在 PreStop 的 curl 中省略 --max-time,否则 curl 可能一直阻塞

参考资料:

  • Spring Boot 官方文档 — Graceful Shutdown
  • Tomcat 源码 — Connector.pause() / GracefulShutdownValve
  • Kubernetes 最佳实践 — Pod Lifecycle (PreStop, terminationGracePeriodSeconds)
  • HikariCP 源码 — HikariDataSource.close()
  • Nacos 源码 — NacosNamingService.deregisterInstance()

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。