ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot优雅关闭实战:从SIGTERM到Bean销毁的完整链路

Spring Boot优雅关闭实战:从SIGTERM到Bean销毁的完整链路

“Spring Boot 应用关闭”这六个字,放在一起看像个没什么技术含量的话题,但只要你经历过一次线上事故,就会意识到它值得单独拉出来做一次专项分析。我之前接手过一套内部订单系统,团队发现每次发版期间总会出现“下单成功但支付记录缺失”的告警,排查到最后,问题不在业务逻辑,而在发布工具对旧实例执行强制结束之后,事务还没来得及落盘。那次之后,我开始把应用关闭当成一等公民来对待。

这个分析要解决的核心问题,就是从操作系统发送终止信号那一刻起,到 Spring 容器彻底销毁 Bean、释放资源、退出进程为止,中间发生的所有事情。它决定了三件事:正在处理的请求能不能跑完、线程池和连接池里的资源能不能干干净净还回去、以及数据最终的一致性由谁兜底。适合所有写 Spring Boot 服务的开发者读一读,尤其是那些正准备把服务迁到容器编排平台、靠探针和健康检查驱动上下线的同学,这部分内容一定用得上。

1. 为什么我把“关闭”当成一等公民来对待

1.1 关闭不是进程结束,而是交接班

打个比方,饭店打烊不是灯一拉全黑,而是要等最后一桌客人吃完,先关烤炉再关风机,锁门之前还要确认天然气阀门。应用关闭是同一个道理:等处理中的 HTTP 请求返回,让消费中的消息完成偏移提交,把数据库连接的归还动作做完,再销毁 Spring 容器。

关闭做得好不好,直接体现在三个指标上:请求成功率有没有在发布窗口掉点、有没有资源泄漏告警、数据库里有没有半截数据。很多人觉得关闭就是“进程退出,啥都不用管”,但实际上 Spring Boot 只是帮我们管理了 Web 容器和 Bean 的生命周期,你代码里自己起的线程、线程池、定时任务、消息消费者,它并不会自动替你安排妥当。

在 Spring Boot 2.3 之前,内置 Web 容器默认是立即关闭的,收到信号后连接器马上停止接收新请求,处理到一半的请求直接断,业务代码根本没有机会做清理。后来引入了 server.shutdown 和 lifecycle 超时配置,才把“优雅关闭”变成开箱即用的选项。但配置只是开头,代码里那些非受管资源,还是得开发者自己交底。

1.2 关闭不当的典型翻车现场

先说场景一:强制结束后的状态不一致。某业务服务的发布脚本用的是先停止再启动,停止动作就是一句 kill -9。结果每次发布完账务对账都有差异,最后发现部分新订单在数据库里只写了主表,关联表还没来得及写,事务上下文就已经没了。SIGKILL 是直接断电,事务管理器根本没机会 rollback 或 commit。

再说场景二:SIGTERM 下依然丢数据。某数据同步服务升级成 graceful 模式后,团队以为万事大吉,结果消费者线程在容器销毁后仍然处理内存里的半成品,回调接口已经连不上了,抛出异常后既没有重试也没有补偿。这属于“容器关了、业务线程还活着”的典型,Web 容器的优雅关闭只对 HTTP 请求生效,对消息线程一概不管。

还有场景三:关闭超时被上层系统强杀。某团队把优雅关闭超时时间设成 30 秒,但任务队列里压着几十万条消息,线程池还在慢慢跑,到了超时点上层编排系统直接强杀,数据一样没保下来。这三类现象都指向同一个答案:关闭需要按阶段设计,不是一两行配置能解决的。后面的执行链路拆解,就是围绕这个结论展开的。

2. 从信号到 Bean 销毁:关闭背后的执行链

2.1 JVM shutdown hook 是唯一入口

先明确一个底层事实:不管你是用 kill 命令、容器停止还是程序里调用 System.exit,JVM 能感知到的终止信号叫 SIGTERM,默认动作是触发 shutdown hook。Spring Boot 在启动阶段会注册一个 shutdown hook,用来在 JVM 关闭时调用 Spring 容器的 close 方法。

注意:kill -9 发送的是 SIGKILL,相当于直接拔电源,JVM 没有任何机会执行清理逻辑,shutdown hook 完全不会触发。

所以任何“我要优雅关闭”的讨论,第一步都是确保关闭流程走的是 SIGTERM,而不是 SIGKILL。另外 shutdown hook 本身只是个触发机制,真正的清理动作在 Spring 容器内部,理解这层关系,才能明白为什么配置了 graceful 之后,依然要关注 Bean 销毁和资源释放的顺序。

2.2 Spring 容器 close 的整体顺序

当 JVM 触发 shutdown hook 后,Spring 会调用 AbstractApplicationContext 的 close 方法,整个流程大致分这几步:

  1. 发布 ContextClosedEvent,让所有监听器有机会做清理,比如缓存清理、状态置位。
  2. 调用 LifecycleProcessor.onClose(),逐个停止实现了 SmartLifecycle 接口的组件,Web 容器(Tomcat、Undertow、Jetty)的停止就是从这里触发的。
  3. 执行 destroyBeans(),销毁所有单例 Bean。
  4. 关闭 BeanFactory 自身。

这里最关键的认知是:Web 容器的停止优先于 Bean 销毁。也就是说,shutdown 信号到达后,Spring 会先让 Tomcat 停止接入新连接、等待处理中的请求结束,然后才轮到业务 Bean 的 @PreDestroy 和 DisposableBean 回调。这个顺序保证了业务代码做资源清理时,已经不会有新的外部请求进来。

Bean 的销毁回调本身也有顺序:先执行 @PreDestroy 标注的方法,再执行 DisposableBean.destroy(),最后执行自定义 destroy-method。很多人只在 @PreDestroy 里打日志确认执行,却没注意到回调抛出异常会中断后续销毁,这个坑在第四章会单独讲。

2.3 两个关键配置项与容器差异

Spring Boot 2.3 开始提供两个核心配置,一个是 server.shutdown,默认值是 immediate,改成 graceful 后,Web 容器会进入优雅停止流程,给处理中的请求留出时间;另一个是 spring.lifecycle.timeout-per-shutdown-phase,用来控制每个关闭阶段的等待超时时间,默认在 30 秒左右。

不同 Web 容器的行为略有差异。Tomcat 在 graceful 模式下会先停止接收新请求,然后等待活动请求完成,超时后会强制关闭连接;Undertow 和 Jetty 也有各自的处理方式。所以如果你同时用了 Web 容器加自定义线程池,不能只依赖容器的优雅关闭,线程池的关闭需要自己在 SmartLifecycle 或 @PreDestroy 里显式控制。

我在实际项目里见过一种误区:团队把 server.shutdown 改成 graceful 之后,就认为所有线程都会等。实际上 HTTP 请求是容器管理的,但你自己创建的业务线程池、消息消费线程、定时任务线程,容器根本不知道它们的存在,更别说帮你优雅停掉了。所以在配置之外,代码层面的资源交底才是真正拉开差距的地方。

3. 实操:优雅关闭的落地配置与代码改造

3.1 基础配置一开始就改掉

最简单的做法,在 application.yml 里加上这两段配置:

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

如果希望通过接口主动触发关闭,可以暴露 actuator 的 shutdown 端点,但生产环境务必做好权限控制,否则等于给攻击者开了一扇门:

management: endpoint: shutdown: enabled: true

配置完成后,重点是验证:向应用发送 SIGTERM,观察日志里是否出现 Tomcat 停止接收新请求、等待处理中请求完成的记录,再观察业务线程池是否进入 drain 状态。很多项目改完配置就不管了,结果日志显示请求根本没等,多半是配置没生效、profile 加载不对,或者连的 Spring Boot 版本太老。

3.2 线程池关闭要交底

Web 容器的优雅关闭只对 HTTP 请求生效,你的业务线程池、异步处理线程、定时任务线程,它一概不关心。线程池的关闭一般分两步:

executorService.shutdown(); if (!executorService.awaitTermination(30, TimeUnit.SECONDS)) { executorService.shutdownNow(); }

第一步 shutdown() 让线程池拒绝新任务,已提交任务继续执行;第二步 awaitTermination 等待任务跑完,只给 30 秒,超时后用 shutdownNow() 中断还在执行的线程。

如果用的是 Spring 的 ThreadPoolTaskExecutor,还可以直接配置:

ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(30);

这套配置生效的原理是 ThreadPoolTaskExecutor 实现了 DisposableBean,在容器销毁阶段会调用 shutdown,而 waitForTasksToCompleteOnShutdown 决定是否等待任务完成。注意:它等待的是已经提交的任务,对于正在处理的长任务,依赖 awaitTerminationSeconds 来控制最长等待时间。我建议所有业务用的线程池都按这个标准配一遍,别让线程池裸奔。

3.3 消息消费者与定时任务的闭环

消息消费场景里,shutdown 时最怕的是消费者线程还在处理内存消息,连接却已经断了。常规做法是让消费者也走统一的线程池,并把偏移提交放在消息处理成功之后。关闭时先停止消费,等当前批次处理完,再提交偏移,然后释放连接。如果用的是 Spring 的 @Scheduled,建议不要让调度器裸跑,而是把核心逻辑包进自定义线程池,和上面的线程池关闭逻辑保持一致。

我自己习惯在关闭阶段额外做一步:把消息消费开关设计成类似“熔断器”的状态。收到关闭信号后,先把开关置为 false,消费者检查到 false 就不再拉取新消息,处理完手头任务后再通知线程池 shutdown。这样即使容器销毁顺序出了意外,业务侧也不会继续消费新数据,至少能保证不产生“处理一半、无补偿”的情况。

3.4 验证关闭顺序的两条命令

验证优雅关闭是否真的生效,我常用两条命令:

# 拿到 Java 进程 PID ps -ef | grep java # 发送 SIGTERM 信号 kill -15 <PID>

观察日志输出顺序。一套正常的优雅关闭日志应该是:

  1. 发布 ContextClosedEvent。
  2. Web 容器开始停止接收新请求。
  3. 线程池开始 shutdown,等待已提交任务完成。
  4. 回调 @PreDestroy 方法,资源释放。
  5. JVM 退出。

如果日志里第二步和第三步之间出现超时日志,说明某个阶段卡住了,往下看排查部分。另外不要把关闭验证放在本地随便点两下就完事,建议在测试环境做一次真实演练:用压测工具灌入请求,在流量不断的情况下触发优雅关闭,观察错误率曲线。这一步能发现很多只有在真实流量下才会暴露的问题。

4. 常见问题排查与老手避坑

4.1 关闭卡死:三大根因与排查手段

关闭卡死,几乎都是这三类:非守护线程无法结束,某个线程池里的线程没走 shutdown,或者直接 new Thread 起来没有设置 setDaemon(true),JVM 就会一直等它;持有锁不释放,业务线程在等待一把永远不被释放的锁,shutdown 阶段同一把锁又被别的线程持有,形成死锁;网络调用超时,销毁阶段某个回调里发了一个远程调用,下游不响应,默认超时又很长,把整个关闭流程拖住。

排查工具直接用 jstack 或 jcmd:

jcmd <PID> Thread.print

重点看 shutdown hook 相关线程停在哪个调用栈上,比如等待锁、等待 Future.get、等待数据库连接,基本就能定位到卡点。经验之谈:优雅关闭阶段不要做任何远程调用,能做本地的绝不要出网。关闭是退出流程,不是业务处理流程,把数据写本地文件都比远程调用靠谱。

4.2 回调异常会中断后续销毁

@PreDestroy 方法里如果抛出未捕获异常,Spring 的销毁逻辑会被打断,后面的 Bean 可能不会执行销毁。所以回调方法里要么 try-catch 包住所有异常,要么把清理逻辑拆得足够原子,确保任何一步失败都不影响下一步。这块最容易踩坑的是:某个 Bean 的 @PreDestroy 里调用了外部接口,外部超时,异常抛出来,容器销毁直接中断,最终表现为“日志里销毁到一半就停了”。

我在代码审查时看到一个很典型的写法:@PreDestroy 方法里直接调用了三方接口,还没接 try-catch,接口超时时间设置三分钟。一旦触发优雅关闭,整个销毁流程卡在这个回调上,后面所有资源都得不到释放。后面改成先释放本地资源、再异步通知外部系统,问题就解决了。

4.3 kill -9 的兜底策略

再完备的优雅关闭,也挡不住无限等待。生产上我推荐在优雅关闭之上再包一层兜底:先发 SIGTERM,等待一段时间(比如给你配置的优雅关闭超时时间再加 10 秒),如果进程还没退出,再发 SIGKILL。这个逻辑可以写在发布脚本里,也可以交给容器编排平台的终止策略。兜底策略的目的是防呆,而不是替代优雅关闭。优雅关闭负责把能做完的事做完,兜底负责保证进程一定退出,两者配合才不会出现“关不死”的僵尸实例。

这里再补一个细节:如果你的服务绑定在容器编排平台上,探针在关闭阶段的表现也值得关注。有些平台在发送终止信号后会继续检查存活探针,如果应用关闭时探针返回异常,可能会被提前强杀,导致优雅关闭窗口被压缩。所以关闭阶段最好让探针返回“已停止接收请求”的状态,而不是直接失败,给关闭流程留出完整窗口。

4.4 关闭阶段问题速查表

现象可能原因排查方向推荐处理
kill -15 后进程几秒就退出,请求全断server.shutdown 没开或配置未生效检查配置与加载的 profile显式开启 graceful,确认版本不低于 2.3
关闭等待超过预期,最终被强杀业务线程池未交底,队列任务过多jstack 看线程状态与队列积压线程池 shutdown 加 awaitTermination,调整超时
日志显示销毁到一半就停了@PreDestroy 抛出异常看回调代码里的异常日志回调方法全量 try-catch
消息重复消费或丢失偏移提交发生在关闭之后检查消费线程与偏移提交顺序先停消费,再提交偏移,最后 shutdown 线程池
优雅关闭了,但数据库连接报警DataSource 连接池未释放或释放超时检查连接池监控与关闭日志确保所用连接池在 destroy 阶段正常 close
JVM 一直不退出存在非守护线程jcmd Thread.print 查看非守护线程统一走线程池,手动启动的线程 setDaemon(true)

我在实际运维里最推荐的做法,是给团队组织一次“关闭演练”。大家先列出所有需要关闭的资源,再对照 Spring 生命周期排好清理顺序,然后在测试环境反复模拟 SIGTERM。整个过程让我意识到,Spring Boot 应用的关闭分析,本质上是一场资源管理审计,把所有还出去的东西、没处理完的任务全都盘一遍,发布窗口的稳定性自然就上来了。如果你正在做类似的关闭改造,按这个思路走,会少踩很多坑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进