
有一次我在VSCode里跑一个Spring Boot MyBatis的多商户商城Demo临时想换个端口号改了application.yml准备把8080改成8081再重启。结果前一个进程还没退出8080端口一直处于占用状态启动直接报错。我当时第一反应是kill -9可杀完之后再启动居然还是提示端口被占。折腾了一圈才发现旧进程的子线程没清理干净TCP连接还在TIME_WAIT状态。就是那次经历让我意识到很多人对Spring Boot应用关闭的理解其实停留在“点了停止按钮、进程窗口消失”这个层面。这篇东西想把我这些年围绕Spring Boot应用关闭踩过的坑、读过的源码、在生产和容器环境里实测过的方案完整梳理一遍。适合正在用Spring Boot做日常开发、或者正准备把它部署到生产环境尤其是K8s环境的工程师看。你说它是底层原理也好说它是排障手册也行总之先把关闭这扇门彻底打开把里面的齿轮一个一个看清楚。1. 关闭的几种姿势从CtrlC到kill -9差别有多大1.1 开发态IDE停止按钮、CtrlC与VSCode终端开发环境下关闭Spring Boot应用的方式五花八门。最常见的是在IDEA里点那个红色方块或者在VSCode的Spring Boot Dashboard里点Stop再或者在终端里直接按CtrlC。这三种方式本质都是向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 -9SIGKILL是操作系统级别的强制终止JVM不会执行任何关闭钩子也就是说Spring Boot的ApplicationContext根本没有机会做close。这不是关闭是猝死。正确做法是kill -15SIGTERM让JVM有机会把shutdown hook跑完。跑完意味着Tomcat连接器能先停止接收新请求再逐步释放端口意味着每个注册到容器里的bean能有机会执行PreDestroy或者destroy()方法意味着数据库连接池、线程池这类资源能按顺序收尾。除了信号Spring Boot还提供了一个软关闭入口Management端点里的shutdown。这个端点默认是禁用的需要显式开启management.endpoint.shutdown.enabledtrue而且只支持POST。它的实现原理就是调用SpringApplication.exit(context)走一遍应用上下文的标准关闭流程。这种方式适合在内部运维平台上做“优雅关机”但要注意必须加权限保护否则任何人都可以POST一下把你的服务关停。我一般不建议对外暴露内网运维平台调用倒是挺合适。1.3 一张表看懂各种关闭方式关闭方式触发信号/机制是否执行shutdown hook适用场景风险等级IDEA/VSCode停止按钮调用destroy()或发送SIGINT/SIGTERM是本地开发低终端CtrlCSIGINT是本地开发低kill -15 PIDSIGTERM是生产、容器低kill -9 PIDSIGKILL否紧急强制结束高Actuator shutdownSpringApplication.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自己注册一个shutdownHookAbstractApplicationContext.registerShutdownHook()Spring Boot 2.3改成了由SpringApplication统一注册SpringApplicationShutdownHook这样做的目的是区分“JVM关闭”和“调用close()关闭”两种场景。这个细节到后面讲优雅关闭时会体现出来先有个印象。2.2 doClose()执行的三段式清理当关闭信号被钩子捕获后核心动作是调用ConfigurableApplicationContext.close()。以咱们最常见的AnnotationConfigServletWebServerApplicationContext为例它继承自AbstractApplicationContextclose()方法最终落到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 hookSpringApplication又额外处理一遍有时会出现重复关闭的日志或者奇怪的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大的组件是基础性的、被依赖的底层组件要先把依赖它的上层组件停掉最后关掉底层。举个例子如果有一个数据同步管道上层是消息监听器phase100底层是连接工厂phase0那关闭时先停监听器再关连接工厂这样才能保证停机过程中不再有新消息进来触发底层调用。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.shutdowngraceful的意思是让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.shutdowngraceful生效后Spring Boot会向Tomcat注册一个GracefulShutdown钩子然后在ProtocolHandler上调用pause()连接器对外表现为不再accept新的socket连接但已经accept的socket继续处理。同时Tomcat内部会定期轮询是否所有活动请求处理完毕直到超时或者全部完成再真正关闭连接器。这里有一个细节容易让初次上手的人懵在优雅关闭等待期间应用对外可能仍然显示活跃如果负载均衡的探针没有把这个实例摘除K8s或者Nginx还是会把新请求转发进来。这会导致“关闭等了30秒新请求还一直被送进来”的尴尬。正确的做法是让就绪探针在应用进入关闭流程后就立即返回失败或者再配合preStop里的短暂等待先把流量摘干净再让优雅关闭收尾。这个场景我放到第5节用实际案例讲。5. 生产环境关闭复盘订单、定时任务、K8s三个战场5.1 多商户商城关闭瞬间的订单一致性既然经常有人搜Spring Boot MyBatis的多商户跨境商城源码我就顺着它说一个非常现实的关闭场景多商户商城在发版关闭的一瞬间正好有买家在提交订单。商户商城和普通单机应用的不同在于它涉及支付回调、库存扣减、物流单生成等多步操作而且这些操作往往跨多个数据源。如果进程在订单流程进行到一半时被杀数据库层面的外键和事务约束能挡住一部分问题但像支付回调这种异步场景Redis里缓存了支付状态数据库里订单状态还没更新完重启后新进程读到的就是中间状态。针对这个场景我的做法是把处理支付回调的消费者实现成SmartLifecyclephase设得大一些也就是让它先于其他业务组件关闭。这样停机时支付回调消费者先停止拉取新消息已拉取的消息暂时不处理或者入本地队列同时用分布式锁保证每个订单在同一时刻只有一个节点在处理。这套思路不能百分之百杜绝关闭瞬间的问题但它能把影响范围从“所有订单”缩小到“正在处理的那几个单子”配合消息队列的重试机制基本能在发布后自动恢复。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-phase60s而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或SSEServer-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分钟但对应用的理解能上一个台阶。