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

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+ 内置了优雅停机支持,核心是两个配置项:

yaml 复制代码
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 配置,零代码:

yaml 复制代码
server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

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

4.2 线程池层

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

java 复制代码
@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 容器负责生命周期:

java 复制代码
@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;
    }
}

消费者注入并使用:

java 复制代码
@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 反注册

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

java 复制代码
@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,完整代码已在生产环境验证。


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

相关推荐
我可能是个假开发2 小时前
FTP Unicode 文件名导致 `550` 的问题分析与解决方案
java·开发语言·spring
caoerzhong2 小时前
中小企业上 WMS 该先上哪几块:JeeWMS 开源 Java 仓库管理系统的分批上线清单
java·python·开源
【JAVA】玩家3 小时前
Spring核心原理全解析:从零到生产实战
java·后端·spring
Wang's Blog4 小时前
Java框架 SpringCloud 快速入门: 实现 Feign 最佳实践(抽取方式)
java·spring cloud
MandalaO_O4 小时前
IDEA 开发(快捷键 + 调试 + 序列化)
java·ide·intellij-idea
xiaoqiMikko5 小时前
JVM 线上排查实战(七):jps 看不见它、jstack 连不上它,可它明明活得好好的
java·jvm
狼爷6 小时前
Rust/Go/Java/Python/PHP 大比拼:负载下后端框架到底差多少?
java·后端·编程语言
念何架构之路6 小时前
zap扩展生态与总结
java·前端·数据库
第七页独白7 小时前
汽车零件厂如何通过 QMS 真正落地 IATF 16949——QMS软件系统:品质检验-内审稽核-8d客诉管理:全星质量管理软件系统
java·前端·数据库
QCoding7 小时前
Spring AI Alibaba Graph实战:从ReAct Agent到Workflow,企业AI复杂流程该如何编排?
java·人工智能