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

📅 发布时间:2026/10/13 15:20:02
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.exitJVM 能感知到的终止信号叫 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 方法整个流程大致分这几步发布 ContextClosedEvent让所有监听器有机会做清理比如缓存清理、状态置位。调用 LifecycleProcessor.onClose()逐个停止实现了 SmartLifecycle 接口的组件Web 容器Tomcat、Undertow、Jetty的停止就是从这里触发的。执行 destroyBeans()销毁所有单例 Bean。关闭 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观察日志输出顺序。一套正常的优雅关闭日志应该是发布 ContextClosedEvent。Web 容器开始停止接收新请求。线程池开始 shutdown等待已提交任务完成。回调 PreDestroy 方法资源释放。JVM 退出。如果日志里第二步和第三步之间出现超时日志说明某个阶段卡住了往下看排查部分。另外不要把关闭验证放在本地随便点两下就完事建议在测试环境做一次真实演练用压测工具灌入请求在流量不断的情况下触发优雅关闭观察错误率曲线。这一步能发现很多只有在真实流量下才会暴露的问题。4. 常见问题排查与老手避坑4.1 关闭卡死三大根因与排查手段关闭卡死几乎都是这三类非守护线程无法结束某个线程池里的线程没走 shutdown或者直接 new Thread 起来没有设置 setDaemon(true)JVM 就会一直等它持有锁不释放业务线程在等待一把永远不被释放的锁shutdown 阶段同一把锁又被别的线程持有形成死锁网络调用超时销毁阶段某个回调里发了一个远程调用下游不响应默认超时又很长把整个关闭流程拖住。排查工具直接用 jstack 或 jcmdjcmd 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 阶段正常 closeJVM 一直不退出存在非守护线程jcmd Thread.print 查看非守护线程统一走线程池手动启动的线程 setDaemon(true)我在实际运维里最推荐的做法是给团队组织一次“关闭演练”。大家先列出所有需要关闭的资源再对照 Spring 生命周期排好清理顺序然后在测试环境反复模拟 SIGTERM。整个过程让我意识到Spring Boot 应用的关闭分析本质上是一场资源管理审计把所有还出去的东西、没处理完的任务全都盘一遍发布窗口的稳定性自然就上来了。如果你正在做类似的关闭改造按这个思路走会少踩很多坑。