欢迎你来读这篇博客,这篇博客主要是关于 Spring Boot 优雅停机。
其中包括了 Spring Boot 应用在关闭时的生命周期、server.shutdown=graceful 配置、timeout-per-shutdown-phase 的作用、
@PreDestroy 的执行时机,以及在生产环境中如何配合线程池、消息队列和 Kubernetes 做更可靠的停机处理。
序言
在开发环境里,我们经常随手停止一个 Spring Boot 应用,IDE 点一下红色按钮,或者直接杀掉进程,好像也没什么问题。
但到了生产环境,停机就不是这么随意的事情了。
假设一个服务正在处理订单、结算、支付回调、消息消费或者文件上传,这时候如果进程被直接强杀,就可能出现请求处理中断、事务未完成、消息未确认、资源未释放等问题。
这类问题最麻烦的地方在于,它们通常不是马上炸,而是变成后续排查时的“历史遗留悬案”。
所以,优雅停机不是一个锦上添花的配置,而是生产服务下线时应该具备的基本能力。
一句话概括:
优雅停机就是让应用在退出前,不再接新活,处理完手上的活,释放资源,然后再体面地下线。
正文
chapter 1:什么是优雅停机
所谓优雅停机,英文叫 Graceful Shutdown。
它并不是简单地让应用“慢一点关闭”,而是让应用在收到关闭信号后,按照一个相对安全的顺序退出。
一个比较理想的停机过程应该是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| 收到关闭信号 ↓ 停止接收新请求 ↓ 等待正在处理的请求完成 ↓ 停止后台任务、定时任务、消息消费 ↓ 关闭线程池 ↓ 销毁 Spring Bean ↓ 执行 @PreDestroy 等销毁逻辑 ↓ 释放连接池、文件句柄、网络连接等资源 ↓ 应用退出
|
如果没有优雅停机,应用可能就是下面这种情况:
1 2 3 4 5 6 7
| 正在处理请求 ↓ 进程被强杀 ↓ 请求中断 ↓ 事务、消息、资源状态变得不可控
|
这就像饭还没吃完,服务员直接把桌子抬走了。
你说它效率高吧,确实挺高;你说它文明吧,多少有点抽象。
chapter 2:Spring Boot 中如何开启优雅停机
Spring Boot 中可以通过下面的配置开启优雅停机:
1 2 3 4 5 6
| server: shutdown: graceful
spring: lifecycle: timeout-per-shutdown-phase: 30s
|
其中:
1 2
| server: shutdown: graceful
|
表示开启 Web Server 的优雅停机能力。
应用关闭时,内嵌 Web 容器会停止接收新请求,并给已经进入应用的请求一段时间完成处理。
1 2 3
| spring: lifecycle: timeout-per-shutdown-phase: 30s
|
表示每个关闭阶段最多等待 30s。
如果在这个时间内请求或生命周期组件没有正常完成,Spring 不会一直等下去,而是继续推进关闭流程。
需要注意的是,timeout-per-shutdown-phase 并不等于“整个应用最多只能关闭 30 秒”。它更准确的含义是:Spring
在关闭每个生命周期阶段时的等待时间。
所以,如果项目中存在多个生命周期组件,真实关闭时间要结合实际情况测试。
chapter 3:停机时 Spring Boot 内部大致发生了什么
当 Spring Boot 应用收到正常关闭信号后,大致流程如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| 收到 SIGTERM ↓ JVM Shutdown Hook 被触发 ↓ SpringApplication 开始关闭 ApplicationContext ↓ Web Server 开始 graceful shutdown ↓ 停止接收新请求 ↓ 等待已有请求完成 ↓ 停止 SmartLifecycle Bean ↓ 销毁普通 Bean ↓ 执行 @PreDestroy、DisposableBean、destroyMethod ↓ 应用进程退出
|
这里有一个非常关键的点:
优雅停机依赖正常关闭信号。
例如:
这种方式默认发送的是 SIGTERM,应用有机会执行关闭流程。
但是:
发送的是 SIGKILL,这是强杀。JVM 没有机会执行 shutdown hook,Spring 也没有机会关闭上下文。
所以,kill -9 不属于优雅停机。
它更像是“别解释了,直接下线”。
在生产环境里,kill -9 应该是最后手段,而不是发布脚本里的日常操作。
chapter 4:@PreDestroy 的作用
很多人一提到 Spring Boot 停机,就会想到 @PreDestroy。
例如:
1 2 3 4 5 6 7 8 9 10 11
| import jakarta.annotation.PreDestroy; import org.springframework.stereotype.Component;
@Component public class ShutdownLogger {
@PreDestroy public void destroy() { System.out.println("应用正在关闭,开始释放资源..."); } }
|
@PreDestroy 是 Spring Bean 销毁前执行的回调方法。
它适合做一些资源清理工作,比如:
- 关闭本地缓存;
- 刷新内存数据;
- 关闭自定义客户端;
- 打印停机日志;
- 释放非 Spring 管理的资源。
但是,@PreDestroy 不是万能的。
它不应该承担所有停机逻辑。
比如下面这些事情,只靠 @PreDestroy 通常是不够的:
- 等待 HTTP 请求处理完成;
- 等待线程池任务执行完成;
- 停止消息队列继续拉取消息;
- 等待正在消费的消息 ACK;
- 停止定时任务;
- 从注册中心或负载均衡中摘除流量。
所以,更准确地说:
@PreDestroy 是停机过程中的最后收尾动作,而不是整个优雅停机机制本身。
chapter 5:线程池也要优雅关闭
很多业务逻辑并不是直接在 HTTP 请求线程里完成的。
例如:
@Async 异步任务;
- 自定义线程池;
- 定时任务;
- 批量计算任务;
- 文件处理任务;
- 消息消费后的异步处理。
如果只配置了:
1 2
| server: shutdown: graceful
|
那么它主要保护的是 Web 请求层面,并不代表所有后台线程都能自动执行完成。
如果项目里使用 Spring 的任务执行器,可以增加下面的配置:
1 2 3 4 5 6 7 8 9 10
| spring: task: execution: shutdown: await-termination: true await-termination-period: 30s scheduling: shutdown: await-termination: true await-termination-period: 30s
|
如果是自定义线程池,可以这样配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@Configuration public class ExecutorConfig {
@Bean public ThreadPoolTaskExecutor bizExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setThreadNamePrefix("biz-executor-"); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(1000);
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize(); return executor; } }
|
这个配置非常重要。
否则就可能出现一种尴尬情况:
HTTP 请求已经优雅停机了,但异步线程池里的任务被直接中断了。
对于普通日志任务,这可能只是少打一条日志。
但对于结算、库存、支付、消息确认这类业务,问题就大了。
生产环境最怕的不是报错,而是“半成功”。
半成功的数据,通常比失败更难处理。
chapter 6:消息消费场景要特别小心
如果应用中有 Kafka、RabbitMQ、RocketMQ 等消息消费逻辑,优雅停机更要谨慎。
理想顺序应该是:
1 2 3 4 5 6 7 8 9 10 11
| 停止拉取新消息 ↓ 等待当前正在处理的消息完成 ↓ 提交 offset 或 ACK ↓ 关闭 consumer ↓ 关闭线程池 ↓ 关闭数据库连接池等底层资源
|
这里最容易犯的错误是:底层资源先关闭,业务线程还在跑。
例如:
1 2 3 4 5 6 7
| 数据库连接池已经关闭 ↓ 消息消费线程还在处理业务 ↓ 业务继续访问数据库 ↓ 报错
|
这种问题看起来像数据库异常,本质上却是停机顺序不对。
如果停机顺序比较复杂,可以考虑实现 SmartLifecycle,通过 phase 控制组件的启动和关闭顺序。
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| import org.springframework.context.SmartLifecycle; import org.springframework.stereotype.Component;
@Component public class MessageConsumerLifecycle implements SmartLifecycle {
private volatile boolean running = false;
@Override public void start() { running = true; }
@Override public void stop(Runnable callback) { try { running = false; } finally { callback.run(); } }
@Override public void stop() { stop(() -> {}); }
@Override public boolean isRunning() { return running; }
@Override public int getPhase() { return 1000; } }
|
注意,stop(Runnable callback) 中一定要调用:
否则 Spring 可能会一直等到超时。
chapter 7:Kubernetes 环境下的优雅停机
如果应用部署在 Kubernetes 中,只配置 Spring Boot 还不够。
因为 Kubernetes 删除 Pod 时,大致会经历下面的过程:
1 2 3 4 5 6 7 8 9
| Pod 被标记为 Terminating ↓ 执行 preStop hook ↓ 发送 SIGTERM 给容器主进程 ↓ 等待 terminationGracePeriodSeconds ↓ 如果还没退出,发送 SIGKILL 强杀
|
所以,Kubernetes 下要同时考虑:
- Spring Boot 的 graceful shutdown;
- Kubernetes 的
preStop;
- Kubernetes 的
terminationGracePeriodSeconds;
- readinessProbe 的流量摘除;
- 外部负载均衡的刷新延迟。
一个常见配置如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| spec: terminationGracePeriodSeconds: 60 containers: - name: app image: your-app:latest lifecycle: preStop: exec: command: [ "sh", "-c", "sleep 10" ] readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 livenessProbe: httpGet: path: /actuator/health/liveness port: 8080
|
为什么 preStop 里要 sleep 10?
因为 Pod 开始 terminating 后,服务发现和负载均衡摘除流量并不一定是瞬间完成的。
可能会出现一个短暂窗口:
preStop sleep 的作用,就是给 Kubernetes、Service、Ingress 或外部负载均衡一点时间完成流量摘除。
但是要注意时间对齐。
如果配置是:
1 2 3
| spring: lifecycle: timeout-per-shutdown-phase: 30s
|
同时:
1 2 3
| preStop: exec: command: [ "sh", "-c", "sleep 10" ]
|
那么 Kubernetes 的:
1
| terminationGracePeriodSeconds: 60
|
就要大于 preStop 时间加上 Spring Boot graceful shutdown 的等待时间。
否则就会出现:
1 2 3 4
| preStop sleep 10s Spring Boot 还想等待 30s Kubernetes 总共只给 30s 结果 Spring 还没优雅完,K8s 直接 SIGKILL
|
这就像公司说“你慢慢交接”,但半小时后门禁直接失效。
这不是优雅,是流程互相背刺。
chapter 8:推荐配置模板
Spring Boot 推荐配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| server: shutdown: graceful
spring: lifecycle: timeout-per-shutdown-phase: 30s
task: execution: shutdown: await-termination: true await-termination-period: 30s scheduling: shutdown: await-termination: true await-termination-period: 30s
management: endpoint: health: probes: enabled: true endpoints: web: exposure: include: health,info
|
Kubernetes 推荐配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| spec: terminationGracePeriodSeconds: 60 containers: - name: app image: your-app:latest lifecycle: preStop: exec: command: [ "sh", "-c", "sleep 10" ] readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 livenessProbe: httpGet: path: /actuator/health/liveness port: 8080
|
这套配置的核心思路是:
1 2 3 4 5 6
| Kubernetes 负责流量摘除 Spring Boot 负责应用上下文关闭 Web Server 负责请求排水 线程池负责等待任务完成 消息消费者负责停止拉取和完成 ACK @PreDestroy 负责最后资源清理
|
不要指望一个配置解决所有问题。
真正的生产稳定性,往往来自多个层面的配合。
chapter 9:如何验证优雅停机是否生效
优雅停机一定要验证,不要只看配置。
可以写一个慢接口:
1 2 3 4 5 6 7 8 9 10 11 12
| import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController;
@RestController public class DemoController {
@GetMapping("/slow") public String slow() throws InterruptedException { Thread.sleep(20_000); return "ok"; } }
|
启动应用:
发起请求:
1
| curl http://localhost:8080/slow
|
在请求还没返回时,执行:
观察结果:
- 请求是否正常返回;
- 应用是否在请求完成后退出;
- 新请求是否不再被接收;
@PreDestroy 是否执行;
- 线程池任务是否执行完成;
- MQ 消费是否完成 ACK;
- Kubernetes 中 Pod 是否被
SIGKILL;
- 日志中是否出现连接池已关闭但业务还在执行的异常。
可以加一个停机日志:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import jakarta.annotation.PreDestroy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component;
@Component public class ShutdownLogger {
private static final Logger log = LoggerFactory.getLogger(ShutdownLogger.class);
@PreDestroy public void onShutdown() { log.info("[shutdown] application is shutting down, start cleanup"); } }
|
验证时尽量不要只在 IDE 里点停止按钮。
不同 IDE 的停止行为可能不同,有些情况下并不能完整模拟生产环境中的 SIGTERM。
更推荐用真实 jar 包运行,然后通过 kill <pid> 验证。
chapter 10:常见误区
误区一:配置了 graceful 就万事大吉
不对。
server.shutdown=graceful 主要解决 Web 请求层面的优雅停机。
线程池、消息消费、定时任务、外部连接、注册中心流量摘除,都需要单独考虑。
误区二:@PreDestroy 等于优雅停机
不对。
@PreDestroy 是 Bean 销毁前的回调。
它适合做资源清理,但不适合承载整个停机流程。
误区三:kill -9 也能触发优雅停机
不能。
kill -9 是强杀,JVM 没有机会执行 shutdown hook,Spring 也没有机会关闭上下文。
误区四:超时时间越长越好
不一定。
时间太短,业务来不及收尾。
时间太长,发布、扩缩容、故障恢复都会变慢。
比较合理的做法是根据接口 P99、最长业务任务、MQ 消费耗时和发布系统超时限制来综合设置。
误区五:Kubernetes 会自动帮我处理好一切
不会。
Kubernetes 只负责容器生命周期管理。
应用内部的请求排水、线程池关闭、消息确认、资源释放,仍然需要应用自己设计。
参考资料
- Spring Boot Reference Documentation:Graceful Shutdown
- Spring Framework Reference Documentation:Using @PostConstruct and @PreDestroy
- Spring Framework Reference Documentation:Customizing the Nature of a Bean
- Kubernetes Documentation:Pod Lifecycle
- Kubernetes Documentation:Container Lifecycle Hooks
启示录
优雅停机的本质,不是让应用“慢慢关闭”,而是让系统在退出时保持秩序。
一个可靠的应用,不应该只关注启动速度,也应该关注退出姿势。
上线要稳,下线也要稳。
真正的工程能力,不只体现在服务能跑起来,也体现在服务能不能安全地停下来。
富贵岂由人,时会高志须酬。
能成功于千载者,必以近察远。