Spring Boot优雅停机全解析:原理、配置与K8s实践
朋友上周遇到一件挺糟心的事服务在流量高峰期直接重启几十个正在处理的支付回调瞬间断掉数据库里多了一堆处理到一半的脏数据。第一反应是加事务、改代码查到最后才发现根子很简单——应用没有做优雅停机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.shutdowngraceful后Spring Boot 在关闭时会先停止接收新请求然后等待存量请求处理完成直到超过timeout-per-shutdown-phase设定的超时时间。默认的shutdownimmediate保持旧行为也就是立刻结束。这套方案的好处在于它被内嵌到了 Spring Boot 的 Web 容器生命周期中对 Tomcat、Jetty、Undertow 和 Reactor Netty 都生效。你现在用 SpringBoot 2.3 版本只要改这两个配置项就能获得一个基础可用的优雅停机能力。3. 实现原理拆解优雅停机背后的机制3.1 一句话说透优雅停机在做什么优雅停机的本质只有三步第一步停止接收新的请求第二步等待已接收的存量请求处理完第三步释放所有资源退出进程。理解这个顺序很重要很多人把优雅停机理解成延迟杀进程其实它核心不是延迟而是先停水再停业的妥协过程。打个比方饭店打烊不是直接把客人轰走而是先不再接待新客人让已经在店里吃饭的客人吃完再关门。优雅停机就是让 Web 容器不再接待新客人同时给正在吃饭的客人留出用餐时间。这里的关键是停止接收新请求不代表立即断开所有连接。Tomcat 的 Acceptor 线程会停止接受新连接但已建立的连接会继续被处理如果是 Keep-Alive 连接容器会停止从该连接上读取新请求等当前请求结束后关闭连接。3.2 Spring Boot 的关闭流程与 SmartLifecycleSpring 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 则提供了专门的 GracefulShutdownHandlerSpring Boot 通过 listener 机制接入。Reactor Netty 作为响应式服务器等待的是当前所有活跃的 channel 处理完再关闭。这意味着你在选型时不需要因为容器而改变业务代码但需要在调优时意识到默认配置里Tomcat 和 Jetty 的等待存量请求能力和活跃线程数、连接数强相关如果服务长期保持大量 Keep-Alive 连接存量请求的完成时间会被拉长。3.4 等待时间耗尽后会发生什么很多人以为设置了timeout-per-shutdown-phase30s30 秒内没跑完的请求就会被无限期等待。实际上不是这样超时时间一到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 如果设置了 sleepSIGTERM 会在 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 ApplicationListenerContextClosedEvent { 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看线程状态确认进程退出时工作线程是正在执行还是已经空闲再确认运维平台的发布脚本用的是killSIGTERM还是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完整演练一次比任何文档都直观。