Spring Boot 优雅关闭与流量摘除——生产环境零中断部署的关键
Spring Boot 优雅关闭与流量摘除——生产环境零中断部署的关键
适读人群:负责服务运维或持续部署的 Java 工程师 | 阅读时长:约16分钟 | 核心价值:掌握 Spring Boot 优雅关闭的完整实现,结合 Kubernetes 实现真正的零中断滚动更新
一、那次上线导致大量 502 的事故
某同学负责一个高并发的支付回调服务,日常 QPS 大约 300。有次做版本升级,他们直接用 kill -9 重启进程,结果:
- 进程收到 KILL 信号后立即终止
- 正在处理中的 47 个支付回调请求全部被中断
- 支付宝回调处理失败后会重试,这 47 笔后来都通过重试补回来了
- 但有 3 个回调已经扣款成功,还没来得及写数据库就被杀死了,后来重试时重复扣款,导致 3 笔资金异常,客服处理了好几天
这是一次代价惨重的事故,根源是没有优雅关闭。
优雅关闭的含义是:进程收到停止信号后,不立即终止,而是先停止接收新请求,等待已经在处理中的请求完成,再退出。这个过程通常需要 10~30 秒。
二、Spring Boot 优雅关闭配置
Spring Boot 2.3+ 内置了优雅关闭支持,配置非常简单:
# application.yml
server:
shutdown: graceful # 开启优雅关闭(默认 immediate 立即关闭)
spring:
lifecycle:
# 等待请求处理完成的超时时间,超过这个时间强制退出
timeout-per-shutdown-phase: 30s仅这两行配置,Spring Boot 就能做到:
- 接收到
SIGTERM信号后,Tomcat/Netty 停止接收新的 HTTP 请求 - 等待正在处理中的请求完成
- 等待 Spring 容器中的
@PreDestroy方法执行完毕 - 超过 30 秒后,不等待强制退出
验证优雅关闭是否生效:
# 启动应用,发一个慢接口请求(比如 sleep 20 秒)
curl http://localhost:8080/slow-api &
# 同时发送 SIGTERM 信号
kill -TERM <pid>
# 观察:
# 1. 新请求应该立即被拒绝(502 或连接拒绝)
# 2. 正在执行的 sleep-20 请求应该继续处理完成
# 3. 20 秒后进程才退出三、完整的优雅关闭实现
除了内置的 HTTP 请求等待,实际项目里还需要处理异步任务、消息消费、线程池等。
package com.example.lifecycle;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.SmartLifecycle;
import org.springframework.stereotype.Component;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* 自定义生命周期组件,控制线程池的优雅关闭。
* SmartLifecycle 提供比 @PreDestroy 更精细的控制,
* 可以指定 phase(关闭顺序)和异步关闭支持。
*
* 关闭顺序建议:
* 1. 先摘除流量(HTTP 入口停止接收)
* 2. 再停线程池(等待异步任务完成)
* 3. 最后关闭 DB/Redis 连接
*/
@Component
public class AsyncExecutorLifecycle implements SmartLifecycle {
private static final Logger log = LoggerFactory.getLogger(AsyncExecutorLifecycle.class);
private final ThreadPoolExecutor asyncExecutor;
private volatile boolean running = false;
public AsyncExecutorLifecycle(ThreadPoolExecutor asyncExecutor) {
this.asyncExecutor = asyncExecutor;
}
@Override
public void start() {
running = true;
log.info("[Lifecycle] 异步执行器已启动");
}
@Override
public void stop(Runnable callback) {
log.info("[Lifecycle] 开始优雅停止异步执行器,当前队列任务数: {}",
asyncExecutor.getQueue().size());
running = false;
// 停止接收新任务
asyncExecutor.shutdown();
// 在新线程里等待,不阻塞 Spring 容器的关闭线程
Thread shutdownThread = new Thread(() -> {
try {
// 等待最多 30 秒让已提交的任务执行完
boolean terminated = asyncExecutor.awaitTermination(30, TimeUnit.SECONDS);
if (terminated) {
log.info("[Lifecycle] 异步执行器所有任务已完成,正常关闭");
} else {
log.warn("[Lifecycle] 等待超时,强制关闭异步执行器," +
"未完成任务数: {}", asyncExecutor.getQueue().size());
asyncExecutor.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
asyncExecutor.shutdownNow();
} finally {
// 通知 Spring 容器,这个组件已经关闭完毕
callback.run();
}
}, "executor-shutdown-thread");
shutdownThread.setDaemon(false);
shutdownThread.start();
}
@Override
public boolean isRunning() {
return running;
}
/**
* phase 值越小越先关闭。
* 建议:HTTP 入口(Tomcat)最先关闭,线程池次之,数据源最后。
* 默认 phase 是 Integer.MAX_VALUE(最后关闭),我们设置得早一点,
* 但在 HTTP 入口之后(Tomcat 的 phase 更低)。
*/
@Override
public int getPhase() {
return Integer.MAX_VALUE - 1000;
}
@Override
public boolean isAutoStartup() {
return true;
}
}四、Kubernetes 滚动更新的完整配置
单纯的 Spring Boot 优雅关闭还不够,在 Kubernetes 里做滚动更新时,还需要处理流量摘除的时序问题。
核心问题:Kubernetes 发送 SIGTERM 和从 Service 摘除 Endpoint 是几乎同时进行的,但 kube-proxy 更新 iptables 规则有延迟(通常 1~5 秒),这段时间里还会有新请求路由到即将关闭的 Pod,如果 Pod 收到 SIGTERM 后立即停止接收请求,这些请求会得到 502 错误。
解法:在 preStop Hook 里加延迟:
# k8s deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # 最多多起 1 个新 Pod
maxUnavailable: 0 # 不允许不可用 Pod(保证平滑)
template:
spec:
containers:
- name: order-service
image: order-service:latest
lifecycle:
preStop:
exec:
# 关键:在 SIGTERM 之前先等 15 秒
# 这 15 秒里 kube-proxy 完成 iptables 更新,停止路由新请求到本 Pod
# 之后再发 SIGTERM,Pod 里只剩已经在处理的请求
command: ["/bin/sh", "-c", "sleep 15"]
# Spring Boot 的优雅关闭超时
env:
- name: SPRING_LIFECYCLE_TIMEOUT_PER_SHUTDOWN_PHASE
value: "30s"
# terminationGracePeriodSeconds 必须 > preStop 等待时间 + 业务最长处理时间
# 建议:preStop sleep(15) + 业务最长处理(30) + 余量(15) = 60
terminationGracePeriodSeconds: 60
# 就绪探针:K8s 根据这个判断 Pod 是否可以接收流量
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 20 # 启动后 20 秒开始检查
periodSeconds: 5 # 每 5 秒检查一次
failureThreshold: 3 # 连续失败 3 次才摘除流量
# 存活探针:失败会重启 Pod
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 5关闭流程完整时序:
Kubernetes 触发滚动更新
↓ 新 Pod 就绪(readinessProbe 通过)
↓ 旧 Pod 开始终止流程
旧 Pod 终止流程:
T=0s: 执行 preStop Hook(sleep 15s)
此时 Pod 仍在接收流量(iptables 未更新)
T=5s: kube-proxy 完成 iptables 更新,不再路由新请求到旧 Pod
T=15s: preStop 完成,Kubernetes 发送 SIGTERM
T=15s: Spring Boot 收到 SIGTERM,停止 Tomcat 接收新连接,等待处理中请求
T=45s: 所有业务请求处理完毕(假设最慢 30 秒)
T=45s: Spring Boot 完成关闭,进程退出
T=45s: Pod 终止
(整个过程 < terminationGracePeriodSeconds=60,没有强制 kill)五、踩坑实录
坑1:terminationGracePeriodSeconds 设置小于实际需要
现象:滚动更新时偶发 502,日志里有 Graceful shutdown aborted with n request(s) still active。
原因:terminationGracePeriodSeconds 设置为 30 秒,但 preStop 已经用了 15 秒,Spring Boot 只剩 15 秒处理业务,有些慢请求还没处理完就被强制 kill。Kubernetes 在 terminationGracePeriodSeconds 到期后会发 SIGKILL,无论 Spring Boot 是否完成优雅关闭。
解法:计算公式:terminationGracePeriodSeconds >= preStop 时长 + Spring Boot 超时时长 + 安全余量。这个坑我也踩过,当时以为 Spring Boot 优雅关闭配好了就行,忽略了 K8s 的强制超时。
坑2:readinessProbe 响应太慢导致滚动更新期间服务不可用
现象:滚动更新期间,新 Pod 启动了,但 readinessProbe 一直 Fail,导致旧 Pod 没被摘除,同时新流量路由不进来,有几十秒的服务容量不足。
原因:服务启动时会做大量初始化(连接池预热、缓存预加载),导致 /actuator/health/readiness 响应慢,被 K8s 判定为 NotReady。
解法:调大 initialDelaySeconds(等服务充分启动再开始检查),调整健康检查的 failureThreshold,以及把 readiness 的检查逻辑和 liveness 检查分开(readiness 检查外部依赖,liveness 只检查进程存活)。
坑3:MQ 消费者没有纳入优雅关闭
现象:Spring Boot 优雅关闭了,但 MQ 消费者收到一半的消息就断开了,消息状态变成 NACK,被重新投递,导致消息重复消费。
原因:MQ 消费者(RabbitMQ/Kafka consumer)的关闭没有等待当前消息处理完成就断开了连接,Broker 认为消息未确认,重新投递。
解法:在消费者的 @PreDestroy 或 SmartLifecycle.stop() 里,先停止监听新消息(container.stop()),等待当前消息处理完,再关闭连接。RabbitMQ 的 SimpleMessageListenerContainer 和 Kafka 的 KafkaListenerEndpointRegistry 都支持优雅停止。
六、优雅关闭检查清单
上线前,对照这个清单确认每一项:
