做后端这些年,我最怕听到的一句话不是“线上又出bug了”,而是“我刚才发版把服务停了,用户数据好像少了一条”。Spring Boot应用关闭这件事,表面看是JVM收到一个信号然后退出,背后却牵扯到请求处理、连接池回收、线程调度、容器销毁一整条链路。大多数人对启动流程如数家珍,对关闭流程却几乎一无所知,总觉得“关服务嘛,kill一下就完了”。今天这篇文章,我把Spring Boot应用从收到关闭信号到进程真正退出之间的每一步都拆开讲清楚,包括Spring容器销毁Bean的顺序、2.3版本引入的优雅关闭机制、生产环境里怎么配合健康检查和流量摘除,以及我自己踩过的几个关闭相关的坑。适合正在维护Spring Boot后端服务、或者打算把应用放到Kubernetes里的开发、运维、SRE朋友读,看完你会对“关闭”这两个字有完全不一样的理解。
1. 为什么Spring Boot应用的“关闭”值得单独拿出来讲
先说结论:关闭不是按一下停止按钮,而是一次有顺序、有超时、有资源回收预期的完整生命周期事件。处理不当,轻则日志里堆满连接异常,重则在途请求被掐断导致数据不一致。
1.1 从一次“发版后偶发脏数据”说起
有次项目发版,操作同学图省事,对所有服务执行了kill -9。当天线上就出现零星几笔订单状态与账户流水对不上的情况。排查到最后,问题不在业务代码,而在于其中一个应用被强杀时,线程正执行到“写主订单表”和“写账户明细表”之间的临界区。主表提交了,明细表没有提交,数据就永远缺了一半。
那次事故之后我们立了个规矩:不允许kill -9,统一走优雅关闭。后来还专门复盘过,只要让JVM收到SIGTERM信号后正常走shutdown hook,Spring容器就会执行Bean销毁、连接池回收、在途请求处理等流程,这半个事务的窗口期基本能被覆盖住。换句话说,不是所有关闭方式都等价,SIGTERM和SIGKILL之间差着一整套Spring容器的“临终关怀”。
1.2 生产环境中的关闭场景清单
我梳理了一下,实际工作中会遇到这些关闭场景,它们对关闭行为的要求还不一样:
| 场景 | 触发方式 | 关闭要求 |
|---|---|---|
| 滚动发布 | Kubernetes滚动更新、注册中心摘流量后停止实例 | 必须优雅,先摘流量再停,不能断在途请求 |
| 水平缩容 | HPA缩容、手动减少副本 | 让存量请求处理完,避免客户端突然断连 |
| 计划内停机维护 | 运维手动停止服务 | 提前通知、摘流量、关闭后做资源检查 |
| 异常终止 | OOMKilled、健康检查失败被重启 | 尽量保留日志,让容器管理器接管后续 |
| 开发调试 | IDE里点停止按钮、本地Ctrl+C | 希望快速退出,但不希望端口半天释放不了 |
这里有个容易忽略的点:Kubernetes里如果容器主进程是shell脚本启动的java,而不是直接用exec启动的java,那SIGTERM信号会被shell吞掉,或者发送给错误的进程,Spring Boot根本收不到。这种“关闭信号没到达应用层”的问题在生产环境里特别常见,后面排查章节我会专门说。
2. 关闭时Spring容器到底做了些什么
要分析关闭,必须先理解信号、shutdown hook、Spring容器的销毁三者之间是怎么协作的。
2.1 JVM层的收尾机制:shutdown hook与信号处理
JVM对外暴露的关闭入口主要有两个信号:SIGTERM(kill命令默认发送)和SIGINT(Ctrl+C发送)。收到信号后,JVM会启动所有通过Runtime.addShutdownHook()注册的钩子线程,等钩子全部执行完,再执行必要的清理并退出进程。
SpringBoot启动过程中会把SpringApplicationShutdownHook注册到JVM里。这个钩子的核心作用就是在JVM退出前触发ApplicationContext的关闭。如果进程收到的是SIGKILL(kill -9),JVM连执行shutdown hook的机会都没有,Spring容器不会正常销毁,所有@PreDestroy、DisposableBean、ContextClosedEvent监听器全部失效。所以如果你想压测“模拟崩溃”的场景,用kill -9没问题;但所有能走SIGTERM的地方,都应该让应用体面退场。
2.2 Spring容器的销毁流程
当shutdown hook被触发后,ApplicationContext的关闭大致按这个顺序走:
- 发布
ContextClosedEvent事件,所有监听这个事件的组件先收到通知。 - 调用
LifecycleProcessor.onClose(),触发SmartLifecycle和普通LifecycleBean的stop()方法。SmartLifecycle按phase从大到小逆序停止,Spring Boot的优雅关闭web服务器就是借助这个机制实现的。 - 销毁单例Bean。销毁顺序遵循一个原则:一个Bean如果被其他Bean依赖,那么它后销毁;没有依赖关系的Bean,按容器内Bean创建的逆序销毁。
- 关闭BeanFactory本身,释放内部资源。
单个Bean销毁时,会依次执行@PreDestroy注解方法、DisposableBean.destroy()接口方法、@Bean(destroyMethod=...)指定的方法。这三种方式如果同时存在,执行的先后顺序大致是:先@PreDestroy,再destroy(),最后是destroyMethod。所以你在设计资源释放逻辑时,要清楚自己用的是哪一种机制,别在几个地方重复释放同一份资源。
2.3 哪些资源最容易在关闭时出问题
我总结下来,Spring Boot应用关闭时最容易出问题的资源有这么几类:
- 数据库连接池。HikariCP在容器销毁时会把池里的连接全部关闭,但如果关闭瞬间还有请求正在使用连接,就可能出现
connection is closed异常。 - 自定义线程池。很多项目手动创建
ThreadPoolTaskExecutor或ExecutorService,没有注册销毁逻辑,关闭时线程池里的任务直接丢弃,队列里的请求白白被吞。 - WebSocket长连接。服务端关闭前如果不对客户端发关闭帧,客户端很难感知到连接已断,只能等心跳超时,体验很差。
@Scheduled定时任务。容器销毁时调度线程如果不做优雅停止,可能刚好在任务执行中途被打断。- 消息队列消费者。消费线程正在处理但还没ack的消息,在服务重启后会被重新投递,如果你的业务不是天然幂等,就可能产生重复数据。
这里我特别想提醒一点:很多人以为Spring容器销毁时“所有Bean都会自动释放资源”,这是错觉。Spring只会调用Bean生命周期回调,具体怎么释放线程池、怎么关闭连接、怎么通知客户端,都是要你自己写的。
3. 优雅关闭的版本演进:从2.3到3.x
Spring Boot真正支持开箱即用的优雅关闭配置,是从2.3版本开始的。在这之前你想优雅关闭,得自己写SmartLifecycle去停Tomcat,或者依赖外部的摘流量工具,非常别扭。
3.1 先理解“优雅关闭”到底优雅在哪
优雅关闭的完整动作分三个阶段:停止接收新请求、处理完已经在途的请求、释放资源并退出。整个过程对调用方来说应该是“无感知”的——请求要么在关闭前完成,要么被负载均衡重新路由到别的健康实例,而不是直接被RST断开。
Spring Boot 2.3把这一套能力收敛成了两个配置项:server.shutdown=graceful表示启用优雅关闭,spring.lifecycle.timeout-per-shutdown-phase表示每个关闭阶段的最大等待时间。Tomcat、Jetty、Reactor Netty原生支持,Undertow的支持相对晚一些,使用时要根据你实际选用的版本确认一下,我建议直接查当前版本的官方文档,别拿旧文章里的配置硬套。
3.2 配置层面怎么做
以我的常用模板为例,application.yml里这样配:
server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30sserver.shutdown不显式配置时默认是immediate,也就是立刻关闭,和没配优雅关闭一样。timeout-per-shutdown-phase的意思是:每个关闭阶段最多等30秒,超时后Spring不再等这个阶段,强制进入下一阶段。比如Tomcat优雅停等在途请求,30秒还没处理完,就直接进入Bean销毁阶段了。
如果你用了异步任务和定时任务,还要额外配置:
spring: task: execution: shutdown: await-termination: true await-termination-period: 30s scheduling: shutdown: await-termination: true await-termination-period: 30s这两个配置分别是让@Async线程池和@Scheduled调度线程池在容器关闭时等待任务执行完,而不是立刻中断。很多项目只看server.shutdown=graceful,结果Web请求是优雅了,异步线程里的活还是被生生掐断,道理是一样的。
从版本演进角度看,2.3引入了能力,2.5/2.6之后配置趋于稳定,Spring Boot 3在关闭机制上没有推翻重来,但底层依赖的Tomcat版本、Jakarta命名空间迁移会带来一些行为差异。我的建议是:不要背版本号,认准配置项,在真实版本上做一次关闭演练,比任何文档都可靠。实际项目里我还见过Spring Boot Admin配合优雅关闭的玩法:关闭前先通过Admin实例列表确认服务是否还在,关闭后观察实例什么时候从列表消失,这比盯日志直观得多。不过注意,Spring Boot 2.6之后actuator默认只暴露health端点,Admin要看的其他信息必须显式开放。
3.3 停止接收流量的前提:健康检查与流量调度
优雅关闭解决的是“在途请求不丢”,但解决不了“新请求继续涌进来”。如果一个服务还没退出,负载均衡还在向它转发新请求,那Web服务器只能一边等着旧请求结束,一边不断拒绝新请求,客户端依然会报错。正确的姿势是:先摘流量,再触发关闭。
生产环境里的标准动作大致是这样:
- 先把实例从服务发现里摘掉,或者把健康检查改为不通过,让负载均衡不再路由新流量。
- 等待一个“静默窗口”,让已经转发的请求处理完。
- 发送SIGTERM触发优雅关闭,Spring Boot按配置等待在途请求结束。
- 超过编排平台给的终止宽限期后,平台发送SIGKILL强制执行兜底。
在Kubernetes里,这个时间线通过readinessProbe、preStop和terminationGracePeriodSeconds协同控制。preStop钩子可以延迟容器的终止,给注册中心留出摘流量时间;terminationGracePeriodSeconds则要大于Spring Boot优雅关闭的总耗时,否则优雅关闭还没跑完,SIGKILL就到了,功亏一篑。我见过太多人把这两个值配反了,结果进程总是被强杀,优雅关闭形同虚设。
4. 实操:配置一个可安全关闭的Spring Boot应用
理论讲完,下面给一套可以照抄的实操清单。这套配置我前后用在了三四个项目里,稳定性和可维护性都不错。
4.1 基础配置清单
完整配置如下,我逐项说明:
server: shutdown: graceful spring: application: name: order-service 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 datasource: hikari: maximum-pool-size: 20 connection-timeout: 30000 validation-timeout: 5000 idle-timeout: 600000 management: endpoints: web: exposure: include: health,info,metrics第一段是server.shutdown和spring.lifecycle,这是优雅关闭的核心。第二段是异步任务和调度任务的等待配置,注意两个命名空间不能写混,execution管@Async,scheduling管@Scheduled。第三段是数据源的一些防护性参数,connection-timeout和validation-timeout能避免在极端情况下连接池长时间阻塞,对关闭也是个隐形保护。
管理端点的暴露要克制,健康检查、基本info和metrics就够用了,能把shutdown端点暴露出来的一定要加认证或网络隔离,这个端点等于远程关机的开关,裸奔上生产就是给自己埋雷。
4.2 编写关闭时的收尾代码
配置只是第一步,应用自身该释放的资源必须写代码收尾。我常用的有三类写法。
第一类是@PreDestroy方法,适合做单Bean的资源清理:
@Component public class CacheFlushService { private final List<LocalCache> caches = new ArrayList<>(); @PreDestroy public void flushAllCache() { log.info("开始刷新本地缓存到Redis"); caches.forEach(LocalCache::flush); log.info("本地缓存刷新完成"); } }第二类是监听ContextClosedEvent,适合做跨Bean的协同收尾,比如通知注册中心下线、记录关闭开始时间:
@Component public class ShutdownReporter implements ApplicationListener<ContextClosedEvent> { @Override public void onApplicationEvent(ContextClosedEvent event) { long start = System.currentTimeMillis(); log.info("收到容器关闭事件,开始协调下线"); // 通知注册中心摘除实例、上报监控等 log.info("下线协调完成,耗时 {} ms", System.currentTimeMillis() - start); } }第三类是实现SmartLifecycle,适合控制关闭顺序。它的phase决定执行顺序:启动时phase从小到大,关闭时phase从大到小。比如消息消费者要先停止拉取,再关闭连接池:
@Component public class MessageConsumerManager implements SmartLifecycle { private volatile boolean running = false; @Override public void start() { running = true; // 开始消费 } @Override public void stop() { running = false; // 停止消费,等待在途消息ack } @Override public boolean isRunning() { return running; } @Override public int getPhase() { return Integer.MAX_VALUE; // 关闭时最先停止 } }写收尾代码时有三个坑要提醒你。第一,不要在@PreDestroy里做耗时很长的操作,优雅关闭的每个阶段都有超时上限;第二,不要在销毁回调里依赖那些可能已经被容器销毁的Bean,比如你在@PreDestroy里调Redis,但Redis连接池可能已经先被关了;第三,销毁回调里尽量少做网络调用,关闭阶段网络环境随时可能异常,一次失败的HTTP调用反而拖慢整个关闭流程。
4.3 线程池与连接池的正确关闭姿势
如果你手动创建了线程池,Spring容器并不会自动帮你回收。我项目里的标准写法是这样:
@Configuration public class ThreadPoolConfig { @Bean(destroyMethod = "shutdown") public ExecutorService reportExecutor() { return Executors.newFixedThreadPool(8, new ThreadFactory() { @Override public Thread newThread(Runnable r) { Thread t = new Thread(r, "report-worker"); t.setDaemon(false); return t; } }); } }关键点在于@Bean(destroyMethod = "shutdown"),Spring容器销毁这个Bean时会调用ExecutorService.shutdown(),已有的任务会继续执行完,新任务不再接收。但shutdown()不会永久等待,如果想给线程池一个明确的等待上限,可以在关闭回调里手动操作:
@Component public class ExecutorLifecycleManager { @PreDestroy public void closeExecutors() throws InterruptedException { reportExecutor().shutdown(); if (!reportExecutor().awaitTermination(30, TimeUnit.SECONDS)) { reportExecutor().shutdownNow(); } } }shutdown()和shutdownNow()的区别,一句话:前者让已提交任务执行完再停,后者直接中断正在跑的任务并返回队列里没执行的任务。生产环境默认用shutdown(),真遇到关闭卡住,再用shutdownNow()兜底。HikariCP本身在Spring容器里是会跟着Bean销毁的,你不需要手动调close(),但要在配置上给它合理的超时和空闲回收,避免关闭瞬间池子里还有一堆按minimumIdle保活的长连接。
连接池这里还有一个WebSocket的衍生问题。如果你的服务对外提供WebSocket长连接,比如消息推送,关闭前必须让客户端感知到。做法是在ContextClosedEvent或@PreDestroy里遍历所有会话,发送关闭帧,再调用session.close()。很多项目忽略这一步,服务端进程都退出了,客户端还傻傻等着,直到TCP超时才发现连接断了,中间这段时间用户看到的就是“推送没反应”。
4.4 用Spring Boot Admin和Actuator辅助观察
做了优雅关闭之后,怎么观测关闭过程是否如预期?日志是一方面,另一方面我建议接上Spring Boot Admin。Admin在项目里通常用来做实例存活监控、服务上下线提醒、在线查看日志和环境信息。关闭一个实例时,Admin页面能看到它从“正常”变成“离线”的过程,这个状态变化比任何日志都直观。
结合热词里提到的“Spring Boot实现监控都有哪些需求和功能”,我补充一个思路:监控在关闭场景下的真正价值不是“看到服务死了”,而是“看到服务正在死亡”。你可以把自己的ContextClosedEvent监听器里记录的时间点上报到监控系统,再和实例“从服务列表消失”的时间做对比,就能算出摘流量到真正停止的耗时。如果这个耗时异常大,说明要么在途请求太多,要么某个销毁回调卡住了。
5. 常见关闭问题与排查实录
这部分全是实战经验,每一个我都踩过或者帮别人排查过。
5.1 关闭卡住:明明配置了优雅关闭,30秒还没退出
最常见的表现是:日志停在“开始优雅关闭”,进程迟迟不退出。排查步骤如下:
- 先看
spring.lifecycle.timeout-per-shutdown-phase配置的值,确认是不是设成了很大,比如10分钟。 - 用
jstack <pid>抓线程栈,重点看RUNNABLE和WAITING状态的线程,找到阻塞点。 - 看是不是某个外部调用的超时时间比优雅关闭超时还长。比如Feign调用没设置
connectTimeout和readTimeout,默认可能几十秒,一个请求卡住,整个关闭阶段就被拖住。
我遇到过一次典型问题:一个项目用RestTemplate调用外部接口,外部服务挂了,连接一直不返回,优雅关闭的Web请求等待阶段把30秒配额耗尽,后面Bean销毁时连接池还残留一堆半开连接。解决办法很简单:所有外部调用必须显式设置超时,并且超时时间要小于优雅关闭的timeout配置。
5.2 数据源报错:关闭时大量connection is closed异常
这个现象多发生在没有优雅关闭或关闭顺序不对时。请求还在执行,HikariCP已经开始关连接,或者连接池最大生命周期到了,连接被后台线程关了,线程再去用它就报错。
解决思路是三层配合:第一层是摘流量,让新请求不再进来;第二层是优雅关闭,让在途请求处理完;第三层是连接池自身配置,把max-lifetime、idle-timeout这些参数想清楚。如果服务只是短暂重启,不建议为了规避关闭异常把连接池参数调得过于激进,你要相信优雅关闭的顺序比单个参数更能解决问题。
5.3 端口未释放、进程没死,或者K8s里怎么停都停不掉
端口未释放一般是有非守护线程或shutdown hook里做了阻塞操作。用lsof -i:<port>看谁占着端口,再用jstack确认线程。如果确认是shutdown hook阻塞,最快的处理是让运维把这台实例从流量池里摘掉,然后kill,必要时直接kill -9重建,不要和它耗着。
容器环境下更隐蔽的问题是“信号传不到Java进程”。Docker或Kubernetes里,如果启动命令是java -jar app.jar,那Java进程就是PID 1,信号能收到;但如果启动命令是/bin/sh -c "java -jar app.jar",信号会发给sh而不是Java,sh不会转发,Java进程根本不会触发优雅关闭。解决办法是在Dockerfile里用exec形式启动:
ENTRYPOINT ["sh", "-c", "exec java -jar /app.jar"]或者直接在ENTRYPOINT里写["java", "-jar", "/app.jar"],去掉中间的shell层。这个坑在K8s滚动发布时特别折磨人,表现为每次发布都像“强杀”,优雅关闭配置完全没生效,日志里连shutdown hook都没有。排查思路其实简单:先确认信号有没有到Java进程,再去查代码。
5.4 从日志快速定位关闭过程
我建议团队统一一套关闭日志规范。收到信号打一条“收到关闭信号,开始优雅关闭”,开始等待在途请求打一条“等待在途请求完成,已耗时XXms”,进入Bean销毁打一条“开始销毁Bean”,全部完成打一条“容器关闭完成”。有了这些时间点,一次关闭是快是慢、卡在哪一步,日志一翻就能定位。
实际排查时你还会发现,有些项目在logback.xml里为了“整洁”把INFO日志过滤掉了,导致关闭流程的关键日志根本看不到。我个人的建议是:关闭过程相关的日志至少用INFO级别,并且不要在这个阶段做日志异步刷盘,避免日志队列还没写出去进程就没了。
5.5 与版本、工具相关的几个衍生问题
有朋友问IntelliJ IDEA社区版里怎么用Spring Boot、关闭时为什么那么慢。社区版和付费专业版在Spring Boot支持上差异很大,社区版没有内置的Run Dashboard,很多启动配置要自己拼,但在“停止应用”这件事上两者没什么本质区别。IDE点停止按钮时默认发送的是SIGINT,相当于手工在终端里按了Ctrl+C,如果IDEA的停止选项里勾了“Kill before exit”,那就会走强杀。开发调试阶段我建议保留一段优雅关闭时间,这样本地测试“服务下线”行为才和生产一致。这里多说一句,很多人的WebSocket联调、第三方回调联调,其实都是在本地通过优雅关闭模拟生产停机的,比直接强杀更能暴露问题。
Spring Boot 2.6和2.3在关闭上的差异,整体来说2.3是能力起点,2.6之后配置和文档都更成熟。如果你在2.3.x遇到server.shutdown配置不生效的怪异问题,先检查版本号和后缀版本,再检查是不是用了Undertow。Spring Boot 3这边,除了依赖和命名空间的迁移,关闭机制没有破坏性变化,但我在两个Spring Boot 3项目里实测,Tomcat在高并发长连接场景下的停机等待表现比2.x更稳定,这跟底层Tomcat版本的线程模型优化有关系。
6. 关闭工程的最后一公里
如果上面这些你都配置好了,最后一公里是把它集成到发布流程里。我在Kubernetes里惯用的模板是:readinessProbe失败后,经过preStop钩子里的短暂sleep,给负载均衡一个摘流量窗口,然后terminationGracePeriodSeconds设置为“优雅关闭总超时+10秒”的冗余值。发布脚本里再配一个等待端口释放的循环,确保旧实例彻底退出后才开始启动新实例。这样一套下来,滚动发布时用户无感,日志里也不会出现一堆“connection reset”。
我个人在实际操作中的体会是:关闭分析这个事,调试阶段比线上更重要。开发环境里每次重启都能用jstack看一眼线程状态,顺手解决一个资源泄漏,比上线后凌晨三点爬起来翻日志要划算得多。真到线上出了问题,反而是那些平时不起眼的细节——比如一个没设超时的HTTP调用、一个忘了写destroyMethod的线程池——决定了一次发布是平稳还是事故。
最后分享一个小技巧:给应用加一个/internal/status内部接口,返回当前在途请求数量、线程池活跃数、连接池使用率。发布前点开看一眼,如果数字都降下来了,再执行关闭动作。我试过之后发现,有了这个数字,关闭就不是“赌一把”了,而是有依据的工程决策。后面你也可以试着把关闭过程的耗时、成功率上报到监控系统,做成一个“关闭健康度”指标,这样每次发布前都能自动发现那些“关不干净”的实例。