凌晨十二点盯着发布流水线,一条kill -15发下去,业务群里瞬间冒出好几条“接口报错了”“刚才提交的订单没返回”。这个场景是我对 SpringBoot 停机机制最初的记忆。默认情况下,SpringBoot 收到 SIGTERM 并不代表它会等手头的事干完,而是“几乎立刻”把 Web 容器和 Spring 容器拆掉,所有在途请求直接被 Reset。SpringBoot 优雅停机机制是解决这类问题的标准方案,它从 Spring Boot 2.3 开始由官方原生支持。这篇文章我会带你搞清楚:优雅停机到底在哪一层生效、两行配置背后发生了什么事、实测中请求表现差多少、以及真正上线时最容易忽略的注册中心和容器编排坑。适合正在做服务发布、弹性伸缩、压测验证的 Java 开发者收藏。
1. 从最惨的一次发布事故说起:默认停机到底停掉了什么
1.1 kill -15 并不温柔:JVM 钩子与 Web 容器的同步关闭
很多人有个误解:以为给 Java 进程发kill -15(SIGTERM)之后,JVM 会“把当前请求处理完再退出”。这个说法只说对了一半。JVM 确实会执行 ShutdownHook,Spring Boot 也注册了自己的SpringApplicationShutdownHook,但问题在于:默认停机模式下,Spring 容器关闭 WebServer 时并不会等待活跃请求。
你可以把那次事故的现场还原一下。Tomcat 收到 stop 指令后,Connector 直接进入关闭流程,还在执行中的 Servlet 线程会被打断,正在读取响应体的 Nginx 突然收到一个空的 FIN 包,客户端表现就是 Connection reset by peer。如果你在写订单接口,可能已经完成了数据库事务提交,但响应没送到客户端,调用方就会选择重试,于是重复下单、重复扣库存、对账不平全都来了。
1.2 粗暴关停的典型故障清单
这类问题在线上有非常固定的模式,我整理了一张表,基本覆盖了绝大部分事故场景:
| 故障现象 | 真实原因 | 涉及模块 |
|---|---|---|
| 客户端提示 Connection reset | Tomcat 在请求进行中直接关闭连接 | Web 容器 |
| 下游收到 502/504 | Nginx 在 Upstream 节点关闭瞬间转发请求失败 | 负载均衡 |
| 消息重复消费 | 消费到一半进程退出,offset 未提交 | Kafka/RocketMQ |
| 事务已提交但响应丢失 | 业务逻辑完成,响应未写回客户端 | Servlet 线程 |
| 定时任务重复执行 | 集群多实例同时停机,任务调度没做抢占 | 任务调度 |
| 注册中心出现异常实例 | 服务端进程已死但心跳还没过期 | Eureka/Nacos |
这些故障有个共同点:都不是代码逻辑错误,而是进程退出顺序问题。业务代码跑得好好的,纯粹因为停机姿势不对,把“正常完成的请求”变成“客户端感知到的失败请求”,这才是最亏的。
1.3 先分清三个概念:JVM ShutdownHook、Actuator Shutdown 与优雅停机
网上搜“SpringBoot 优雅停机”,经常看到两种做法混在一起,容易踩坑。
第一种是 JVM 层面的 ShutdownHook,代码里Runtime.getRuntime().addShutdownHook(new Thread(...)),它只保证“JVM 退出时我能执行一段代码”,但改变不了 Web 容器已经在关闭的事实。你用这个方式做“通知注册中心摘流”,因为顺序不对,往往还没来得及发请求,容器已经断了。
第二种是 Actuator 的/actuator/shutdown端点,需要手动打开并 POST 触发。它本质上也是触发ApplicationContext.close(),在 Spring Boot 2.3 之前,它是实现“相对优雅”停机的主要手段,因为你可以控制触发时机,不让它依赖操作系统信号。
第三种才是真正的“优雅停机(Graceful Shutdown)”,也就是从 2.3 开始官方内置的能力。它做的事情很明确:先停止接收新请求,然后等待所有在途请求处理完成(或超时),最后再关闭 WebServer 和 Spring 容器。后面我会从配置开始完整拆解。
2. 先别急着加配置:版本、容器与两行核心参数的前置判断
2.1 Spring Boot 版本是分水岭,低于 2.3 就没有原生方案
Spring Boot 2.3.0.RELEASE 的 Release Notes 明确写了:为内嵌 Web 服务器增加优雅停机支持。所以如果你项目还在 2.1、2.2,对不起,server.shutdown=graceful这个配置是无效的,日志里连个警告都不会有。
我见过有人把server.shutdown=graceful加到 2.2 项目上,发版之后以为万事大吉,结果压测一打就露馅。所以第一步永远是看pom.xml里的父版本:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent>如果你日常用的是 Spring Boot 3.x,那当然更没问题,这套机制一直延续下来了。如果是老项目暂时升不动版本,至少要做到:给负载均衡或注册中心留出摘流时间,再手动触发/actuator/shutdown,不要裸着发 SIGTERM。
2.2 核心配置只有两行,但第二行含义经常被读错
在application.properties(或 yaml)里加上:
server.shutdown=graceful spring.lifecycle.timeout-per-shutdown-phase=30s第一行很简单,把默认的immediate改成graceful,告诉 Spring Boot 走优雅停机流程。
第二行spring.lifecycle.timeout-per-shutdown-phase写的默认值是 30s。注意关键词是per-shutdown-phase,也就是“每个关闭阶段”的超时时间,不是“整个停机过程”的总超时。Spring 容器里不同 Lifecycle 组件按 phase 分批关闭,理论上有几个 phase,就可能累加几个 30s。这一点后面讲事件链的时候还会展开,先记住:不要把它理解成“最多等 30 秒就强制退出”。
2.3 官方支持矩阵:四个内嵌容器全都支持,但成熟度有差异
Spring Boot 官方文档对优雅停机的描述是:支持所有四个内嵌 Web 服务器(Tomcat、Jetty、Reactor Netty、Undertow),Servlet 和响应式应用都可以。
但做底层适配的人都知道,一个能力“支持”和“支持得好”是两回事。Tomcat 的实现最完整,从 9.0.33 开始提供了相对成熟的 Connector pause + 线程池等待机制;Jetty 和 Reactor Netty 也能做到“停止接收新请求,等待在途请求完成”;Undertow 也能用,但我在网上见过一些版本差异导致的边界问题,如果你不是必须用 Undertow,建议在生产环境优先用 Tomcat,省心。
| 容器 | 是否支持 | 实际表现 |
|---|---|---|
| Tomcat 9.0.33+ | 完整支持 | pause Connector,等待线程池任务完成,日志清晰 |
| Jetty 9.4+ | 支持 | 等待在途请求,但部分版本需要额外注意连接超时 |
| Reactor Netty | 支持 | WebFlux 场景可用,2.3 起支持,等待事件循环中请求 |
| Undertow | 支持 | 能用,但社区反馈的边界问题相对多 |
2.4 开启前检查下自己的环境
这里给一个快速自查清单,照着过一遍再上线:
- 项目
spring-boot-starter-web或spring-boot-starter-webflux版本是否 ≥ 2.3。 - 是否使用内嵌容器(如果打成 war 丢外置 Tomcat,这套配置不直接生效)。
- 确认停机时是
kill -15或docker stop、systemctl stop触发,而不是kill -9。 - 有没有在代码里自定义 ShutdownHook?如果自定义 Hook 里做了“等待 xx 秒”之类的逻辑,要测试它与 Spring 优雅停机的先后顺序,别让两段等待互相打架。
3. 停机时到底在等什么:SIGTERM 到容器关闭的完整事件链
3.1 事件链路拆解:SIGTERM → JVM Hook → Spring 容器关闭
优雅停机不是魔法,它只是在正确的位置插入了等待逻辑。
进程收到kill -15后,JVM 开始执行已注册的 ShutdownHook。Spring Boot 的SpringApplicationShutdownHook在这个时机被触发,它会调用ApplicationContext.close()。close()内部会先触发ContextClosedEvent,然后通过DefaultLifecycleProcessor去 stop 所有 Lifecycle 组件。
关键就在这里:Spring Boot 把内嵌 WebServer 的优雅停机封装成了一个SmartLifecycle,它会在 Spring 容器关闭时先执行。这个 Lifecycle 做的事是:
- 设置 Server 不再接收新请求(Tomcat 表现为停止 accept 新连接)。
- 等待活跃请求执行完成。
- 超过
timeout-per-shutdown-phase的请求不再等待,直接强制关闭。
等它结束后,WebServer 才真正销毁,Spring Bean 才开始挨个销毁。这条链路保证了“先等业务收完尾,再拆房子”。
3.2 三层等待:容器层、线程池层、业务资源层
实际等的东西可以分成三层看。
第一层是 Web 容器层。Tomcat 的 Connector 进入 paused 状态后,不再 accept 新连接,但已经建立连接上正在执行请求的线程不会被打断。
第二层是线程池层。Tomcat 内部维护了一个执行 Servlet 的工作线程池,优雅停机时它会等线程池里所有任务执行完。如果你的某个接口里定义了 10 秒Thread.sleep,它就是这里被等的重点对象。
第三层是业务资源层。Spring 容器真正 close 时,HikariCP 数据源、消息连接池这些 Bean 才开始逐个关闭。HikariCP 关闭时也会等待租出去的连接归还。这就是为什么“Web 容器等待”和“数据源等待”是两个不同的时间窗口,别只用接口耗时去预算停机总时长。
3.3 为什么日志里只见 “Commencing graceful shutdown” 却迟迟不结束
开启优雅停机后,收到 SIGTERM,控制台会输出类似这样的日志:
o.s.b.w.e.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete很多第一次接优雅停机的人看到这行日志,然后发现进程迟迟不退,会慌。其实这是正常现象。它正在等待在途请求。
等所有请求处理完,会看到:
o.s.b.w.e.tomcat.GracefulShutdown : Graceful shutdown complete之后 Spring 容器继续关闭剩下的 Bean。整个停机的标准日志顺序应该是:
Commencing graceful shutdown. Waiting for active requests to complete (这里停顿,时间取决于在途请求) Graceful shutdown complete Closing org.springframework.context.annotation.AnnotationConfigApplicationContext ...如果你始终看不到第二行日志,大概率是某个请求永远不返回(比如 WebSocket 连接、SSE 长连接、卡死的任务),最后只能等超时被强制切断。这也是生产环境最容易出问题的点:看似在“优雅等待”,实际在“无限等待”。
3.4 timeout-per-shutdown-phase 的真实累计逻辑
前面说了,这个参数是“每阶段超时”。Spring 的DefaultLifecycleProcessor会把所有 Lifecycle 分组,每个 phase 单独计时。如果你既用了 WebServer 的优雅停机,又自定义了几个 SmartLifecycle,还接了消息监听容器等等,那总停机时间可能超过 30s。
举个实际例子:phase 大的 Lifecycle 先关闭,如果它在 30s 内没结束,后面哪怕还有很多 Bean 没关,也会被强制打断继续往下走。所以设置这个参数时,最好把你业务里“最长的一个慢请求耗时 + 数据源连接归还时间 + 自定义清理时间”都估算进去。宁愿给 60s,也不要卡在 30s 边缘。
4. 用压测说话:优雅停机前后的请求行为对比
4.1 测试环境与压测准备
配置说再多,不如自己跑一轮压测直观。下面是我在本地搭的一组最小验证环境:Spring Boot 2.7 项目 + Tomcat,一个慢接口模拟“在处理中的业务”。
@RestController public class GracefulController { @GetMapping("/slow") public String slow() throws InterruptedException { System.out.println("请求进入:" + Thread.currentThread().getName() + " 时间:" + System.currentTimeMillis()); TimeUnit.SECONDS.sleep(10); System.out.println("请求完成:" + Thread.currentThread().getName() + " 时间:" + System.currentTimeMillis()); return "ok"; } }压测用 ab 就够了,不用上 JMeter:
ab -n 500 -c 50 http://127.0.0.1:8080/slow这个命令的意思是:总共 500 个请求,50 并发,每个请求都要花 10 秒。按下命令后间隔几秒,再开一个终端执行:
kill -15 $(jps | grep -i app | awk '{print $1}')然后观察 ab 的统计结果和控制台日志。
4.2 未开启优雅停机:kill 之后响应直接被掐断
先把server.shutdown=graceful注释掉,用默认的immediate跑一轮。kill 之后,控制台立刻开始关闭容器,正在执行的接口线程直接被中断。ab 端的表现非常明显:
- 大量
Failed requests。 - 客户端侧报
Connection reset by peer。 - 不管接口逻辑后面还有没有收尾工作,一律不执行。
- 最终输出的
Complete requests远小于 500。
我在实际压测中看到过更夸张的情况:有些请求已经进入 Tomcat 工作线程,但还没来得及打印“请求进入”,进程就关了。这类请求就是“连业务代码都没跑完就被掐断”的典型。
4.3 开启优雅停机:新请求被拒绝,在途请求正常收尾
然后把两行配置加上,重新启动并压测。中途 kill -15,重点关注几个表现:
第一,控制台输出Commencing graceful shutdown之后,原本已经在跑的接口线程继续打印“请求完成”,没有中断异常。
第二,ab 的Complete requests和Failed requests不再难看。已经进入 Tomcat 的请求基本都能正常拿到响应。
第三,停机期间新进来的请求会失败,因为容器已经不再 accept。你会在 Tomcat 日志里看到连接被拒绝的痕迹,这是预期行为。
所以优雅停机的核心价值不是“所有请求都不失败”,而是“已经进入的请求别被半路打死”。至于新请求,本来就应该靠前面的负载均衡摘流解决,这属于第 5 章要讨论的问题。
4.4 超时场景:超过等待上限的请求最终怎么收场
再验证一个边界:如果请求耗时超过timeout-per-shutdown-phase会怎样?
把spring.lifecycle.timeout-per-shutdown-phase改成3s,然后接口 sleep 10 秒,压测中途 kill。你会发现:
- 3 秒之后,日志里出现容器强制关闭的动作。
- 那个还在 sleep 的请求被中断,客户端同样会收到连接重置。
- 所以优雅停机不是“无限等”,它是有上限的。上限到了,该断还是断。
这个测试特别适合用来跟业务方对齐预期:停机窗口内新请求会失败,超长请求会失败,只有“正常长度”的在途请求能被保护。
4.5 生产视角看待压测结果
跑完这些对比,我建议把结论写进发布手册:
- 发布前先确认 Kafka 消费位移、消息推送、文件上传这类长任务耗时不超预算。
- 压测结论要让运维、QA、后端三方都看到,别只停留在“我这边加了配置”。
- 把压测中的
Graceful shutdown complete日志出现时间点记录下来,作为每次发布前的参考基线。
5. 单机优雅不解决流量分配:注册中心、Nginx、Docker 和 K8s 的配合
5.1 最容易被忽视的坑:负载均衡还在往停机节点甩流量
单机优雅停机只解决“请求已经进来了”的情况。但发布的时候,前面挂的 Nginx、Gateway、注册中心客户端并不知道这个节点正在停机,它们仍会把新请求转发过来。这个瞬间,连接被拒、超时、报错,还是会重演。
我见过一个团队做出来的停机顺序是反的:先 kill 进程,等容器优雅停完了,再去 Nginx 摘节点。摘流事件发生在进程死亡之后,前面的用户流量已经遭殃了。
正确的顺序应该是:先摘流,再停机。摘流手段根据你的流量入口不同,有几种做法,下面分开说。
5.2 手动摘流:Nginx、Eureka、Nacos、Consul 分别怎么处理
如果你们上游是 Nginx,最直接的健康检查方案是:Spring Boot 暴露一个健康检查接口,停机前先把该接口状态改成 DOWN,Nginx 通过max_fails或主动健康检查摘掉这个 upstream 节点。比较粗暴但有效的做法是在停机前用脚本调 Nginx 接口直接标记 down。
如果用的是注册中心,更标准化的做法是调用服务治理 API 提前反注册:
| 注册中心 | 摘流方式 | 说明 |
|---|---|---|
| Nacos | nacos-client的deregisterInstance | 主动摘除后,客户端拉取新列表需要时间 |
| Eureka | 发心跳把状态置为 OUT_OF_SERVICE | 配合 eureka 客户端缓存刷新 |
| Consul | 服务实例标记为 maintenance 或反注册 | 同样存在传播延迟 |
无论哪种方式,都要记住一个现实:注册中心的摘流是异步传播的,客户端可能存在缓存延迟。所以不要“反注册完立刻杀进程”,要留一个缓冲窗口,比如再等 5~10 秒,等流量真的淡出再停。
5.3 Docker stop 的 10 秒绞刑:默认 STOP_TIMEOUT 与 30 秒优雅超时的冲突
这块是网上抱怨最多、也最隐蔽的坑。
用 Docker 跑 SpringBoot 时,docker stop默认只等 10 秒,超过 10 秒直接SIGKILL。可是spring.lifecycle.timeout-per-shutdown-phase默认是 30s。如果你的停机时间超过 10s,Docker 会毫不客气地把进程干掉,优雅停机形同虚设。
解决办法有三个:
# 运行时指定 docker run --stop-timeout 60 your-image # compose 文件 services: app: stop_grace_period: 60s# 更推荐的还是在 Dockerfile 里明确信号 STOPSIGNAL SIGTERM注意这行STOPSIGNAL SIGTERM:有些基础镜像或启动脚本会对信号做二次转发,如果默认不是SIGTERM,优雅停机可能根本没被触发。Dockerfile 里写清楚,就没有这个歧义。
5.4 Kubernetes 滚动发布:readiness 探针与 preStop 的最佳姿势
K8s 场景跟 Docker 又不一样,因为 K8s 控制的是 Pod 生命周期,它默认给了terminationGracePeriodSeconds: 30s,这个窗口要大于你预估的优雅停机时间。
推荐的做法是用 readiness 探针配合 preStop hook:
spec: terminationGracePeriodSeconds: 60 containers: - name: app readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 10 periodSeconds: 5 lifecycle: preStop: exec: command: ["/bin/sh", "-c", "sleep 5"]这里面的逻辑是:K8s 在停止 Pod 前会先把该 Pod 从 Service Endpoint 中摘掉,但摘掉动作的生效也要时间,preStop里 sleep 几秒,等于给了 K8s 一个缓冲。sleep 结束,K8s 才发 SIGTERM,此时流量已经基本不来了,Spring Boot 才开始优雅停机。这样组合起来,才能真正做到“先摘流,再等请求,最后杀进程”。
5.5 多实例同时停机的雪崩问题:分批发布
最后一个集群层面的坑:如果你有多个实例,发布系统把实例 A、B、C 同时 kill,那它们同时在优雅停机,同时在等各自的重请求完成。等它们全部重启完,中间出现的空闲窗口,可能直接把整个服务的容量打穿。
稳妥做法是分批发布,先把流量摘掉一部分,停一个、起一个。滚动发布有天然的顺序,但如果你是自己写脚本发布,一定要手动控制并发数。这个经验我在线上吃过亏:优雅停机修好了“单机请求被掐断”,却差点因为“全部实例同时停机”导致服务不可用。
6. 给业务留足收尾时间:事件监听、Lifecycle 顺序与上线检查清单
6.1 ContextClosedEvent:别在 Web 容器停止后再做异步收尾
优雅停机等于给了你一个“业务收尾”的时间窗口。但很多团队把收尾逻辑写在@PreDestroy或自定义 ShutdownHook 里,顺序非常不可控。
更可控的方式是监听ContextClosedEvent。这个事件在 Spring 容器开始关闭时发出,此时 Bean 还都活着,你可以在这时候通知消息队列暂停消费、把服务状态标记为“停止接收流量”、保存内存中的批次数据等。
@Component public class ShutdownListener { @EventListener(ContextClosedEvent.class) public void onShutdown(ContextClosedEvent event) { // 1. 暂停消息消费 // 2. 触发缓存刷新或持久化 // 3. 标记健康检查为 DOWN System.out.println("容器开始关闭,执行业务收尾"); } }但要注意:这个回调里不要做耗时太长的操作,它会被计入整个关闭流程,超时一样会被强杀。
6.2 SmartLifecycle 与 phase 控制:注册中心为什么必须放在高 phase
如果你需要在 Web 容器停止之前就把注册中心摘掉,更规范的做法是实现SmartLifecycle并给一个较大的 phase。
Spring 的规则是:停止时数字越大越先关闭。所以摘流逻辑应该用 Integer.MAX_VALUE 附近的 phase,确保它排在 WebServer 优雅停机之前。
@Component public class DeregisterLifecycle implements SmartLifecycle { private volatile boolean running; @Override public void start() { this.running = true; } @Override public void stop() { // 在这里调用 Nacos / Eureka / Consul 摘流 System.out.println("先从注册中心摘除实例"); this.running = false; } @Override public boolean isRunning() { return this.running; } @Override public int getPhase() { return Integer.MAX_VALUE; } }这个模式比在ContextClosedEvent里写更严谨,因为它跟 Spring 的关闭顺序是同一套机制,不用担心某个 Bean 提前销毁导致调用失败。不过也要注意:摘流后必须在注册中心传播延迟里再拖几秒,所以我一般会在stop()里加一个轻量 sleep,别一摘完就立刻进入下一步。
6.3 定时任务、线程池、数据库连接池在停机期的真实表现
如果你项目里有@Scheduled定时任务,默认情况下它们跑在单线程调度器里。优雅停机过程中,这些任务不会自动取消,如果某个任务正在刷新缓存或跑批量任务,它也会被容器关闭打断。
我的建议是:给这些异步任务显式指定一个线程池,并在关闭时主动 shutdown。这样能保证优雅停机时,任务执行到安全边界才退出。
数据库连接池这块,HikariCP 在容器关闭时会等待租借出去的连接归还,但默认的等待时间并不长。如果某个慢 SQL 在停机瞬间还没跑完,Hikari 的连接会被强制释放,事务可能回滚。所以你真正要评估的是“业务慢请求的最差耗时”,而不是平均值。
6.4 上线前照着抄的停机检查清单
最后给一份可以直接抄进发布手册的检查清单:
- 确认 Spring Boot ≥ 2.3,配置了
server.shutdown=graceful且把timeout-per-shutdown-phase按最坏情况调大。 - 用
kill -15压测一次,确认出现Commencing graceful shutdown和Graceful shutdown complete两行日志。 - Docker 场景检查
stop-timeout或stop_grace_period,K8s 场景检查terminationGracePeriodSeconds与 preStop。 - 确认注册中心摘流在进程退出前完成,并留了缓冲时间。
- 明确新请求在停机窗口内注定会失败,由负载均衡/网关做兜底。
- 针对长连接和长期占用线程的请求(WebSocket、SSE、文件上传)单独确认超时策略。
我个人的体会是,优雅停机不是一个让“所有请求零失败”的方案,它是一个让“该成功的请求能成功”的方案。真正要啃的硬骨头在停机前的那几步流量调度上。把代码里的等待时间、容器参数里的超时时间、K8s 里的优雅终止时间这三件事对齐,线上发布才算真的稳健。