Spring Boot 2.2.x 优雅停机实践指南

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 容器已销毁

ContextClosedEventdestroyBeans() 之前发布,此时所有 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

九、总结

优雅停机的核心要点:

  1. 监听 ContextClosedEvent:在 Bean 销毁前执行预处理
  2. 主动反注册:让注册中心立即推送下线通知
  3. 设置运行标志:通知消费者停止 poll 循环
  4. 等待时间窗口:给上游服务感知下线的时间
  5. 线程池托管:让 Spring 自动管理线程池生命周期
  6. AtomicBoolean 防护:防止 Feign 子上下文事件冒泡

通过以上方案,可以在 Spring Boot 2.2.x 版本实现完整的优雅停机,保障服务在滚动更新、手动发布等场景下的数据安全和业务连续性。


参考资料

相关推荐
宸津-代码粉碎机1 小时前
Spring AI企业级RAG进阶|文档智能分片调优、ES深度整合、接口限流熔断监控生产实战
java·开发语言·人工智能·后端·spring·elasticsearch·oracle
JustHappy10 小时前
古法编程秘籍(二):什么是代码模块化?别背概念,把房间收拾明白就够了
前端·后端
小江的记录本11 小时前
【JVM虚拟机】堆内存分代模型:年轻代(Eden+Survivor)、老年代、元空间Metaspace(附《思维导图》+《面试高频考点清单》)
java·前端·jvm·后端·python·spring·面试
逍遥德12 小时前
MQTT教程详解-04.SpringBoot集成MQTT(告别手动控制)
java·spring boot·物联网·中间件·iot·iotdb
IT_陈寒14 小时前
Python闭包里藏的这个坑,差点让我加班到凌晨
前端·人工智能·后端
IT_陈寒14 小时前
Java注解空指针?这个坑我踩得莫名其妙
前端·人工智能·后端
土狗TuGou15 小时前
SQL内功笔记 · 第8篇:事务的四大特性与隔离级别
数据库·笔记·后端·sql·mysql·oracle
ZengLiangYi15 小时前
React Query + REST API 最佳实践
javascript·后端·react.js
星浩AI15 小时前
项目实战:合同智能审批 · LangGraph + HITL 人机协同方案 [有源码]
后端·langchain·agent