Spring 优雅关闭与 Shutdown Hook 实战
提出问题
线上服务重启时,如果直接 kill -9 杀掉进程,正在处理的请求会中断,数据库连接池来不及归还,MQ 消息可能丢失,事务中的写操作变成"写入一半"的状态。这些问题的根源是同一个:没有优雅关闭。
2026 年的生产环境,大部分 Spring Boot 应用跑在 K8s 上。Pod 滚动更新、扩缩容、节点维护——每一次 Pod 销毁都是一次"被迫关闭"。如果应用不配合优雅关闭,每一次上线都是"先断网再关机"式的蛮力操作,用户端的表现就是连接重置、500 错误、超时重试。
Spring Boot 2.3+ 提供了内置的优雅关闭支持,但很多人只是配了 server.shutdown=graceful 就以为万事大吉,结果发现 K8s 里照样断连。这背后的坑在哪?怎么配才能真正做到无损关闭?
分析问题
优雅关闭到底在关什么?
说优雅关闭之前,先搞清楚"关闭"这个动作要处理哪些东西:
- HTTP 请求——正在处理的请求要放行完成,新来的请求直接拒绝
- 线程池任务——已经在执行的任务要等它跑完
- 数据库连接池——归还连接,避免 MySQL 端残留 TIME_WAIT
- MQ 消息——消费中的消息确认提交,避免重复消费
- Spring Bean 销毁——执行 @PreDestroy、DisposableBean.destroy()
- 外部注册中心——从 Nacos/Eureka 摘除节点,避免服务调用方继续发流量
任何一个环节不到位,都不算"优雅"。
Spring Boot 内置优雅关闭的原理
Spring Boot 2.3+ 引入的优雅关闭,核心开关就两个配置:
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s底层发生了什么?来看源码链路:
Application 收到 SIGTERM
→ SpringApplicationShutdownHook 触发
→ Tomcat 的 Connector.pause() 停止接收新连接
→ 等待已提交的请求在 timeout 内完成
→ Spring 容器调用 destroyBeans()
→ @PreDestroy → DisposableBean.destroy()
→ 线程池 shutdown()
→ 进程退出关键代码在 TomcatWebServer 的 shutDownGracefully() 方法中:
// 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 滚动更新大致的流程是:
- 新 Pod 启动 → 通过就绪探针(ReadinessProbe)→ 加入 Service Endpoint
- 旧 Pod 收到 SIGTERM → 开始关闭
- 等待 terminationGracePeriodSeconds(默认 30s)→ 收不到 SIGTERM 响应就强制 SIGKILL
问题出在第 2 步和第 3 步之间:K8s 发送 SIGTERM 的同时,会把 Pod 从 Endpoint 中摘除。但摘除操作是异步的,等 kube-proxy 更新 iptables 规则、负载均衡器刷新路由表,需要几秒到十几秒的时间。如果旧 Pod 在收到 SIGTERM 后立刻拒绝新连接(connector.pause()),但这期间负载均衡器还在把流量发过来,请求就会直接失败。
正确的做法是:先摘除节点,再关闭服务。
不同负载均衡器的摘除延迟实测
| 负载均衡器类型 | 摘除延迟(典型值) | 建议 PreStop 多等时间 |
|---|---|---|
| kube-proxy (iptables) | 1-3s | 5s |
| kube-proxy (ipvs) | 0.5-1s | 3s |
| AWS ALB / NLB | 5-10s | 15s |
| 阿里云 SLB | 5-15s | 15s |
| 自建 Nginx + upstream | 1-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 配置:
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 侧需要配合:
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 端点默认被保护,需要显式放行:
@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() 中:
// HikariCP 关闭流程
public void close() {
// 1. 关闭连接池,所有连接被标记为"驱逐"
// 2. 等待正在使用的连接归还(默认 5 秒超时)
// 3. 物理关闭所有连接
this.hikariPool.shutdown();
}HikariCP 默认的关闭超时是 5 秒。如果业务线程持有数据库连接超过 5 秒,HikariCP 会强制关闭连接,导致业务线程后续操作拿到一个已关闭的连接。对于长事务场景,建议调大这个超时时间:
spring:
datasource:
hikari:
# 与 graceful shutdown 的超时匹配
shutdown-timeout: 30000
# 连接最大存活时间,配合优雅关闭
max-lifetime: 1800000踩坑实录:某次线上发布,一个定时任务刚好在关闭时跑了一个 10 秒的 SQL 查询。HikariCP 默认 5 秒超时,直接关闭了连接,SQL 查询异常退出,导致这批数据丢失。把 shutdown-timeout 改为 30 秒后解决。
线程池的优雅关闭
业务代码中自己创建的线程池,Spring 容器不会自动关闭。需要手动在 @PreDestroy 中处理:
@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 关闭:
# application.yml 中配置 Nacos 关闭时立即注销,而不是优雅等待
spring:
cloud:
nacos:
discovery:
# 关闭时立即注销
deregister-on-shutdown: trueEureka 的机制则不同: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 生产环境,必须补齐三层:
- K8s 层:
terminationGracePeriodSeconds给足时间(建议 45-60s),PreStop hook 中先触发关闭再 sleep 等待负载均衡器摘除 - Spring Boot 层:配置优雅关闭、Actuator shutdown 端点、数据库连接池超时
- 业务层:自定义线程池的 @PreDestroy 关闭逻辑、注册中心摘除
推荐的最小配置清单:
# Spring Boot 配置
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s
spring.datasource.hikari.shutdown-timeout=30000
management.endpoint.shutdown.enabled=true# 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()