1. 关闭流程的底层机制
1.1 从 kill 命令到 Spring 容器销毁的完整链路
很多同学对应用关闭的理解停留在“进程死了就完事”的层面,但真实情况远比这复杂。以最常见的kill -9和kill(默认 SIGTERM)为例,这两者在 Spring Boot 应用身上引发的连锁反应完全不同。
先说kill -9。这个信号 JVM 无法拦截,进程会直接终止,Spring 容器的销毁方法、Bean 的@PreDestroy、DisposableBean 的 destroy 方法统统不会执行。如果你在生产环境用过这招,大概率遇见过日志里的“未完成请求”“数据库连接泄露”这类诡异问题。原因很简单:连接池里的连接还没来得及归还,下一批请求就被新实例接管,而旧进程的 TCP 连接直接断了。
再说正常的kill(即 SIGTERM)。JVM 收到后,会先触发已注册的 shutdown hook(关闭钩子)。Spring Boot 启动时,SpringApplication内部会通过Runtime.addShutdownHook注册一个钩子,这个钩子的核心逻辑是调用SpringApplication.doClose,最终把ConfigurableApplicationContext整个容器关掉。而这个关容器动作,具体包含几件大事:触发ContextClosedEvent、调用各 Bean 的@PreDestroy回调、销毁DisposableBean、清理单例池、关闭底层 Web 容器。
这个过程听起来顺理成章,但里面有个关键时序问题:Spring Boot 在不同版本里,对 Web 容器的关闭是放在容器自身停止逻辑中的,而不是简单线性执行。比如 Tomcat 的关闭逻辑里又分“先停止接收新请求”和“再缓慢处理已经接收的请求”两段。如果这两段之间协调不好,就会出现“应用还活着但新请求已经进不来、旧请求却卡住”的场景。
1.2 Shutdown Hook 与 ContextClosedEvent 的触发顺序
在实际排查中,很多人搞不清楚@PreDestroy、SmartLifecycle.stop、监听器里的ContextClosedEvent三者到底谁先谁后。这直接关系到你在关闭时做清理工作的代码写在哪儿才靠谱。
我把顺序讲透:ContextClosedEvent的发布发生在容器关闭的最早期,准确说是AbstractApplicationContext.doClose方法首先publishEvent(new ContextClosedEvent(this))。而@PreDestroy的触发时间在后续的destroyBeans阶段。SmartLifecycle.stop则更早——容器关闭流程里,第一步就是调lifecycleProcessor.onClose(),这个阶段会执行所有 SmartLifecycle Bean 的stop回调。
所以如果你在做异步任务队列清理,想“在容器关掉之前把剩余任务处理完”,用SmartLifecycle接口的stop方法配合phase数值来控制先后顺序,比单纯监听ContextClosedEvent更可靠。因为ContextClosedEvent触发时,Web 容器可能已经开始拒绝新请求了。而SmartLifecycle.stop里你还可以做阻塞等待,只要设置合适的phase,可以确保它比 Web 容器的 stop 更早执行。
注意:
phase值越小,越先启动,但停止的顺序相反——phase值越大,越先停止。别记反了。
2. 优雅关闭的实现方案选择
2.1 Spring Boot 2.3+ 原生优雅关闭配置
从 Spring Boot 2.3 开始,官方终于把“优雅关闭”做成了内置能力,不再需要额外引包。配置方式非常简洁:
server: shutdown: graceful配合这个配置的还有超时时间,默认是 30 秒:
spring: lifecycle: timeout-per-shutdown-phase: 30s这里有个容易踩的坑:很多人以为server.shutdown=graceful配完就万事大吉,但其实它只在处理“新请求不再接收、旧请求等待完成”这一层起作用。对于 Tomcat 而言,意味着 Connector 会先停止接受新连接,但已经 keep-alive 的连接上发来的新请求,Tomcat 内部会判断如果还在 graceful 窗口内,仍然会处理掉,之后才逐步关闭。
timeout-per-shutdown-phase这个参数很有意思,它控制的是每个关闭阶段的最大等待时间,而不是整体时间。关闭流程被划分成多个 phase,每个 phase 超时后继续进下一个 phase。实际项目中,我建议把超时配置成你线上最长请求耗时的 1.5 到 2 倍。如果业务 SLA 要求在 10 秒内返回,那我一般配 15 秒左右。不要一味贪大,超时太长,发布时流量切走,旧实例迟迟不退,会占用资源;太短又可能出现大量“处理了一半”的请求被杀掉。
2.2 自定义 GracefulShutdown 实现方案
如果你的项目还在用 Spring Boot 2.2 或更早版本,那就只能自己实现 Tomcat 的优雅关闭了。网上流传的TomcatGracefulShutdown方案核心代码大致如下:
@Component public class TomcatGracefulShutdown implements SmartLifecycle { @Autowired private WebServerStartStopLifecycle lifecycle; @Override public void stop(Runnable callback) { // 获取Tomcat的Connector,停止接收新请求 // 等待活跃请求数归零 } }这个方案的实际效果取决于你对 Tomcat 内部类Tomcat、Connector的访问方式。在 Spring Boot 2.x 不同小版本里,TomcatWebServer的内部结构有些微变化,如果你直接强转或者反射取内部字段,升级一个小版本就可能编译报错。
更稳的做法是直接用WebServerFactoryCustomizer定制Tomcat的ProtocolHandler。Tomcat 8.5 之后AbstractProtocol里有个pause方法,调用它可以让 Connector 不再接收新连接,注意是“不再接收新连接”,但已经建立的 keep-alive 长连接上,Tomcat 仍然会处理传入的新请求,直到连接被客户端关闭或超时。
所以,一个完整的手写优雅关闭方案至少要做三件事:
- 调用 Connector 的
pause,暂停接收新连接。 - 等待或限制活跃请求完成。
- 再执行容器真正的 stop。
这也是为什么 Spring Boot 官方在 2.3 里直接内置优雅关闭后,我强烈建议你升级版本,而不是自己维护这套逻辑。因为内部细节太多,稍不注意就有请求漏掉或卡死。
2.3 结合 Actuator 实现远程关闭
Spring Boot Actuator 里有一个shutdown端点,默认是关闭的。当你需要从运维平台远程触发应用关闭时,开启它很实用:
management: endpoint: shutdown: enabled: true endpoints: web: exposure: include: health,info,shutdown但我要泼一盆冷水:如果直接在生产环境暴露这个端点,风险极大。任何人只要知道路径,就能 POST 一个请求把你的服务关掉。我一般建议通过 Spring Boot Admin 集成管理端,或者配合网关做内部网络隔离,并且在开启后把端口绑定到内网 IP,而不是对外暴露。还要做鉴权,哪怕是 Spring Security 的简单 Basic Auth 也好过裸奔。
使用姿势上,有一点要注意:shutdown端点的操作是异步的,发起 POST 请求后,HTTP 连接在进程退出前可能无法正常返回响应。所以运维脚本里不能等这个接口返回“success”再继续,否则会卡到超时。
3. 关闭过程中的资源清理与数据一致性
3.1 数据库连接池与线程池的销毁顺序
先问一个问题:HikariDataSource.close()其实发生在什么阶段?如果你在@PreDestroy方法里有事务提交后同步数据的需求,而连接池已经先关闭了,那代码里拿不到连接,事务提交必然报错。
Spring 容器对 Bean 的销毁顺序遵循一个基本规则:@PreDestroy回调的执行顺序和 Bean 的依赖关系、depends-on声明、order属性相关。HikariDataSource 通常被自动配置为一个单例 Bean,它在容器里的销毁时序相对靠后,但如果你自定义的 Bean 在@PreDestroy里直接向数据库写数据,依然可能遇到“连接池已关闭”的并发问题。
我踩过的坑是这样的:项目里有一个缓存同步任务,在@PreDestroy里把内存中最新数据刷到 MySQL。本地测试一切正常,但容器关闭时报了HikariDataSource is closed。排查发现,close()逻辑里虽然 Hikari 池主线程会先停止分配连接,但由于容器是多线程并发销毁 Bean,我的同步 Bean 和 HikariDataSource 的销毁同时进行,导致拿到已关闭的连接。
解决方案是在自己的清理 Bean 上使用@DependsOn("hikariDataSource"),强制保证连接池在这个 Bean 之前存活。这是个很冷门但极其有效的技巧。
3.2 异步任务与消息队列的补偿策略
优雅关闭最怕什么?不是新请求进不来,而是“已经拿到的消息还没处理完”。比如 Kafka 消费者线程正在处理一条消息,此时进程退出,消息虽然已经拉取到内存,但因为 offset 还没提交,重启后会重新消费,造成重复。更麻烦的是如果处理逻辑里写了一半数据库,重启后重复执行,可能出现脏数据或幂等性破功。
针对这种情况,我一般用三层策略:
第一层:在SmartLifecycle.stop()里给固定的缓冲时间,比如 10 秒,让正在执行的消费者线程处理完手头这一条消息。这个时间要足够长,但又不能太长,否则发布窗口期被拉长。
第二层:手动暂停消息监听容器。如果你的消费者用的是@KafkaListener+ConcurrentKafkaListenerContainerFactory,关闭前先调用KafkaMessageListenerContainer.stop(),它能确保已拉取的消息在pause后被消费完。注意,stop()默认也是优雅的,它会等待当前在途记录处理完再返回。
第三层:兜底补偿。在数据库层面做幂等键,或者在消息体里带上业务幂等 ID。这样即使重启后重复消费,最多是多执行一次,而不是产生脏数据。
3.3 缓存组件与外部资源释放
Redis、Redis Cluster、Elasticsearch 客户端这些组件的清理,在实际关闭过程中容易被忽略。以 Spring Data Redis 为例,默认的JedisConnectionFactory或LettuceConnectionFactory在容器关闭时会释放底层连接资源。但如果你在代码里手动创建了RedisTemplate的线程池(比如某些RedisExecutor),这部分资源不会被容器自动感知,需要你自己在@PreDestroy里调用关闭方法。
这块我见过的低级错误是:工程师在 Spring 容器里手动创建了一个Executors.newFixedThreadPool用来并发写 Redis,但从没关闭过。应用每次发布后,就有一个线程池的资源没释放。虽然 JVM 退出后这些线程自然没了,但在同一个 JVM 里跑多个 Spring 应用(比如通过 Spring Cloud 的进程内多容器模式)时,线程泄漏会逐步吃光内存。
经验之谈:容器关闭日志里如果出现
jvm 1 | Exception in thread "pool-8-thread-1" java.lang.InterruptedException,多半是某个自定义线程池在关闭时没有正确处理中断,优先检查自定义线程池的 shutdown 与 awaitTermination。
4. 生产环境关闭排查与监控要点
4.1 如何从关闭日志中判断是否优雅
应用关闭是否成功、是否优雅,不是看进程退没退。我会先看日志里有没有这几类关键信息:
- “Commencing graceful shutdown. Waiting for active requests to complete” —— Spring Boot 2.3+ 开始优雅关闭时会打印这条。
- “Tomcat started on port(s)” 之后的
Shutdown相关日志,能看到Destroying ProtocolHandler或Pausing ProtocolHandler。 - “Closing JPA EntityManagerFactory” / “Closing HikariCP” 这类资源关闭日志。
如果看到的是直接进程消失、没有任何日志输出,那基本可以断定是 SIGKILL 或System.exit强行终止。后者其实也走关闭钩子,所以有日志;前者则没有。
另外,关闭日志里如果出现大量InterruptedException或者RejectedExecutionException,说明你在优雅关闭期间还给工作线程派了新任务,或者线程被强制中断。这属于关闭逻辑的设计问题,不是是非对错,但通常说明需要重新编排关闭阶段。
4.2 线程 Dump 定位关闭卡死问题
生产环境遇到最多、也最难查的问题是关闭特别慢,甚至十几分钟都退不出去。这种状况极大概率是有线程在优雅关闭阶段阻塞住了。排查方法很简单:在关闭期间连续抓多次线程 Dump,对比关闭前后线程状态变化,找出始终停留在WAITING或BLOCKED状态的线程。
具体操作可以用jstack直接抓:
jstack <pid> > thread_dump_1.txt sleep 5 jstack <pid> > thread_dump_2.txt重点看两个地方:
Tomcat的 worker 线程是否卡在第三方接口调用上,比如某个 HTTP 调用没有设置超时时间,外部服务又不响应,导致线程一直在等。- 自定义
Excutor线程池中awaitTermination是否在等待一个永远不会结束的任务,比如一个循环里没有判断中断标志位。
针对这类问题,我的修复经验是在所有外部调用的规范里强制加连接超时和读取超时。很多团队只在开发阶段测试正常,到关闭时就卡死,就是因为在关闭窗口期,外部依赖服务的超时时间默认 30 秒,而你的优雅关闭超时只有 10 秒,线程来不及结束就被容器等待,最后进程迟迟退不掉。
4.3 关闭阶段的多实例协调与流量摘除
单实例优雅关闭只是基础。真正生产环境里,服务基本都是多实例部署,这时候“关闭一个实例”就涉及流量摘除和负载均衡器协调。以 Nginx 上游和 Spring Cloud Gateway 为例。
如果你用的是 Nginx,我一般建议发布前先从 upstream 节点列表里做down操作,等 Nginx 把流量切走后再发 SIGTERM。Spring Cloud Gateway 则可以利用DiscoveryClient主动将自身注册状态改为OUT_OF_SERVICE。Spring Boot 2.3 之后,graceful关闭虽然会让 Web 容器不再接受新请求,但前提是负载均衡器已经把流量调度走,否则在关闭窗口内,负载均衡器仍然可能把新请求打过来,造成短暂 5xx。
这里有个很实用的组合:在优雅关闭前先调actuator/health探针让自己处于 DOWN 状态。Kubernetes 的readinessProbe和 Spring Boot 自带的health端点天然搭配。先让探针失败,再等几个探针周期,确保所有流量的路由表更新完毕,最后再关闭 JVM。
实战心得:在 K8s 环境里,不要只依赖
preStop钩子做优雅关闭。preStop是阻塞执行的,K8s 在 preStop 完成后才发 SIGTERM。如果你在 preStop 里做长耗时操作,会延长整个 Pod 终止时间,而且 K8s 的 terminationGracePeriodSeconds 一到直接 SIGKILL,你的优雅关闭根本没机会执行完。
4.4 关闭超时参数与 K8s 生命周期对齐
容器化部署是当下主流,参数对齐这事踩过的坑太多了。Kubernetes 默认的terminationGracePeriodSeconds是 30 秒,如果你在 Spring Boot 里配的spring.lifecycle.timeout-per-shutdown-phase是 45 秒,那么即使应用还在优雅关闭,Pod 到 30 秒也会被强制杀掉。
发布前必须把两边的超时时间对齐好的。我给一个典型配置样例:
# application.yml spring: lifecycle: timeout-per-shutdown-phase: 25s server: shutdown: graceful# K8s deployment 部分 terminationGracePeriodSeconds: 30再配合探针:
readinessProbe: httpGet: path: /actuator/health port: 8080 initialDelaySeconds: 10 periodSeconds: 10 failureThreshold: 3这样调配的逻辑是:探针在 3 个周期内失败,通常意味着 30 秒左右已经把该实例从 Service 的端点列表里摘除,之后 K8s 发 SIGTERM,Spring Boot 开始优雅关闭。总体 25 秒的关闭超时覆盖请求处理,30 秒的 K8s 宽限期兜底,两边都有余量,不会出现一端先杀进程的尴尬。
5. 常见关闭异常案例速查
这里把我在不同项目里遇到并处理过的高频异常整理成一张表,方便你对照排查。
| 现象 | 可能原因 | 处理策略 |
|---|---|---|
| 关闭日志出现 “waiting for N active requests to complete” 后迟迟不退 | 有请求一直未结束,超出timeout-per-shutdown-phase | 查看线程 Dump,重点排查外部调用超时设置;加大 graceful 超时时间 |
@PreDestroy 中写数据库报HikariDataSource is closed | Bean 销毁顺序不对,连接池先于业务 Bean 关闭 | 给业务 Bean 加@DependsOn("hikariDataSource") |
| Kafka 消费者重启后重复消费消息 | 优雅关闭未处理在途消息,offset 未提交 | 在SmartLifecycle.stop里先暂停并等待消息消费完成 |
| 关闭后端口还在监听,进程无法退出 | 非 Spring 管理的原生线程池或外部连接未释放 | 排查自定义线程池、Netty 组、ZooKeeper 客户端等,显式调用 close |
kill -9后重启出现连接被拒的雪崩 | 旧实例未摘除即被杀,流量还没切走 | 调整负载均衡健康检查时间,协调流量摘除 |
| shutdown 端点 POST 请求响应超时 | 异步关闭导致响应无法在进程退出前返回 | 运维脚本不等待响应,改为轮询端口的探活方式判断进程退出 |
5.1 超过关闭等待时间却没有报错
有一种特别容易忽视的情况:Spring Boot 的优雅关闭超时到了以后,Tomcat 并不会把还在执行的请求强制中断,而是继续等待余下的请求。也就是说超时后只是“不再保证优雅”,进程可能继续等那些永远不结束的请求。所以日志里哪怕没有报超时错误,你的应用也可能僵死在关闭流程里。
这类问题我碰上过一次,排查到最后是一个老项目里的 RestTemplate 没设置连接池超时,对端服务挂了一个端口,TCP 连接一直挂着。优雅关闭时 Tomcat 的两个 worker 线程卡在ResponseBodyHandler的读取上。解决办法是给 RestTemplate 添加connectTimeout和readTimeout:
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(); factory.setConnectTimeout(3000); factory.setReadTimeout(5000);同时在超时策略上关注两个参数的区别。连接超时管的是建立连接,读取超时管的是等待响应。对于依赖下游很重的服务,读取超时尤其重要,否则你的线程会一直傻等。
5.2 Nacos / Consul 注册中心在关闭时的反注册
服务注册中心的节点摘除也是一个很容易被忽略的细节。Spring Cloud 项目中,服务关闭时如果没来得及主动反注册,网关或调用方会持续把流量路由到已挂实例上,直到下次心跳失联被注册中心剔除。这个窗口期通常有 15 秒到 60 秒不等,期间请求会间歇性失败。
Spring Cloud 本身在 WebServer 停止后会自动执行反注册逻辑。但这里有个隐藏顺序问题:如果server.shutdown=graceful的等待时间很长(比如 30 秒),而注册中心的心跳剔除逻辑认为你的节点失联需要 60 秒,那么在这个 30 秒内,注册中心里的节点状态依然是“在线”,流量会源源不断进来。这就会造成一个奇怪的现象:应用已经进入关闭流程,但负载均衡器还在往它发送新请求,而优雅关闭期间 Tomcat 已经不再接受新连接,于是新请求直接被拒绝。
解决思路是在流量摘除阶段就把实例标记为不健康。让/actuator/health返回 DOWN,注册中心探活失败后会主动将节点剔除。也就是我之前说的,把健康检查、探针、优雅关闭三件事联动起来,比单纯依赖哪个组件的默认行为可靠得多。
6. 实际操作中的心得体会
说实话,优雅关闭这件事,在业务开发阶段几乎没人会花时间考虑。我第一次碰到还是因为一次半夜发布事故:上线脚本直接kill -9,第二天一早收到监控报警,数据库连接数暴涨,消息队列积压了几十万条。当时怎么也想不通,代码里明明每一步都盯着,怎么会出这种问题。后来仔细看了生产日志才知道,kill -9不仅让 Spring 容器来不及关,连 TCP 连接都直接断了,数据库服务端的连接还没感知,要等 tcp keepalive 超时才会回收,期间连接池越积越多。
那次之后,我给自己定了一条生产发布铁律:任何环境不允许用kill -9,默认一律kill,并且用脚本校验进程退出时间超过阈值就报警。对于 K8s 环境,不允许改terminationGracePeriodSeconds默认值而忽略 Spring Boot 侧的超时配置。如果你的发布平台只给一个“停止服务”按钮,那么请务必确认这个按钮背后是 SIGTERM 而不是 SIGKILL,很多云厂商的“强制停止”选项就是直接 SIGKILL,这个选项在生产环境必须禁用。
另外,我还特别想提一点:Spring Boot 的优雅关闭参数并不复杂,但把它和负载均衡摘流、注册中心摘除、消息队列暂停、连接池关闭、自定义线程池回收这几个阶段串联起来,才是一个完整的关闭方案。单纯配一个server.shutdown=graceful,只能保证 Web 容器这层优雅,背后的线程池、消息消费者、外部依赖清理仍需要你针对业务去定制。
最后分享一个我常用的关闭阶段编排思路:按phase大小规划,phase最大的最先停止,最先停止的是消息消费者,这样在途消息有足够时间消化;然后是业务线程池,确保任务能提交到队列但不再新增;再是 Web 容器(这个由 Spring Boot 控制);最后是连接池和外部客户端。每个阶段之间的等待时间,至少留 3 秒余量,防止依赖组件的内部异步回调还没结束就进入下一阶段。
这套思路在多个项目里验证下来,发布过程基本能做到零异常零告警。等你照着这个思路把自己的应用关闭流程捋过一遍,你会发现之前随手用的kill -9有多么“暴力”。