Java 微服务优雅停机:从踩坑到最佳实践

博客标签Java Spring Boot 微服务 优雅停机 Kafka
阅读时长:约 10 分钟


一、从一次半夜告警说起

凌晨两点,运维发来消息:

“刚刚发布了一个版本,线上有几十条订单状态异常,用户投诉了。”

排查之后发现,问题出在服务重启的那 3 秒——Kafka 消费者正在处理一批消息,kill 命令一下去,JVM 直接退出,消息处理到一半,数据库写了一半,状态就永远停在了中间态。

这就是没有优雅停机的代价。


二、什么是优雅停机?

优雅停机(Graceful Shutdown)是指:服务在接收到停止信号后,不立即强制退出,而是先完成正在处理的请求/任务,再有序释放资源,最后退出进程

对比两种停机方式:

方式 命令 行为 风险
强制停机 kill -9 <pid> 内核直接终止进程,JVM 无感知 数据丢失、状态不一致
优雅停机 kill -15 <pid> JVM 捕获 SIGTERM,触发 ShutdownHook 可控,推荐

kill -9 是 SIGKILL,操作系统层面强杀,任何代码钩子都无法拦截。生产环境禁止使用


三、Spring Boot 优雅停机原理

Spring Boot 2.3+ 内置了优雅停机支持,核心是两个配置项:

server:
  shutdown: graceful   # 启用优雅停机(默认 immediate)

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s  # 最大等待时间

触发流程如下:

kill -15
  │
  ▼
JVM ShutdownHook
  │
  ▼
Spring 发布 ContextClosedEvent
  │
  ▼
各组件有序销毁(HTTP容器 → Bean → 连接池)
  │
  ▼
JVM 退出

启用 server.shutdown=graceful 后,Tomcat/Undertow 会拒绝新请求,等待已有请求处理完毕,再关闭。


四、实战:三层资源的优雅关闭

生产环境中,一个微服务通常有三类需要优雅处理的资源:

4.1 HTTP 请求层

只需 YAML 配置,零代码:

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

Spring Boot 会自动为 Tomcat/Undertow 注入 GracefulShutdown 回调,停机时不再接收新请求,等待存量请求完成。

4.2 线程池层

业务线程池需要主动等待任务完成,否则线程池一关,正在跑的任务直接中断:

@Configuration
public class ThreadPoolConfig {

    @Bean(name = "bizExecutor")
    public ThreadPoolTaskExecutor bizExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(20);
        executor.setMaxPoolSize(50);
        executor.setQueueCapacity(200);
        executor.setThreadNamePrefix("biz-thread-");
        
        // 关键:停机时等待任务完成
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(20);  // 最多等 20s
        
        return executor;
    }
}

参数约束:awaitTerminationSeconds(20s)< timeout-per-shutdown-phase(30s),保证线程池有足够时间收尾。

4.3 Kafka 消费者层

Kafka 消费者是最容易踩坑的地方。以 @KafkaListener 为例:

问题:Spring Kafka 的消费者在收到停机信号后,会调用 consumer.wakeup() 中断 poll(),但正在业务线程池里处理的消息不会等待

解决方案:消费者业务线程池同样使用 Spring Bean 管理,让 Spring 容器负责生命周期:

@Configuration
public class ConsumerThreadPoolConfig {

    @Bean(name = "kafkaBizExecutor")
    public ThreadPoolTaskExecutor kafkaBizExecutor(
            @Value("${kafka.consumer.thread-pool-size:50}") int poolSize) {
        
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(poolSize);
        executor.setMaxPoolSize(poolSize);
        executor.setQueueCapacity(0);           // SynchronousQueue 语义,防止积压
        executor.setThreadNamePrefix("kafka-biz-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        
        // Spring 停机时自动 shutdown 并等待
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(20);
        
        return executor;
    }
}

消费者注入并使用:

@Component
@RequiredArgsConstructor
public class OrderConsumer {

    private final ThreadPoolTaskExecutor kafkaBizExecutor;

    @KafkaListener(topics = "order-topic", groupId = "order-group")
    public void onMessage(ConsumerRecord<String, String> record) {
        // 提交到业务线程池异步处理
        kafkaBizExecutor.submit(() -> handleOrder(record));
    }

    private void handleOrder(ConsumerRecord<String, String> record) {
        // 真正的业务逻辑
        log.info("处理订单消息: {}", record.value());
    }
}

五、微服务注册中心配合:Nacos 反注册

在微服务体系中,停机还需要配合注册中心下线,否则上游服务还会把请求打过来:

@Component
@Slf4j
public class GracefulShutdownHandler implements ApplicationListener<ContextClosedEvent> {

    private final NacosAutoServiceRegistration nacosRegistration;
    private final AtomicBoolean stopped = new AtomicBoolean(false);

    @Override
    public void onApplicationEvent(ContextClosedEvent event) {
        // AtomicBoolean 防止 Feign 子上下文事件冒泡重复触发
        if (!stopped.compareAndSet(false, true)) {
            return;
        }

        log.info("[优雅停机] 开始执行...");
        
        // 第一步:Nacos 取消注册,上游立即感知下线
        if (nacosRegistration.isRunning()) {
            nacosRegistration.destroy();
            log.info("[优雅停机] Nacos 反注册完成");
        }

        // 第二步:等待上游 Ribbon 缓存刷新(通常 30s 周期)
        log.info("[优雅停机] 等待 30s,让上游感知下线...");
        ThreadUtil.sleep(30_000);
        log.info("[优雅停机] 等待结束,进入 Bean 销毁阶段");
    }
}

为什么要 sleep 30s?
Ribbon 客户端的服务列表缓存默认 30s 刷新一次。Nacos 反注册后,上游不会立刻知道你下线了,这 30s 的等待是给上游缓存过期的时间窗口。


六、完整停机时序

kill -15
  │
  ▼
[GracefulShutdownHandler] 监听 ContextClosedEvent
  ├─ ① Nacos 反注册(上游秒级感知)
  └─ ② sleep(30s)(等待上游缓存刷新)
  │
  ▼
[Spring 自动销毁 Bean]
  ├─ kafkaBizExecutor.shutdown()(等待 ≤ 20s)
  ├─ bizExecutor.shutdown()(等待 ≤ 20s)
  ├─ DataSource 连接池关闭
  └─ Redis 连接池关闭
  │
  ▼
JVM 退出(全程约 30s + Bean 销毁时间)

七、常见踩坑汇总

原因 解决方案
kill -9 无效 SIGKILL 绕过 JVM 改用 kill -15
停机日志打印 N 遍 Feign 子上下文 ContextClosedEvent 冒泡 AtomicBoolean.compareAndSet 防重
线程池不等待 忘记设置 setWaitForTasksToCompleteOnShutdown(true) 见 §4.2
上游仍打流量 未做 Nacos 反注册 见 §5
Spring Boot 2.2 不支持 server.shutdown=graceful 该配置 2.3 才引入 自实现 ApplicationListener<ContextClosedEvent>

八、总结

优雅停机的核心是三层协同

  1. 容器层(HTTP):server.shutdown=graceful,拒绝新请求,等待存量完成
  2. 线程池层setWaitForTasksToCompleteOnShutdown=true,等待异步任务完成
  3. 注册中心层:主动 Nacos 反注册 + sleep,给上游感知时间

做到这三点,就能把停机风险从"数据损坏"降到"业务无感知"。

本文示例基于 Spring Boot 2.3+ + Spring Cloud Alibaba + Nacos,完整代码已在生产环境验证。


如果本文对你有帮助,欢迎点赞收藏!有问题欢迎评论区交流。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐