什么是优雅停机(Graceful Shutdown)?Spring Boot 怎么做到「停机不丢请求」?
简化版
优雅停机(Graceful Shutdown)是指「应用关闭时,先处理完手头正在执行的请求、拒绝新请求,再真正退出」,而不是「一刀切立刻杀死进程、把正在处理的请求全丢掉」。为什么需要:如果直接 kill 进程,正在处理的请求会被强行中断——用户看到报错、订单可能处理一半、数据可能不一致。Spring Boot 怎么做(2.3+ 内置):配置 server.shutdown=graceful,收到停机信号(SIGTERM)时:① 停止接收新请求(web 服务器不再接受新连接);② 等待正在处理的请求执行完(有一个超时 spring.lifecycle.timeout-per-shutdown-phase,默认 30 秒,超时就强制结束);③ 关闭线程池、释放资源、执行 @PreDestroy;④ 进程退出。触发前提:要收到 SIGTERM(kill 默认信号、K8s 停 Pod 发的信号)——JVM 的**关闭钩子(ShutdownHook)**捕获它触发优雅停机流程;但 kill -9(SIGKILL)无法捕获,会直接杀死,优雅停机失效。所以优雅停机 + K8s 的 preStop + 就绪探针配合,才能实现「滚动发布不丢请求」。
详细版
优雅停机 vs 强制停机:
| 维度 | 强制停机(kill -9) | 优雅停机(graceful) |
|---|---|---|
| 正在处理的请求 | 直接中断、丢失 | 等它处理完 |
| 新请求 | — | 拒绝接收 |
| 资源清理 | 不执行 | 执行 @PreDestroy、关连接池 |
| 数据一致性 | 可能损坏 | 保证处理完整 |
| 能否被捕获 | SIGKILL 不可捕获 | SIGTERM 可捕获 |
开启配置:
server:
shutdown: graceful # 开启优雅停机(默认 immediate)
spring:
lifecycle:
timeout-per-shutdown-phase: 30s # 等待正在处理请求的最长时间
优雅停机的流程:
收到 SIGTERM(kill / K8s 停 Pod)
↓
JVM 关闭钩子(ShutdownHook)被触发
↓
① web 服务器停止接收新连接/新请求
② 等待正在处理的请求执行完(最多等 timeout-per-shutdown-phase)
↓ (超时则强制结束剩余请求)
③ 关闭 Spring 容器:执行 @PreDestroy、销毁 Bean、关线程池/连接池
↓
④ 进程正常退出
⚠️ 优雅停机不是「配一个参数就万事大吉」,它依赖一整条链路:① 必须收到可捕获的信号——
kill(SIGTERM)、kill -15、K8s 删 Pod(先发 SIGTERM)可以;kill -9(SIGKILL)不可捕获,直接杀死,优雅停机完全失效。② K8s 环境还有「摘流量」的时间差问题——Pod 收到 SIGTERM 的同时,K8s 才开始把它从 Service 的 Endpoints 里摘掉,这期间可能还有新流量进来。所以要配preStop钩子(sleep 几秒,等 K8s 摘流量完成)+ 就绪探针(提前置为 not ready),配合应用的优雅停机,才能真正做到「滚动发布零丢请求」。单靠server.shutdown=graceful只解决了「应用侧不丢正在处理的请求」,摘流量是运维侧的事。
完整版教学
一、为什么需要优雅停机
先理解「强制停机会出什么问题」:
场景:滚动发布(发新版本,要停掉旧实例)
或 缩容(减少实例)、重启
如果直接 kill 进程(强制停机):
- 正在处理的请求被拦腰斩断 → 用户看到 502/连接重置
- 请求可能执行到一半:订单创建了但没返回、扣了款没发货
- 数据库事务可能未提交或未回滚干净
- 消息可能消费了一半没 ack
→ 用户体验差 + 数据可能不一致
优雅停机想要的:
停机时,"温柔地"收尾:
- 不再接新请求(新流量导向其他实例)
- 把手头正在处理的请求处理完(不丢)
- 清理资源(关连接、提交/回滚事务、释放锁)
- 再退出
→ 用户无感知、数据一致
优雅停机的动机是「停机时不丢正在处理的请求、不破坏数据一致性」——强制 kill 会拦腰斩断正在处理的请求(用户报错、订单处理一半、事务不干净)。优雅停机「温柔收尾」:不接新请求、处理完手头请求、清理资源再退出。在滚动发布、缩容、重启等频繁停机的云原生场景尤其重要。理解「强制停机丢正在处理的请求、破坏数据一致性;优雅停机不接新请求+处理完手头请求+清理资源再退出」,就理解了为什么需要优雅停机。
二、信号:SIGTERM vs SIGKILL
优雅停机的前提是「收到一个可捕获的信号」,这里 SIGTERM 和 SIGKILL 的区别是关键:
Linux 进程停止信号:
SIGTERM(15):礼貌的"请你退出"信号
- kill <pid>(默认)、kill -15、K8s 删 Pod 都先发它
- ★ 可以被进程捕获 → 应用能在退出前做优雅收尾
SIGKILL(9):强制的"立刻死"信号
- kill -9 <pid>
- ★ 不可捕获、不可忽略 → 进程被内核直接杀死,来不及做任何事
- 优雅停机对它完全无效
所以优雅停机的第一前提:
停机要用 SIGTERM(可捕获),不能用 kill -9
→ 应用捕获 SIGTERM → 触发优雅停机流程
K8s 的默认行为(很贴心):
删 Pod 时先发 SIGTERM,等 terminationGracePeriodSeconds(默认30秒)
如果还没退出,才发 SIGKILL 强杀
→ 给了应用优雅停机的时间窗
优雅停机的前提是「收到可捕获的 SIGTERM」——SIGTERM(15,kill 默认、K8s 删 Pod 先发)可被捕获,应用能在退出前收尾;SIGKILL(9,kill -9)不可捕获,进程被内核直接杀死,优雅停机无效。K8s 删 Pod 时先发 SIGTERM、等 terminationGracePeriodSeconds(默认 30 秒)后才 SIGKILL,给了优雅停机的时间窗。理解「SIGTERM 可捕获(kill 默认/K8s 先发)触发优雅停机、SIGKILL(kill -9)不可捕获直接杀死、K8s 先 SIGTERM 等宽限期再 SIGKILL」,就理解了优雅停机的信号前提。
三、JVM 关闭钩子:捕获信号的机制
应用是怎么「捕获 SIGTERM 并触发收尾」的?靠 JVM 关闭钩子:
JVM 关闭钩子(ShutdownHook):
Runtime.getRuntime().addShutdownHook(thread)
注册的线程,会在 JVM 正常关闭时(收到 SIGTERM、或 System.exit)执行
Spring Boot 利用它:
ApplicationContext 注册了一个关闭钩子
收到 SIGTERM → JVM 触发关闭钩子 → 调 context.close()
→ 触发容器关闭:执行 @PreDestroy、销毁 Bean、关闭 web 服务器...
→ 优雅停机的整套流程就是挂在这里
关键:
- SIGTERM/System.exit → 触发关闭钩子 → 能优雅收尾
- SIGKILL(kill -9)→ 不触发关闭钩子 → 无法收尾
- 所以 @PreDestroy、优雅停机 都依赖"进程被优雅地要求退出"
应用捕获信号靠 JVM 关闭钩子(ShutdownHook)——注册的线程在 JVM 正常关闭(收到 SIGTERM 或 System.exit)时执行。Spring Boot 的 ApplicationContext 注册了关闭钩子,收到 SIGTERM 时触发 context.close(),进而执行 @PreDestroy、销毁 Bean、关闭 web 服务器——优雅停机整套流程挂在关闭钩子上。而 kill -9 不触发关闭钩子,所以 @PreDestroy 和优雅停机都失效。理解「JVM 关闭钩子捕获 SIGTERM 触发 context.close()、优雅停机和 @PreDestroy 都挂在关闭钩子上、kill -9 不触发钩子」,就理解了捕获信号的机制。
四、Spring Boot 优雅停机的完整流程
Spring Boot 2.3+ 内置优雅停机,配 server.shutdown=graceful 后的完整流程:
配置:
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s
流程(收到 SIGTERM 后):
1. 关闭钩子触发,开始关闭容器
2. ★ web 服务器进入"优雅关闭"模式:
- 停止接收新连接/新请求
- 但保持正在处理的请求继续执行
3. 等待正在处理的请求执行完
- 最多等 timeout-per-shutdown-phase(默认 30 秒)
- 期间新请求被拒绝(连接被关闭或返回错误)
- 所有在途请求处理完 → 立刻进入下一步
- 超时仍有未完成的 → 强制结束它们
4. 关闭 Spring 容器:@PreDestroy、销毁 Bean、
关闭线程池、数据库连接池等
5. 进程退出
支持的容器:Tomcat、Jetty、Undertow、Netty(Reactor) 都支持
Spring Boot 优雅停机流程(server.shutdown=graceful):① web 服务器停接新请求、保持在途请求;② 等在途请求处理完(最多 timeout-per-shutdown-phase,默认 30 秒,超时强制结束);③ 关闭容器执行 @PreDestroy、关线程池/连接池;④ 退出。Tomcat/Jetty/Undertow/Netty 都支持。这样保证「正在处理的请求不丢」。理解「优雅停机流程:停接新请求→等在途请求完成(超时强制)→关容器执行 @PreDestroy 关连接池→退出、默认 30 秒超时」,就掌握了 Spring Boot 优雅停机的完整流程。
五、K8s 环境:摘流量的时间差
生产(尤其 K8s)里,优雅停机还有个「摘流量时间差」问题必须解决:
问题:Pod 收到 SIGTERM 时,K8s 才"同时"开始把它从 Service 摘掉
但"摘除"是异步的、有延迟(要更新 Endpoints、各节点 kube-proxy 同步)
→ 这期间(可能几百 ms~几秒),仍有新流量被路由到这个正在停的 Pod!
→ 如果应用此时已停止接收新请求 → 这些流量被拒绝 → 用户报错
解决:preStop 钩子 + 就绪探针
① preStop 钩子:Pod 停止前先执行(如 sleep 5~15 秒)
lifecycle:
preStop:
exec: command: ["sh","-c","sleep 10"]
→ 给 K8s 时间把流量摘干净,再让应用真正开始停机
(preStop 执行期间,SIGTERM 还没发给应用,应用还在正常服务)
② 就绪探针:停机开始时先置为 not ready,K8s 停止转发新流量
完整链路(零丢请求):
K8s 删 Pod → 执行 preStop(sleep,等摘流量)
→ 发 SIGTERM → 应用优雅停机(处理完在途请求)→ 退出
K8s 环境的额外问题是「摘流量的时间差」——Pod 收到 SIGTERM 时 K8s 才异步开始摘流量(有延迟),这期间仍有新流量进来但应用已拒绝接收,导致用户报错。解决靠 preStop 钩子(停机前 sleep 几秒,等 K8s 摘流量完成,期间应用还在正常服务)+ 就绪探针(提前置 not ready)。完整链路:删 Pod → preStop sleep 等摘流量 → SIGTERM → 应用优雅停机 → 退出。理解「K8s 摘流量是异步有延迟、这期间新流量进来会被拒、用 preStop sleep 等摘流量+就绪探针配合应用优雅停机才能零丢请求」,就理解了生产环境优雅停机的完整方案。
六、其他资源的优雅关闭
优雅停机不只是 web 请求,还要考虑「其他正在进行的工作」:
除了 web 请求,停机时还有这些要"优雅":
① 线程池里正在执行的任务:
- ThreadPoolTaskExecutor 设 setWaitForTasksToCompleteOnShutdown(true)
+ setAwaitTerminationSeconds(N) → 等任务执行完再关
② 消息消费者(Kafka/RabbitMQ):
- 停止拉取新消息,把正在处理的消息处理完并 ack,再断开
- Spring 的消息容器一般在容器关闭时会优雅停止
③ 定时任务(@Scheduled):
- 正在执行的定时任务要能被优雅等待/中断
④ 数据库连接池:
- 等正在用的连接归还、事务提交/回滚,再关闭池
统一思路:
@PreDestroy / DisposableBean 里做资源收尾
或用 SmartLifecycle(可控制关闭阶段和顺序)
Spring 容器关闭时会按顺序触发这些,配合优雅停机一起完成
优雅停机要覆盖「所有正在进行的工作」,不只是 web 请求:线程池任务(setWaitForTasksToCompleteOnShutdown)、消息消费者(处理完在途消息并 ack)、定时任务、数据库连接池(等连接归还、事务提交)。这些通过 @PreDestroy/DisposableBean/SmartLifecycle(可控关闭顺序)在容器关闭时收尾。所以优雅停机是「应用整体的优雅收尾」。理解「优雅停机要覆盖线程池任务/消息消费/定时任务/连接池、通过 @PreDestroy/SmartLifecycle 收尾、是应用整体的优雅收尾」,就理解了优雅停机的完整范围。
记忆钩子:「优雅停机 = 停机时先处理完在途请求、拒绝新请求、清理资源再退出(不像 kill -9 一刀切丢请求);Spring Boot 2.3+ 配 server.shutdown=graceful + spring.lifecycle.timeout-per-shutdown-phase(默认30s);流程:停接新请求→等在途请求完成(超时强制)→关容器执行 @PreDestroy 关连接池→退出;前提:收到可捕获的 SIGTERM(kill 默认/K8s 先发)→JVM 关闭钩子触发,kill -9(SIGKILL)不可捕获直接杀死优雅停机失效;K8s 摘流量异步有延迟,要 preStop sleep 等摘流量+就绪探针才能零丢请求」。
七、常见误区与追问
- 误区:配了 server.shutdown=graceful 就能零丢请求。 只解决了「应用侧不丢在途请求」;K8s 环境还有摘流量的时间差(Pod 收到 SIGTERM 时 K8s 才异步摘流量),要配 preStop 钩子(sleep 等摘流量)+ 就绪探针,才能真正零丢请求。
- 误区:kill -9 也能触发优雅停机。 不能——SIGKILL(kill -9)不可捕获、不触发 JVM 关闭钩子,进程被内核直接杀死,优雅停机和 @PreDestroy 全部失效;优雅停机必须用可捕获的 SIGTERM(kill 默认信号)。
- 误区:优雅停机会无限等待在途请求。 有超时(spring.lifecycle.timeout-per-shutdown-phase,默认 30 秒);等待期内在途请求处理完就立刻进入下一步,超时仍未完成的会被强制结束——避免某个卡住的请求让停机永远完不成。
- 误区:优雅停机只管 HTTP 请求。 还要管线程池正在执行的任务、消息消费者的在途消息、定时任务、数据库连接池等;这些通过 @PreDestroy/SmartLifecycle 在容器关闭时优雅收尾,是应用整体的优雅关闭。
- 追问:@PreDestroy 和优雅停机是什么关系? @PreDestroy 挂在 JVM 关闭钩子触发的容器关闭流程里——收到 SIGTERM → 关闭钩子 → context.close() → 执行 @PreDestroy;优雅停机在此基础上先做「停接新请求、等在途请求完成」,再走到 @PreDestroy 这步;两者都依赖收到可捕获信号(kill -9 时都不执行)。
- 追问:K8s 里为什么要配 preStop sleep? 因为 Pod 收到 SIGTERM 的同时,K8s 才异步开始把它从 Service Endpoints 摘除(有延迟),这期间仍有新流量路由过来;preStop 里 sleep 几秒让应用先「多服务一会」,等 K8s 摘流量完成后再收 SIGTERM 开始停机,避免摘流量窗口期的请求被拒。
- 追问:terminationGracePeriodSeconds 和 timeout-per-shutdown-phase 的关系? 前者是 K8s 给 Pod 的总宽限时间(SIGTERM 后等多久才 SIGKILL,默认 30 秒);后者是 Spring 等待在途请求的时间(默认 30 秒);要保证 K8s 的宽限期 ≥ preStop 时间 + Spring 优雅停机时间,否则应用还没优雅停完就被 K8s SIGKILL 强杀了。
八、加强记忆
优雅停机(Graceful Shutdown)= 应用关闭时先处理完在途请求、拒绝新请求、清理资源再退出,而非 kill -9 一刀切丢请求(会导致用户报错、订单处理一半、数据不一致)。Spring Boot 2.3+ 内置:配 server.shutdown=graceful + spring.lifecycle.timeout-per-shutdown-phase(默认 30s)。流程(收到信号后):① web 服务器停接新请求、保持在途请求;② 等在途请求执行完(最多等超时,超时强制结束);③ 关闭容器执行 @PreDestroy、关线程池/连接池;④ 退出。前提是收到可捕获的 SIGTERM(kill 默认、K8s 删 Pod 先发)——JVM 关闭钩子(ShutdownHook) 捕获它触发 context.close();kill -9(SIGKILL)不可捕获、直接杀死,优雅停机和 @PreDestroy 全失效。K8s 环境还有「摘流量时间差」:Pod 收 SIGTERM 时 K8s 才异步摘流量(有延迟),期间新流量会被拒,要配 preStop 钩子(sleep 等摘流量)+ 就绪探针,配合应用优雅停机才能「滚动发布零丢请求」。优雅停机还要覆盖线程池任务、消息消费、定时任务、连接池(@PreDestroy/SmartLifecycle 收尾)。一句话「优雅停机=停机先处理完在途请求、拒新请求、清资源再退出;Spring Boot 配 server.shutdown=graceful(默认30s超时);靠 JVM 关闭钩子捕获 SIGTERM(kill -9 不可捕获失效);K8s 还要 preStop sleep 等摘流量+就绪探针才能零丢请求」。