Spring Boot 2.2.x 优雅停机实践指南
在微服务架构中,优雅停机是保障服务可靠性的重要一环。本文分享在 Spring Boot 2.2.x 版本中实现优雅停机的完整方案,涵盖 Nacos 反注册、Kafka 消费者停止、线程池等待等核心场景。
一、背景与问题
1.1 为什么需要优雅停机?
当服务收到停止信号(如 K8s Pod 滚动更新、手动发布等)时,如果直接终止,可能导致:
- 在途请求丢失:HTTP 请求处理到一半,连接被强制关闭
- 消息重复消费:Kafka 消费者正在处理消息,offset 未提交就被终止
- 服务发现延迟:注册中心未及时摘除实例,流量仍路由到已停止节点
- 线程池任务中断:异步任务执行到一半被强制终止
1.2 Spring Boot 版本的限制
Spring Boot 2.3.0 才内置 server.shutdown=graceful 配置,自动启用 Web 容器优雅停机。
但很多存量项目仍运行在 2.2.x 版本,需要自行实现优雅停机逻辑。
二、整体方案设计
2.1 设计原则
| 原则 | 说明 |
|---|---|
| 零侵入 | 不修改现有业务代码,通过新增组件实现 |
| 有开关 | 新功能可通过配置控制,默认关闭 |
| 有默认值 | 配置项有合理默认值,不配置时行为与之前一致 |
| 统一入口 | 所有停机逻辑收敛到一个 Handler,便于维护 |
2.2 停机顺序设计
kill -15 <pid>
│
▼
JVM 触发 SpringContextShutdownHook
│
▼
发布 ContextClosedEvent(早于 Bean 销毁)
│
├─ [GracefulShutdownHandler 监听 ★]
│ ① 设置运行标志为 false(通知消费者停止 poll)
│ ② 主动调用注册中心反注册(下游秒级感知)
│ ③ sleep(N秒)(等待上游服务感知下线)
│ 期间 HTTP 请求、异步任务、远程调用可继续完成
│
▼
Spring 依次销毁 Bean
├─ KafkaConsumer.close()
├─ ThreadPoolExecutor.shutdown()(等待任务完成)
├─ DataSource 连接池关闭
└─ Redis 连接池关闭
│
▼
Web 容器 stopping
│
▼
JVM 退出(exit code 0)
三、核心实现
3.1 GracefulShutdownHandler
监听 ContextClosedEvent,在 Spring 销毁 Bean 之前执行停机前处理:
java
@Slf4j
@Component
public class GracefulShutdownHandler implements ApplicationListener<ContextClosedEvent> {
private final AtomicBoolean executed = new AtomicBoolean(false);
@Autowired(required = false)
private NacosAutoServiceRegistration nacosAutoServiceRegistration;
@Override
public void onApplicationEvent(ContextClosedEvent event) {
// 防止 Feign 子上下文事件冒泡导致重复执行
if (!executed.compareAndSet(false, true)) {
log.info("[GracefulShutdownHandler] 重复事件,跳过。来源={}",
event.getApplicationContext().getDisplayName());
return;
}
log.info("[GracefulShutdownHandler] 收到 ContextClosedEvent,开始优雅停机...");
// 1. 设置全局运行标志
SystemConfig.RUNNING = false;
log.info("设置 RUNNING=false,通知消费者停止");
// 2. 主动 Nacos 反注册
if (nacosAutoServiceRegistration != null) {
log.info("Nacos 主动反注册...");
nacosAutoServiceRegistration.destroy();
log.info("Nacos 反注册完成");
}
// 3. 等待上游服务感知(Ribbon 缓存刷新周期约 30s)
int waitSeconds = 30;
log.info("等待 {} 秒,让上游服务感知下线...", waitSeconds);
ThreadUtil.sleep(waitSeconds * 1000L);
log.info("优雅停机预处理完成,交由 Spring 继续销毁 Bean");
}
}
3.2 关键点解析
3.2.1 为什么监听 ContextClosedEvent?
| 事件 | 触发时机 | 是否适合 |
|---|---|---|
ContextClosedEvent |
Bean 销毁之前 | ✅ 适合 |
PreDestroy |
Bean 销毁时 | ❌ 太晚,DataSource 可能已关闭 |
ShutdownHook |
JVM 退出时 | ❌ 太晚,Spring 容器已销毁 |
ContextClosedEvent 在 destroyBeans() 之前发布,此时所有 Bean 仍可用,可以安全执行反注册、等待等操作。
3.2.2 为什么需要 AtomicBoolean?
问题 :Spring Cloud 为每个 Feign Client 创建独立子 ApplicationContext。主上下文销毁子上下文时,每个子上下文都会发布 ContextClosedEvent 并冒泡到父上下文,导致 Handler 被触发 N 次。
JVM SIGTERM
└─ 主 context.doClose()
├─ publishEvent(ContextClosedEvent) ← Handler 第 1 次触发
└─ destroyBeans()
└─ FeignContext.destroy()
├─ 关闭子 ctx A → 事件冒泡 → Handler 第 2 次
├─ 关闭子 ctx B → 事件冒泡 → Handler 第 3 次
└─ ...
解决 :使用 AtomicBoolean 兜底,只执行第一次(主上下文事件最先到达)。
3.2.3 为什么 sleep 30 秒?
- Ribbon 缓存刷新周期:默认 30 秒,上游服务最多 30 秒后感知下线
- 在途请求完成:给正在处理的 HTTP 请求留出完成时间
- 异步任务收尾 :
@Async任务、定时任务可继续执行
四、Kafka 消费者优雅停止
4.1 两种消费者模式
| 模式 | 实现方式 | 停止方法 |
|---|---|---|
| 注解式 | @KafkaListener |
Spring 自动管理,无需额外处理 |
| 手动式 | new KafkaConsumer() + while 循环 |
需要检查 RUNNING 标志 |
4.2 手动消费者实现
java
public class ManualKafkaConsumer implements Runnable {
private final KafkaConsumer<String, String> consumer;
private volatile boolean running = true;
@Override
public void run() {
try {
while (running && SystemConfig.RUNNING) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
// 处理消息...
consumer.commitSync();
}
} finally {
consumer.close();
log.info("KafkaConsumer 已关闭");
}
}
public void shutdown() {
this.running = false;
}
}
关键点:
poll()超时设为 1 秒,RUNNING=false后最多延迟 1 秒退出finally块确保consumer.close()一定执行commitSync()在关闭前提交 offset,避免消息重复
五、线程池优雅关闭
5.1 Spring 托管线程池
推荐使用 Spring 的 ThreadPoolTaskExecutor,自动管理生命周期:
java
@Configuration
public class ThreadPoolConfig {
@Bean("businessExecutor")
public ThreadPoolTaskExecutor businessExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(100);
executor.setQueueCapacity(1000);
executor.setThreadNamePrefix("business-");
// 关键配置:等待任务完成
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(20); // 最多等待 20 秒
executor.initialize();
return executor;
}
}
| 配置项 | 说明 |
|---|---|
setWaitForTasksToCompleteOnShutdown(true) |
停机时等待已提交任务完成 |
setAwaitTerminationSeconds(20) |
最多等待 20 秒,超时强制中断 |
5.2 手动创建线程池
如果必须手动创建,需要在停机时显式调用 shutdown():
java
ExecutorService executor = new ThreadPoolExecutor(...);
// 停机时
executor.shutdown();
try {
if (!executor.awaitTermination(20, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
}
六、HTTP 请求保护(可选)
6.1 Servlet Filter 计数
如果服务有较高并发 HTTP 流量,可以通过 Filter 精确计数在途请求:
java
@Component
public class GracefulShutdownFilter implements Filter {
private final AtomicInteger activeRequests = new AtomicInteger(0);
private volatile boolean shuttingDown = false;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (shuttingDown) {
// 可选:拒绝新请求,返回 503
response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "Service is shutting down");
return;
}
activeRequests.incrementAndGet();
try {
chain.doFilter(request, response);
} finally {
activeRequests.decrementAndGet();
}
}
public void awaitRequestsCompletion(long timeoutMs) {
this.shuttingDown = true;
long deadline = System.currentTimeMillis() + timeoutMs;
while (activeRequests.get() > 0 && System.currentTimeMillis() < deadline) {
ThreadUtil.sleep(100);
}
}
}
6.2 与 Handler 配合
java
// 在 GracefulShutdownHandler 中
@Autowired(required = false)
private GracefulShutdownFilter shutdownFilter;
// 停机时
if (shutdownFilter != null) {
shutdownFilter.awaitRequestsCompletion(5000); // 等待在途请求完成
}
七、验证方式
7.1 日志校验
bash
# 检查停机日志
grep "GracefulShutdownHandler" server.log
# 期望输出:
# [GracefulShutdownHandler] 收到 ContextClosedEvent,开始优雅停机...
# 设置 RUNNING=false,通知消费者停止
# Nacos 主动反注册...
# Nacos 反注册完成
# 等待 30 秒,让上游服务感知下线...
# 优雅停机预处理完成,交由 Spring 继续销毁 Bean
7.2 触发次数校验
bash
# 确保只触发一次(无 Feign 冒泡)
grep -c "GracefulShutdownHandler.*收到" server.log
# 期望:1
7.3 整体停机时长
bash
grep -E "收到 ContextClosedEvent|all closed success" server.log
# 期望:两行时间差 ≈ 30s(sleep 时长)+ Bean 销毁时间
八、风险与注意事项
| 场景 | 行为 | 建议 |
|---|---|---|
kill -9 |
跳过所有 JVM 钩子,立即终止 | ❌ 禁止使用 |
| HTTP 在途请求 | sleep 后 DataSource 关闭,可能返回 500 | 启用 Filter 计数保护 |
| Feign 子上下文冒泡 | Handler 执行 N 次,停机卡住 | 加 AtomicBoolean 防护 |
| 消费者任务超时 | 线程池强制中断,可能丢失消息 | 评估业务耗时,调大 awaitTerminationSeconds |
| 需要立即关闭 | - | kill -15 正常触发,不要用 kill -9 |
九、总结
优雅停机的核心要点:
- 监听
ContextClosedEvent:在 Bean 销毁前执行预处理 - 主动反注册:让注册中心立即推送下线通知
- 设置运行标志:通知消费者停止 poll 循环
- 等待时间窗口:给上游服务感知下线的时间
- 线程池托管:让 Spring 自动管理线程池生命周期
- AtomicBoolean 防护:防止 Feign 子上下文事件冒泡
通过以上方案,可以在 Spring Boot 2.2.x 版本实现完整的优雅停机,保障服务在滚动更新、手动发布等场景下的数据安全和业务连续性。