有一次我在VSCode里跑一个Spring Boot + MyBatis的多商户商城Demo,临时想换个端口号,改了application.yml准备把8080改成8081再重启。结果前一个进程还没退出,8080端口一直处于占用状态,启动直接报错。我当时第一反应是kill -9,可杀完之后再启动,居然还是提示端口被占。折腾了一圈才发现,旧进程的子线程没清理干净,TCP连接还在TIME_WAIT状态。就是那次经历让我意识到:很多人对Spring Boot应用关闭的理解,其实停留在“点了停止按钮、进程窗口消失”这个层面。
这篇东西想把我这些年围绕Spring Boot应用关闭踩过的坑、读过的源码、在生产和容器环境里实测过的方案,完整梳理一遍。适合正在用Spring Boot做日常开发、或者正准备把它部署到生产环境(尤其是K8s环境)的工程师看。你说它是底层原理也好,说它是排障手册也行,总之先把关闭这扇门彻底打开,把里面的齿轮一个一个看清楚。
1. 关闭的几种姿势:从Ctrl+C到kill -9,差别有多大
1.1 开发态:IDE停止按钮、Ctrl+C与VSCode终端
开发环境下,关闭Spring Boot应用的方式五花八门。最常见的是在IDEA里点那个红色方块,或者在VSCode的Spring Boot Dashboard里点Stop,再或者在终端里直接按Ctrl+C。这三种方式本质都是向JVM发送中断指令,IDEA和VSCode的Stop按钮会调用进程的destroy()或者发送SIGINT/SIGTERM信号。很多人在这一步会遇到一个现象:点了停止之后,进程还在系统列表里待着,用lsof -i:8080去看端口,发现8080还被占着,日志停在“Closing org.springframework.boot.web.embedded.tomcat.TomcatWebServer”之后就没下文了。
这种情况十有八九是JVM收到了关闭信号,开始执行shutdown hook,但某个非守护线程卡在资源清理或者业务逻辑中,导致进程没有真正退出。我见过最典型的是开发调试时,某次请求里的子线程还在等待数据库连接或者HTTP长轮询返回,关闭指令一下去,那个线程还在等,JVM就只能一直挂着。更隐蔽的情况是,你在文件里改了端口号,但忘了旧进程是上一个配置启动的,新进程起来之前旧进程根本没退出。所以“修改端口号”这件小事,背后也藏着关闭机制的问题。
1.2 生产态:kill命令与Actuator关闭端点
生产环境比开发环境稍微讲究一点,但不少早期项目也就停留在kill -9的层面。这里必须先明确一个概念:kill -9(SIGKILL)是操作系统级别的强制终止,JVM不会执行任何关闭钩子,也就是说Spring Boot的ApplicationContext根本没有机会做close。这不是关闭,是猝死。正确做法是kill -15(SIGTERM),让JVM有机会把shutdown hook跑完。跑完意味着Tomcat连接器能先停止接收新请求,再逐步释放端口;意味着每个注册到容器里的bean能有机会执行@PreDestroy或者destroy()方法;意味着数据库连接池、线程池这类资源能按顺序收尾。
除了信号,Spring Boot还提供了一个软关闭入口:Management端点里的shutdown。这个端点默认是禁用的,需要显式开启management.endpoint.shutdown.enabled=true,而且只支持POST。它的实现原理就是调用SpringApplication.exit(context),走一遍应用上下文的标准关闭流程。这种方式适合在内部运维平台上做“优雅关机”,但要注意必须加权限保护,否则任何人都可以POST一下把你的服务关停。我一般不建议对外暴露,内网运维平台调用倒是挺合适。
1.3 一张表看懂各种关闭方式
| 关闭方式 | 触发信号/机制 | 是否执行shutdown hook | 适用场景 | 风险等级 |
|---|---|---|---|---|
| IDEA/VSCode停止按钮 | 调用destroy()或发送SIGINT/SIGTERM | 是 | 本地开发 | 低 |
| 终端Ctrl+C | SIGINT | 是 | 本地开发 | 低 |
| kill -15 PID | SIGTERM | 是 | 生产、容器 | 低 |
| kill -9 PID | SIGKILL | 否 | 紧急强制结束 | 高 |
| Actuator shutdown | SpringApplication.exit | 是 | 内部运维平台 | 中,需鉴权 |
| K8s滚动发布 | SIGTERM + preStop钩子 | 是 | 容器环境 | 低 |
表里的每一行我都踩过或者亲眼见过对应的事故。比如用kill -9杀掉生产进程,后面数据库连接池的物理连接还在等数据库心跳,数据库端发现连接不活跃开始回收,就会引发断连风暴,这些细节放到后面细说。你只需要先记住一句话:只要不是必须立刻干掉进程的情况,一律用kill -15;容器编排平台默认发SIGTERM,那就不要再手动补一道kill -9。
2. 真正干活的代码:从JVM shutdown hook到close()的完整调用链
2.1 启动时埋下的钩子
要理解Spring Boot应用关闭,必须从JVM的shutdown hook说起。Java的Runtime允许注册关闭钩子,JVM收到SIGTERM/SIGINT后会按注册顺序执行这些钩子。Spring Boot在启动时干了这么一件事:SpringApplication.run()的过程中会创建并注册一个SpringApplicationShutdownHook,同时把当前ApplicationContext挂上去。等到JVM收到信号,这个钩子就负责驱动整个应用上下文关闭。
如果你翻源码,会在SpringApplication类里看到registerShutdownHook相关的逻辑。Spring Boot 2.3之前的做法和2.3之后的做法有些区别——早期版本让ApplicationContext自己注册一个shutdownHook(AbstractApplicationContext.registerShutdownHook()),Spring Boot 2.3改成了由SpringApplication统一注册SpringApplicationShutdownHook,这样做的目的是区分“JVM关闭”和“调用close()关闭”两种场景。这个细节到后面讲优雅关闭时会体现出来,先有个印象。
2.2 doClose()执行的三段式清理
当关闭信号被钩子捕获后,核心动作是调用ConfigurableApplicationContext.close()。以咱们最常见的AnnotationConfigServletWebServerApplicationContext为例,它继承自AbstractApplicationContext,close()方法最终落到doClose()。doClose()里其实做了三件大事。
第一件,发布ContextClosedEvent。这个事件很重要,因为很多第三方组件就是监听这个事件来做清理的,比如Spring Data的MongoDB、Redis的客户端,以及一些消息中间件。第二件,执行lifecycleProcessor.onClose(),这一步用来停止那些实现了Lifecycle接口和SmartLifecycle接口的bean,比如后台线程、定时任务、消息监听容器。第三件,调用destroyBeans(),把所有单例Bean按逆序销毁,这时候你写在Bean里的@PreDestroy和DisposableBean.destroy()才能真正执行。
这个顺序是写死的:先事件,再生命周期,最后销毁Bean。很多人误以为Bean销毁在最前面,结果在@PreDestroy里拿不到上下文或者发现线程池还在跑,就是因为对顺序的理解有偏差。事件发布在Bean销毁之前,所以监听ContextClosedEvent的组件在那一刻还能借助上下文做点收尾动作。
2.3 从Spring Boot 2.3开始,钩子管理变了
多说一句Spring Boot 2.3的改动,因为很多从老版本升级上来的同事对这个问题完全没有感知。老版本(2.3之前)里,ApplicationContext自己注册shutdown hook,SpringApplication又额外处理一遍,有时会出现重复关闭的日志或者奇怪的NPE。2.3之后,SpringApplicationShutdownHook成为唯一入口,它内部会等所有注册的SpringApplication关闭完成,并且有一套超时工厂机制来避免无限期等待。
这个设计其实是在告诉你:关闭流程不是无限期的,等太久就是等死。你在运维层面设置kill流程之前,应用内部已经有一套自己的“宽限期”概念了。顺带提一句,SpringApplication.exit()也会触发同样的关闭链路,只是它额外多发布一个ExitCodeEvent,方便你根据退出码做后续处理。比如:
int exitCode = SpringApplication.exit(applicationContext, () -> 0); System.exit(exitCode);这一段代码在需要自己控制退出码的场景里非常实用,比如批处理任务跑完以后根据结果决定是否让JVM返回非0状态。
3. 关闭时资源释放的顺序:谁先停、谁后停,源码里写死了
3.1 phase越大越先关闭
前面提到doClose()的第二件事是lifecycleProcessor.onClose(),这里有必要展开讲排序机制。DefaultLifecycleProcessor在onClose()执行时,会把所有实现SmartLifecycle的bean按phase值排序,然后从大往小依次调用stop()。phase的本质是关闭优先级:phase越大,越早被停止。
为什么是这个方向?因为设计者假定phase大的组件是基础性的、被依赖的底层组件,要先把依赖它的上层组件停掉,最后关掉底层。举个例子,如果有一个数据同步管道,上层是消息监听器(phase=100),底层是连接工厂(phase=0),那关闭时先停监听器,再关连接工厂,这样才能保证停机过程中不再有新消息进来触发底层调用。
Spring内置的一些组件也用了phase,比如task scheduling的ScheduledAnnotationBeanPostProcessor,以及spring-web中的一些生命周期组件。如果你自己写的后台任务bean实现了SmartLifecycle,注意别把phase设成所有组件里最小的那个,否则你会看到别的资源都关了,你的组件还在坚持工作,等待它的线程自然也就不肯退出。一个典型的自定义关闭阶段代码长这样:
@Component public class GracefulTaskLifecycle implements SmartLifecycle { private volatile boolean running; @Override public void start() { running = true; } @Override public void stop() { stop(() -> running = false); } @Override public boolean isRunning() { return running; } @Override public int getPhase() { return Integer.MAX_VALUE - 10; } }把phase设置成接近Integer.MAX_VALUE,意味着它会在关闭流程的早期就被叫停。这个“叫停”不等同于线程中断,只是告诉你的组件该停了,具体怎么停是你自己的事。
3.2 三种销毁回调的执行顺序
Bean层面的销毁也讲究顺序。按Spring的规则,单例Bean在容器关闭时会按创建顺序的逆序执行销毁。在整个销毁流程中,Spring按照以下优先级寻找销毁逻辑:首先是实现了DisposableBean接口的destroy()方法,其次是@PreDestroy注解标注的方法,再然后是init-method/destroy-method这类配置指定的方法。一个Bean同时具备这三种销毁逻辑的话,执行顺序就是上面这个顺序。
这里有一个容易踩的坑:如果你在@PreDestroy里提交了一个新的异步任务,而这个异步任务跑在非守护线程上,那么Bean销毁完不等于线程池销毁。更麻烦的是,如果这个线程池没有被Spring管理(比如你直接new了一个ThreadPoolExecutor),它压根不会进入destroyBeans()的流程。进程能否退出就看这个线程池的线程是不是已经进入空闲等待。所以我的习惯是:所有在Bean内部创建的线程池都要用@Bean来管理,或者至少实现SmartLifecycle,在stop()里执行shutdown()并等待终止,否则关闭就变成碰运气。
3.3 被漏杀的典型资源
顺着上一节往下,我盘点一下关闭时最常见的漏网之鱼,这些都是我在线上和本地遇到过不止一次的:
- 手动new的ScheduledThreadPoolExecutor:核心线程默认存活时间无限长,只要没在关闭时shutdown,进程一定卡住。
- HTTP长轮询或者WebSocket连接:Tomcat的优雅关闭会等这些请求完成,如果客户端一直不断开,又没有配超时时间,整个停机窗口就被无限拉长。
- 自己实现的消费者线程:从内存队列里poll消息的while循环,如果没有给队列设置超时,或者没有正确处理线程中断标志,它就永远在poll。
- 获取数据库连接卡住的线程:连接池最大等待时间过长时,关闭瞬间如果有线程正在acquire连接,而这个连接池已经准备销毁,两方可能产生僵持。
这些资源有一个共同特征:它们都不是Spring容器直接管理的,或者虽然是Spring管理的,但缺少“关闭回调”。所以要靠SpringApplicationShutdownHook加上你自己的优雅关闭配置,再配合每个组件的显式清理代码,才能保证进程退得干净。我不会把这一环完全寄托给框架,因为框架并不知道你手动new了什么线程。
4. 优雅关闭:Spring Boot 2.3带来的“最后体面”
4.1 没有优雅关闭之前是什么感受
在Spring Boot 2.3之前,想实现优雅关闭基本要靠自己写。当时的痛苦在于:直接kill -15虽然会让Tomcat进入关闭流程,但Tomcat默认的shutdown行为是立刻拒绝新请求,同时正在处理中的请求也可能被粗暴打断,因为底层socket关闭逻辑并没有等待线程池里正在跑的请求任务完成。
你可以想象这样一个画面:一个用户正在下单,后端已经执行到数据库写入的前一步,运维恰好发布新版本给进程发了SIGTERM,这个请求直接被中断,数据库事务要么回滚要么悬挂。用户体验就是“订单提交失败,请重试”,而且每次发版都集中出现这种报错。所以当时的社区方案五花八门:有人自己写Filter统计活跃请求数,在关闭信号里sleep一段时间;有人通过Tomcat的Connector调整maxKeepAliveRequests让连接尽快结束;还有人干脆在负载均衡层面对后端做摘流,等待几秒再kill。这些方案本质上都是手动实现优雅关闭,精度和复杂度参差不齐。
4.2 两个配置项就搞定
从Spring Boot 2.3开始,官方终于把优雅关闭内置了。你需要配置的就两行:
server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30sserver.shutdown=graceful的意思是让Web服务器进入优雅关闭模式。对Tomcat来说,调用shutdownGracefully()之后,Connector停止接收新连接,但已经接收的请求允许继续处理。spring.lifecycle.timeout-per-shutdown-phase则指定每个关闭阶段的最大等待时间,这里的阶段包括SmartLifecycle的stop、Bean销毁等。如果我们不配置,默认就是无限等待,那是有风险的,我强烈建议所有生产环境都配上具体的超时时间。
再补充一句,这个graceful配置只对Web服务器(Tomcat、Jetty、Undertow、Reactor Netty)生效,它管的是HTTP请求层级。业务层的优雅关闭(比如停止定时调度、先关掉MQ消费者)还是靠前面说的SmartLifecycle和Bean销毁回调来自行实现。框架只能帮你把门面关好,里面的收银台还得自己清。
4.3 底层到底怎么等的
以Tomcat为例,server.shutdown=graceful生效后,Spring Boot会向Tomcat注册一个GracefulShutdown钩子,然后在ProtocolHandler上调用pause(),连接器对外表现为不再accept新的socket连接,但已经accept的socket继续处理。同时Tomcat内部会定期轮询是否所有活动请求处理完毕,直到超时或者全部完成,再真正关闭连接器。
这里有一个细节容易让初次上手的人懵:在优雅关闭等待期间,应用对外可能仍然显示活跃,如果负载均衡的探针没有把这个实例摘除,K8s或者Nginx还是会把新请求转发进来。这会导致“关闭等了30秒,新请求还一直被送进来”的尴尬。正确的做法是让就绪探针在应用进入关闭流程后就立即返回失败,或者再配合preStop里的短暂等待,先把流量摘干净,再让优雅关闭收尾。这个场景我放到第5节用实际案例讲。
5. 生产环境关闭复盘:订单、定时任务、K8s三个战场
5.1 多商户商城:关闭瞬间的订单一致性
既然经常有人搜Spring Boot + MyBatis的多商户跨境商城源码,我就顺着它说一个非常现实的关闭场景:多商户商城在发版关闭的一瞬间,正好有买家在提交订单。商户商城和普通单机应用的不同在于它涉及支付回调、库存扣减、物流单生成等多步操作,而且这些操作往往跨多个数据源。如果进程在订单流程进行到一半时被杀,数据库层面的外键和事务约束能挡住一部分问题,但像支付回调这种异步场景,Redis里缓存了支付状态,数据库里订单状态还没更新完,重启后新进程读到的就是中间状态。
针对这个场景,我的做法是把处理支付回调的消费者实现成SmartLifecycle,phase设得大一些,也就是让它先于其他业务组件关闭。这样停机时,支付回调消费者先停止拉取新消息,已拉取的消息暂时不处理或者入本地队列;同时用分布式锁保证每个订单在同一时刻只有一个节点在处理。这套思路不能百分之百杜绝关闭瞬间的问题,但它能把影响范围从“所有订单”缩小到“正在处理的那几个单子”,配合消息队列的重试机制,基本能在发布后自动恢复。
5.2 推荐系统:定时任务没跑完怎么办
另一个典型场景是后台定时任务,比如基于Spring Boot的大学生就业推荐系统里常见的推荐计算任务。这类系统经常会用@Scheduled写一个每天凌晨跑全量推荐的任务,也可能有每10分钟跑一次的增量任务。当应用收到关闭信号,Spring的ScheduledTaskRegistrar会跟着容器销毁,但这里有个前提:任务调度线程正在执行的那个任务,Spring默认是等它跑完的。如果这个任务本身要跑20分钟,你的优雅关闭等待只给了30秒,那就会一直等到超时,任务执行一半的结果可能被写坏。
处理这类问题,我觉得思想比配置本身更关键。第一,给定时任务加一个“停止请求”的标记位,任务循环体里每处理一批数据就检查一次标记,发现要停了就安全退出并记录游标位置。第二,不要用@Scheduled直接跑超长任务,而是把任务拆成很多小片,由调度器只负责触发,处理进度存到数据库或者Redis。这样当关闭事件来临时,任务能被很快打断,进度还能在下次启动时继续。这种方案在我做过的推荐系统项目里实测下来,关闭时间从几分钟降到10秒以内。
5.3 K8s滚动发布:把关闭编排进容器的生命周期
把场景切到K8s,这才是现代Spring Boot应用最标准的关闭战场。K8s在终止一个Pod时,会先执行preStop钩子(如果有的话),然后向主进程发送SIGTERM信号,接着进入terminationGracePeriodSeconds设定的宽限期(默认30秒),宽限期结束后如果进程还没退出,K8s会发送SIGKILL强行杀掉。这条流程本身很清晰,但很多人配置不当,掉进几个大坑。
第一个坑是preStop里sleep的时间过长,白白消耗宽限期。很多老教程说sleep 20秒是为了等负载均衡摘流量,但如果你用了就绪探针且探针轮询间隔很短,preStop里根本不需要sleep那么久。第二个坑是优雅关闭的等待时间大于terminationGracePeriodSeconds。比如配置spring.lifecycle.timeout-per-shutdown-phase=60s,而K8s宽限期只有30秒,那么进程一定是在优雅关闭还没跑完时就被SIGKILL了,优雅关闭等于白配。我建议优雅关闭总等待时间要小于宽限期的三分之二,留一些余量给JVM自身。第三个坑是就绪探针没有在关闭开始时fail。如果探针还是一直返回200,滚动发布时新Pod起来、旧Pod一边优雅关闭一边还在接收新请求,两边同时处理流量,结果就是关闭期间频繁报错。
下面是一个我常用的最小配置(K8s Deployment YAML里的相关片段):
spec: terminationGracePeriodSeconds: 45 containers: - name: app readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 10 periodSeconds: 5 lifecycle: preStop: exec: command: ["sh", "-c", "sleep 5"]对应的application.yml里,优雅关闭等待时间我一般配30到35秒,整体算下来:preStop的5秒加上优雅关闭的30秒等于35秒,小于45秒的宽限期,还剩10秒给JVM完成最后的退出动作。这套组合实测下来,滚动发布几乎没有因为关闭造成的请求失败。如果你不想依赖Spring Boot Actuator的readiness组,也可以让preStop里跑一个脚本把健康检查状态标记成DOWN,或者直接从Service的Endpoints里退出,但Actuator的/actuator/health/readiness是Spring Boot原生支持,我优先用它。
6. 关闭卡住的排查思路:线程栈是最好的照妖镜
6.1 先拉线程栈,再看WAITING的线程
不管配置多完善,总有那么几次关闭还是卡住。这时候别去猜,直接拉线程栈。线上用jstack,本地用VisualVM或者JMC。命令很朴素:
jstack <pid> > thread-dump-$(date +%Y%m%d%H%M).txt拿到线程栈之后,先找那些不是daemon、而且状态是WAITING或BLOCKED的线程。daemon线程不阻碍JVM退出,所以只有当非daemon线程还在活跃时,进程才退不了。搜关键字Waiting和parking,再往上看栈顶的类名,基本就能锁定是谁还赖着不走。有一次我在一个Spring Boot应用关闭后等了2分钟没退,jstack一看,一个名为pool-3-thread-1的线程parking在LinkedBlockingQueue.take()上,顺藤摸瓜找到是某个Bean里new出来的固定线程池在阻塞队列里等任务,而那个Bean压根没有在关闭时shutdown这个线程池。改成由Spring管理的线程池并加destroy方法后,关闭时间瞬间变成1秒以内。
6.2 常见的赖着不走的线程清单
结合几年下来积累的个案,关闭卡住的元凶基本逃不出下面这些:
- Executors.newFixedThreadPool()手动创建的线程池,核心线程空闲也不回收,必须显式shutdown。
- WebSocket或SSE(Server-Sent Events)长连接,Tomcat在优雅关闭模式下会等它们结束,如果客户端不主动断开,又没有设置容器层的maxKeepAliveRequests或idleTimeout,就会一直等。
- 数据库连接池的获取连接操作,acquire卡在等待队列上,常见于配置了较大minimumIdle但连接已经被数据库端回收,线程在等待连接池创建新连接。
- 分布式锁或Redis操作卡在超时重试上,尤其使用Lettuce连接池又没有配置合理超时时间时,关闭阶段可能还在重试。
- 自定义的Netty客户端或服务端线程组,EventLoop是非daemon线程,没有在关闭时执行shutdownGracefully()。
每一次排查都是先看栈,栈上看不出来的辅以jcmd或者Arthas的thread命令,基本都能在10分钟内定位。
6.3 我的习惯性预防手段
这些坑踩多了之后,我养成了几个关闭相关的习惯,这里直接分享出来。所有线程池都委托给Spring管理,优先用ThreadPoolTaskExecutor或者ThreadPoolTaskScheduler,这样容器销毁时能自动执行shutdown;如果非要自己new,那就在@PreDestroy里shutdown(),并且调用awaitTermination等待一段时间,代码大概是这样的:
@PreDestroy public void shutdown() { executor.shutdown(); try { executor.awaitTermination(10, TimeUnit.SECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }长连接和长轮询请求,服务端一定要配超时时间,Tomcat的connectionTimeout、maxKeepAliveRequests,以及Spring MVC的async request timeout都算上。最后是关闭日志,我给Spring Boot应用的shutdown hook里加了一个简单的追打日志,记录当前存活的非daemon线程数量和主要线程名,发布后运维可以直接查日志确认这次关闭是不是干净。
这些预防手段不需要多高深的技术,但每一条都是真实事故换来的。我不会说做到这些就万无一失,因为每次生产环境和业务形态一变,总会有新的资源需要纳入管理。但有一个通用的判断标准:如果关闭一个Spring Boot应用时,日志在Closing之后的几行内就停了,说明该进程的线程已经全部退场;如果日志停住不动,那就按上面这套方法去jstack。
我自己的体会是,Spring Boot的关闭机制就像一个平时不响、一响就是大事的警报器。把它的调用链、资源顺序、超时配置和排查手段弄清楚,无论是日常开发还是线上排障,都能少一些“进程明明停了但端口还在”的尴尬时刻。建议你也找一个不忙的时间,复盘一次自己项目里的关闭日志,跑一次jstack看看关闭时都有哪些线程还在活动。这个动作花不了10分钟,但对应用的理解能上一个台阶。