朋友上周遇到一件挺糟心的事:服务在流量高峰期直接重启,几十个正在处理的支付回调瞬间断掉,数据库里多了一堆处理到一半的脏数据。第一反应是加事务、改代码,查到最后才发现根子很简单——应用没有做优雅停机(Graceful Shutdown)。SpringBoot 2.3 之前,这个能力基本靠手写,写不好就丢请求;SpringBoot 2.3 之后框架原生支持,可很多团队仍然没配,或者配了不生效。这篇文章我想把优雅停机的演进、原理、落地配置和常见坑一次讲透,给你一份能直接参考的方案。
1. 为什么需要优雅停机:从一次深夜事故说起
1.1 停机前的那几秒到底发生了什么
一个普通的 SpringBoot 应用,从请求进来到处理完,链路大概是这样的:请求先到 Tomcat 的连接器(Connector),由 Acceptor 线程接收,然后丢给工作线程池,工作线程再一路调到 Controller、Service、DAO,中间可能涉及数据库事务、Redis 缓存、RPC 调用、消息队列发送。这条链路上的任何一个环节被打断,都可能留下不完整的数据。
举一个最直接的例子:用户下单接口里,先从库存表扣减库存,再创建订单记录,最后调支付网关。如果线程执行到"扣完库存、还没创建订单"时进程被杀,库存少了、订单却没生成。这类问题在高并发场景下特别隐蔽,因为不是每次都触发,但一旦触发就是线上 P0 事故。
还有个容易被忽略的点:数据库连接池里的连接是有限的。进程突然消失,连接不会被主动归还,MySQL 要等 TCP 超时才能感知,这期间连接池可能被占满,影响其他服务。Tomcat 的线程池也一样,正在处理的线程被中断,后续请求全部堆在队列里,然后端口被释放后,新节点启动、旧节点还没完全退出,负载均衡来回切换,等于在伤口上撒盐。
1.2 三个典型的不优雅停机事故场景
第一个场景是 kill -9 直接杀掉进程。这种操作对 JVM 来说是"意外死亡",不会触发任何清理逻辑,正在执行的业务线程原地消失,文件没落盘、消息没 ack、分布式锁没释放。
第二个场景发生在发布系统里:负载均衡还没把节点摘掉,运维就直接停进程。流量还在往这个节点转发,节点却已经凉了,用户端看到的就是连接被重置、请求超时、HTTP 500。
第三个场景跟异步任务有关。很多应用会内嵌线程池做异步处理,比如异步发短信、异步写日志。进程一停,线程池里的任务排队排到一半就没了,日志丢失、短信漏发,排查起来特别费劲。
这三个场景有个共同点:单纯靠"提高并发能力"解决不了,必须要让进程在退出前有一段"善后时间",把正在做的事做完,或者明确拒绝新任务并给出响应,这就是优雅停机的价值。
1.3 优雅停机是底线,不是高级优化
我见过不少团队把优雅停机当成"有时间再弄的增强功能",实际上它应该属于基础设施的默认配置。单机部署的时候,重启一次影响面可能只是几笔请求;微服务化之后,一个节点重启,上游可能同时有几十个服务在调它,影响被成倍放大。
而且现在的容器环境基本都默认发 SIGTERM 信号给进程,不再靠人肉去 kill -9。这种部署模式下,如果应用不处理 SIGTERM、不给自己留缓冲时间,那么每次滚动发布都是一次小范围事故。换句话说,优雅停机不是锦上添花,而是容器化部署的默认要求。
2. 优雅停机的演进路线:从硬杀到好商量
2.1 裸奔时代:kill -9 与凌晨发版
早年间部署 Java Web 应用,最常見的操作就是 找到进程号、kill -9、重新启动。那时候能接受这种粗暴方式,有几个现实原因:单体应用、用户量不大,深夜发版影响有限;数据库和中间件没有像现在这么复杂;出问题也就是第二天翻日志。
但技术债是要还的。随着业务并发上来、调用链变长,"哪天不丢请求"变成了运气问题。大家开始意识到:停机不能瞬间完成,应该给进程一个"缓冲期",让它在退出前把该处理的处理完。
这个阶段的改进是尽量避开高峰期发布,用脚本先摘流量、再杀进程。但脚本摘流量依赖运维手工操作,摘慢了漏掉请求,摘快了浪费资源,终究不是稳定方案。
2.2 JVM ShutdownHook:第一层补救
Java 层面第一个正式的补救机制是 ShutdownHook。通过Runtime.getRuntime().addShutdownHook()注册一个线程,JVM 收到 SIGTERM 时,会启动这些 Hook 线程做一些清理,比如关闭连接池、保存状态。
Runtime.getRuntime().addShutdownHook(new Thread(() -> { System.out.println("shutdown..."); // 清理资源 }));这个机制解决了一部分问题,但局限很明显:ShutdownHook 只保证 JVM 退出前执行你注册的代码,它不会等待 Tomcat 里正在处理中的请求。假如一个接口执行要 5 秒,Hook 只管自己清理完就结束,那 5 秒内还没跑完的线程照样被终止。
所以在实际项目里,ShutdownHook 更适合做"进程级资源清理",而不是"请求级优雅停机"。要想让请求安全跑完,还是得靠 Web 容器本身的支持。
2.3 Spring Boot 2.3:官方方案落地
Spring Boot 2.3 之前,要实现比较完整的优雅停机,有两条路:一是引入 Spring Boot Actuator,通过/shutdown端点触发关闭,再配合自定义 SmartLifecycle;二是直接用 Tomcat 原生的生命周期接口去停连接器。两条路都需要不少手写代码,不同项目的写法差异很大,很难保持统一。
Spring Boot 2.3 开始,官方原生支持优雅停机,配置变得非常简单:
server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s设置server.shutdown=graceful后,Spring Boot 在关闭时会先停止接收新请求,然后等待存量请求处理完成,直到超过timeout-per-shutdown-phase设定的超时时间。默认的shutdown=immediate保持旧行为,也就是立刻结束。
这套方案的好处在于它被内嵌到了 Spring Boot 的 Web 容器生命周期中,对 Tomcat、Jetty、Undertow 和 Reactor Netty 都生效。你现在用 SpringBoot 2.3+ 版本,只要改这两个配置项就能获得一个基础可用的优雅停机能力。
3. 实现原理拆解:优雅停机背后的机制
3.1 一句话说透:优雅停机在做什么
优雅停机的本质只有三步:第一步,停止接收新的请求;第二步,等待已接收的存量请求处理完;第三步,释放所有资源,退出进程。理解这个顺序很重要,很多人把优雅停机理解成"延迟杀进程",其实它核心不是延迟,而是"先停水再停业"的妥协过程。
打个比方,饭店打烊不是直接把客人轰走,而是先不再接待新客人,让已经在店里吃饭的客人吃完再关门。优雅停机就是让 Web 容器"不再接待新客人",同时给"正在吃饭的客人"留出用餐时间。
这里的关键是"停止接收新请求"不代表立即断开所有连接。Tomcat 的 Acceptor 线程会停止接受新连接,但已建立的连接会继续被处理;如果是 Keep-Alive 连接,容器会停止从该连接上读取新请求,等当前请求结束后关闭连接。
3.2 Spring Boot 的关闭流程与 SmartLifecycle
Spring Boot 的关闭流程依托于 Spring 容器的ApplicationContext.close()方法,这个方法会依次触发 Bean 的销毁、发布ContextClosedEvent事件、调用所有 Lifecycle 组件的停止方法。
这里的核心接口是SmartLifecycle,它定义了stop(Runnable callback)、getPhase()等方法,Spring 容器按照getPhase()的返回值从小到大依次停止组件。相位值越小的越先停止。Spring Boot 在优雅停机场景里内置了一个WebServerGracefulShutdownLifecycle,它的相位设置在比较靠前的位置,所以 Web 容器会先进入"停止接收新请求"的状态,之后才是其他普通 Bean 的销毁。
我建议你先记住这个顺序:Web 容器优雅停机 → 其他 Lifecycle 组件按相位停止 → Bean 销毁和资源清理。这样后续排查问题时,你能大致判断当前进程卡在哪一步。
3.3 内嵌容器的具体实现差异
虽然配置入口统一,但不同容器的实现差别很大。Tomcat 在收到停机指令后会让 Connector 先pause(),暂停接受新连接,同时等待工作线程池中的任务完成。Spring Boot 2.3 内置的 Tomcat 9.0.33 已经支持这套机制。
Jetty 的实现思路类似,通过停止连接工厂来拒绝新请求,已经进入处理流程的请求等待完成。Undertow 则提供了专门的 GracefulShutdownHandler,Spring Boot 通过 listener 机制接入。Reactor Netty 作为响应式服务器,等待的是当前所有活跃的 channel 处理完再关闭。
这意味着你在选型时不需要因为容器而改变业务代码,但需要在调优时意识到:默认配置里,Tomcat 和 Jetty 的"等待存量请求"能力和活跃线程数、连接数强相关;如果服务长期保持大量 Keep-Alive 连接,存量请求的完成时间会被拉长。
3.4 等待时间耗尽后会发生什么
很多人以为设置了timeout-per-shutdown-phase=30s,30 秒内没跑完的请求就会被无限期等待。实际上不是这样,超时时间一到,Spring Boot 会强制结束等待,容器进入最终关闭流程,还没处理完的请求直接被终止。
这个设计是合理的:优雅停机不等于无限期停机。如果一个请求因为死循环、外部依赖一直不返回而永远不结束,系统就永远退不掉,发布流程会被活活拖死。所以你必须给等待时间设置一个上限,同时保证足够覆盖大多数业务的正常处理时长。
实际操作中,我一般把超时时间设置为 30 到 60 秒,然后通过监控观察"接近超时"的请求数量。如果经常有请求刚好卡在超时阈值附近,那说明不是因为停机流程有问题,而是某些业务本身跑得太慢,需要单独优化。
4. 生产环境落地:配置、容器编排与验证
4.1 一份可以直接抄的配置
在 SpringBoot 项目中,最小可用的优雅停机配置就是这样:
server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s如果项目里用到了内嵌数据库连接池(比如 HikariCP),我建议你同时给连接池配一个合理的maximumPoolSize和连接空闲超时,避免停机时大量连接被占住。HikariCP 默认会随容器销毁连接,但连接释放需要时间,如果连接数很多,可能会影响后续资源清理。
还有一个容易被忽略的地方:自定义的线程池也要参与停机。Spring 的ThreadPoolTaskExecutor默认实现了DisposableBean,在容器关闭时会shutdown(),但如果你是自己 new 的ExecutorService,它不在 Spring 容器管理范围内,需要你手动关闭。
@Bean public ExecutorService taskExecutor() { return Executors.newFixedThreadPool(10); }上面这种写法就建议替换成ThreadPoolTaskExecutor,或者至少注册一个SmartLifecycle在停机阶段执行shutdown()。不然线程池里排队任务会在进程退出时静默丢失。
4.2 和 Kubernetes/负载均衡打配合的时序
配置好 SpringBoot 仅仅是第一步,真正生产环境的优雅停机需要和容器编排平台配合,否则应用侧等半天,流量管道的另一边还在固执地往这个节点转发请求。
Kubernetes 中,Pod 被删除时会发生几件事:kubelet 触发 PreStop 钩子(如果配置了)、Endpoints 从 Service 中摘除、然后发送 SIGTERM 给容器主进程。PreStop 如果设置了 sleep,SIGTERM 会在 sleep 结束后才发送。也就是说,你可以利用 PreStop 的延迟,给负载均衡摘流预留时间。
我实际用的方案是三步:先在 deployment 里配置terminationGracePeriodSeconds,让它略大于"PreStop 等待时间 + 应用优雅停机超时时间";再配 PreStop 执行sleep 10,给 SLB 或 Ingress 一个摘流间隔;最后让 SpringBoot 的优雅停机超时保持在 30 秒左右。这样整个删除流程下来,Pod 能比较平滑地退出。
spec: terminationGracePeriodSeconds: 60 containers: - name: app lifecycle: preStop: exec: command: ["sleep", "10"]这个做法解决的核心问题是:k8s 摘流量是异步的,如果不给摘流时间,即使 SpringBoot 配置了优雅停机,新请求依然可能在被终止的 Pod 上打转。PreStop sleep 的意思不是单纯拖延,而是给路由层一个"反应时间"。
4.3 给关键任务加最后一道保险
容器层面的优雅停机解决的是"请求不丢",但有些资源清理工作是框架感知不到的。比如你在用消息队列手动 ack 模式,消费者收到消息后还在执行业务,如果进程直接退出,可能不会 ack,消息会被重新投递;如果你持有分布式锁,停机时不释放,其他节点可能一直拿不到锁。
这类问题,我的建议是用 Spring 的事件监听机制,在容器关闭时执行自定义清理逻辑:
@Component public class GracefulShutdownListener implements ApplicationListener<ContextClosedEvent> { @Override public void onApplicationEvent(ContextClosedEvent event) { // 释放分布式锁 // 标记消费者停止拉取新消息 // 持久化内存中的待处理状态 } }注意,ContextClosedEvent是在容器关闭时发布的,但发布时机比 Web 容器的优雅停机要晚。所以你在这个事件里做清理时,Web 容器已经不再接收新请求了,可以安心处理存量业务资源。如果你有更严格的顺序要求,考虑实现SmartLifecycle并设置合适的phase值。
4.4 验证优雅停机:测试方案与监控指标
配置写完不验证等于没写。我提供一个最直接的验证方法:写一个故意慢的接口,让它 sleep 5 秒,压测工具发起请求的同时,给进程发送 SIGTERM,观察请求是否能正常返回。
比如用 curl 模拟一个长请求,后台再发停止信号:
# 请求会执行 10 秒 curl http://localhost:8080/slow-request & # 找到进程并发送 SIGTERM kill -TERM $(jps | grep Application | awk '{print $1}')如果配置生效,你应该看到:新请求被拒绝或连接重置,而slow-request这个请求能正常返回 200;日志中会出现 Spring Boot 的优雅停机提示,进程直到请求处理完或者达到超时时间才退出。
监控方面,建议记录两个指标:一个是"从收到 SIGTERM 到进程真正退出"的总时长;一个是"停机期间失败的请求数"。这两个指标直接反映优雅停机的效果。配合日志关键词Shutting down ExecutorService、Commencing graceful shutdown就能定位大多数问题。
5. 常见问题与排查技巧实录
5.1 配置了优雅停机,请求为什么还是丢
这是最常被问到的。配置没问题、代码没问题,但请求还是丢,多半是下面的原因:
第一,没有用 SIGTERM 停进程。优雅停机依赖 JVM 正确接收到 SIGTERM 信号,如果你用kill -9,信号被直接拦截,Spring Boot 根本没有机会执行优雅停机逻辑。第二,负载均衡摘流太慢,新请求在优雅停机开始前已经进入容器,这时候容器会尽量处理,但 Kafka 消费、RPC 回调和异步线程池里的任务不受控制,就可能丢。第三,超时时间设置太短,长请求还没跑完就被强杀。
排查时可以先用jstack看线程状态,确认进程退出时工作线程是正在执行还是已经空闲;再确认运维平台的发布脚本用的是kill(SIGTERM)还是kill -9。
5.2 进程一直退不掉,发布超时
优雅停机改成"graceful"之后,最常见的副作用是进程退出变慢,严重时发布超时。根因通常是有请求一直不结束,比如 WebSocket 长连接没断、SSE 连接保持打开、HTTP 请求里的异步逻辑卡在等待外部响应。
WebSocket 和 SSE 这类长连接,Tomcat 的工作线程会一直占用,优雅停机的等待时间会不断累计,直到超过timeout-per-shutdown-phase才开始强制关闭。如果超时时间设得很长,发布平台可能先忍不了。
我的处理方式是:给所有外部调用设置连接超时和读取超时,避免线程无限挂起;对 WebSocket 连接,在停机前主动发送关闭帧;对 SSE,控制心跳机制,确保连接不会长期闲置。如果真要调大超时时间,先想清楚有哪些请求会长期占用线程。
5.3 K8s 滚动更新时优雅停机"失灵"
K8s 环境里常见的假象是:Pod 在优雅停机,但流量还在进来,导致请求大量失败。这要回到流程本质:k8s 摘除 Endpoint 是异步的,SIGTERM 一发给进程,Pod 还有优雅停机时间,但对 kube-proxy 和 Ingress Controller 来说,移除这个节点需要时间。
如果发现滚动更新时请求大量失败,请先检查terminationGracePeriodSeconds是否小于应用的优雅停机超时。小于的话,kubelet 会在超时后直接 SIGKILL,优雅停机白配。另外检查是否设置了 readinessProbe,并且探针是否在停机前先返回失败。最稳妥的组合就是:"readinessProbe 检测到停机立即失败 + preStop sleep 10 + SpringBoot graceful shutdown 30s"。
5.4 线程池与中间件资源没释放干净
有些应用配置了优雅停机,进程却在那之后还会"苟活"很一段时间,CPU 不高,就是退不掉。这时候多半是自定义线程池或者第三方客户端线程没有销毁。
排查方法简单直接:jstack 一把线程 dump,看是否有非守护线程一直处于 TIMED_WAITING 或 RUNNABLE 状态。常见的如 Elasticsearch Client 的通信线程、Redis 连接池的空闲监测线程、消息队列消费者的拉取线程。这些资源的关闭要么注册成 Spring Bean,要么显式在@PreDestroy里调用close()。
我个人在实际操作中的体会是:优雅停机不是单个开关能解决的,它是一个和部署方式耦合的系统工程。SpringBoot 的配置只提供最基础的保证,真正决定效果的是你有没有把所有资源都纳入可控范围。每次微调配置后,都值得完整走一遍发布流程观察指标,别停在"配了就平安无事"的错觉里。另外一个小技巧:在本地调试时,用curl配合kill -TERM完整演练一次,比任何文档都直观。