线上发布时,你有没有用过kill -9强行终止应用?进程瞬间消失,部署脚本跑得飞快,看起来一切正常。但用户那边可能正在提交订单、正在支付回调、正在上传文件——这些请求在毫秒之间被腰斩,数据写了一半,消息发了一半,分布式锁没释放,数据库连接没归还。一次kill -9的代价,可能是一批用户投诉、一笔对账差账、甚至一次线上故障。
从 Spring Boot 2.3 开始,框架正式内置了优雅停机功能,覆盖 Tomcat、Jetty、Undertow 和 Reactor Netty 四种内嵌 Web 服务器。但直到今天,很多团队依然没有开启它。
开启优雅停机,只需要两行配置
在application.yml中加上两个配置项即可:
server: shutdown: "graceful" spring: lifecycle: timeout-per-shutdown-phase: "20s"
第一行告诉 Spring Boot 收到关闭信号后不要立即终止,而是先停止接收新请求,再等待正在处理的请求完成。第二行设置宽限期,默认值是 30 秒,超过这个时间仍未完成的请求会被强制中断。
不同 Web 服务器的行为略有差异:Tomcat、Jetty 和 Reactor Netty 在网络层直接拒绝新连接;Undertow 则会接受新请求但立即返回 503,因为它的线程模型决定了“不接收”不如“快速拒绝”更安全。
Kubernetes 环境下,仅靠 Spring Boot 配置还不够
如果你在 K8s 中部署,有一个关键陷阱:Spring Boot 的优雅停机处理的是“已经到达 Pod 的请求”,但它无法阻止 Kubernetes 在 Pod 被摘除之前继续向它转发新流量。K8s 默认在发送 SIGTERM 信号的同时,就开始了从 Service Endpoint 中移除 Pod 的操作——但负载均衡器的状态同步存在延迟。
正确的做法是在 Deployment 中配置preStopHook,在 SIGTERM 发出前预留一段缓冲时间:
lifecycle: preStop: exec: command: ["/bin/sh", "-c", "sleep 15"]
这 15 秒的作用是:让 K8s 有足够时间将这个 Pod 从 Endpoint 中摘除,确保所有新流量已经停止转发到该实例。之后再发送 SIGTERM,Spring Boot 才开始优雅停机流程。同时,terminationGracePeriodSeconds必须大于preStop的 sleep 时间加上 Spring Boot 的宽限期,否则容器还没完成清理就会被 K8s 强制杀死。
别忘了清理你自定义的资源
Spring Boot 的优雅停机默认只负责 Web 层的请求排空。如果你在代码中自定义了线程池、调度器或消息队列监听器,这些资源需要你自己在@PreDestroy方法中关闭。
线程池的优雅关闭关键在两个参数:setWaitForTasksToCompleteOnShutdown(true)让线程池在关闭前等待队列中的任务执行完毕,setAwaitTerminationSeconds(60)设置最大等待时间。对于数据库连接池,可以在@PreDestroy中先暂停连接借出,再等待活跃连接归还。有一个容易被忽略的坑:如果你的线程池是通过new关键字自己创建的,而不是交给 Spring 容器管理,@PreDestroy不会被调用,必须手动注册销毁逻辑。
从“启动时优雅”到“关闭时优雅”
Spring Boot 优雅停机的意义,不在于它用了多复杂的技术——底层不过是 JVM 的 Shutdown Hook 机制。它的真正价值在于提醒我们:一个服务的生命周期,不应该只有“启动”和“运行”两个状态。关闭同样是一个需要认真设计的阶段,它决定了你的系统在滚动更新、弹性扩缩容、故障迁移时,是让用户无感,还是让用户买单。
下次发布时,把kill -9换成kill -15,把那两行配置加上。这不是一个需要纠结的技术选型,而是一个应该成为默认习惯的工程素养。