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 Boot2.3.0才内置server.shutdowngraceful配置自动启用 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 之前执行停机前处理Slf4jComponentpublicclassGracefulShutdownHandlerimplementsApplicationListenerContextClosedEvent{privatefinalAtomicBooleanexecutednewAtomicBoolean(false);Autowired(requiredfalse)privateNacosAutoServiceRegistrationnacosAutoServiceRegistration;OverridepublicvoidonApplicationEvent(ContextClosedEventevent){// 防止 Feign 子上下文事件冒泡导致重复执行if(!executed.compareAndSet(false,true)){log.info([GracefulShutdownHandler] 重复事件跳过。来源{},event.getApplicationContext().getDisplayName());return;}log.info([GracefulShutdownHandler] 收到 ContextClosedEvent开始优雅停机...);// 1. 设置全局运行标志SystemConfig.RUNNINGfalse;log.info(设置 RUNNINGfalse通知消费者停止);// 2. 主动 Nacos 反注册if(nacosAutoServiceRegistration!null){log.info(Nacos 主动反注册...);nacosAutoServiceRegistration.destroy();log.info(Nacos 反注册完成);}// 3. 等待上游服务感知Ribbon 缓存刷新周期约 30sintwaitSeconds30;log.info(等待 {} 秒让上游服务感知下线...,waitSeconds);ThreadUtil.sleep(waitSeconds*1000L);log.info(优雅停机预处理完成交由 Spring 继续销毁 Bean);}}3.2 关键点解析3.2.1 为什么监听 ContextClosedEvent事件触发时机是否适合ContextClosedEventBean 销毁之前✅ 适合PreDestroyBean 销毁时❌ 太晚DataSource 可能已关闭ShutdownHookJVM 退出时❌ 太晚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 两种消费者模式模式实现方式停止方法注解式KafkaListenerSpring 自动管理无需额外处理手动式new KafkaConsumer() while 循环需要检查 RUNNING 标志4.2 手动消费者实现publicclassManualKafkaConsumerimplementsRunnable{privatefinalKafkaConsumerString,Stringconsumer;privatevolatilebooleanrunningtrue;Overridepublicvoidrun(){try{while(runningSystemConfig.RUNNING){ConsumerRecordsString,Stringrecordsconsumer.poll(Duration.ofSeconds(1));// 处理消息...consumer.commitSync();}}finally{consumer.close();log.info(KafkaConsumer 已关闭);}}publicvoidshutdown(){this.runningfalse;}}关键点poll()超时设为 1 秒RUNNINGfalse后最多延迟 1 秒退出finally块确保consumer.close()一定执行commitSync()在关闭前提交 offset避免消息重复五、线程池优雅关闭5.1 Spring 托管线程池推荐使用 Spring 的ThreadPoolTaskExecutor自动管理生命周期ConfigurationpublicclassThreadPoolConfig{Bean(businessExecutor)publicThreadPoolTaskExecutorbusinessExecutor(){ThreadPoolTaskExecutorexecutornewThreadPoolTaskExecutor();executor.setCorePoolSize(10);executor.setMaxPoolSize(100);executor.setQueueCapacity(1000);executor.setThreadNamePrefix(business-);// 关键配置等待任务完成executor.setWaitForTasksToCompleteOnShutdown(true);executor.setAwaitTerminationSeconds(20);// 最多等待 20 秒executor.initialize();returnexecutor;}}配置项说明setWaitForTasksToCompleteOnShutdown(true)停机时等待已提交任务完成setAwaitTerminationSeconds(20)最多等待 20 秒超时强制中断5.2 手动创建线程池如果必须手动创建需要在停机时显式调用shutdown()ExecutorServiceexecutornewThreadPoolExecutor(...);// 停机时executor.shutdown();try{if(!executor.awaitTermination(20,TimeUnit.SECONDS)){executor.shutdownNow();}}catch(InterruptedExceptione){executor.shutdownNow();}六、HTTP 请求保护可选6.1 Servlet Filter 计数如果服务有较高并发 HTTP 流量可以通过 Filter 精确计数在途请求ComponentpublicclassGracefulShutdownFilterimplementsFilter{privatefinalAtomicIntegeractiveRequestsnewAtomicInteger(0);privatevolatilebooleanshuttingDownfalse;OverridepublicvoiddoFilter(ServletRequestrequest,ServletResponseresponse,FilterChainchain)throwsIOException,ServletException{if(shuttingDown){// 可选拒绝新请求返回 503response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE,Service is shutting down);return;}activeRequests.incrementAndGet();try{chain.doFilter(request,response);}finally{activeRequests.decrementAndGet();}}publicvoidawaitRequestsCompletion(longtimeoutMs){this.shuttingDowntrue;longdeadlineSystem.currentTimeMillis()timeoutMs;while(activeRequests.get()0System.currentTimeMillis()deadline){ThreadUtil.sleep(100);}}}6.2 与 Handler 配合// 在 GracefulShutdownHandler 中Autowired(requiredfalse)privateGracefulShutdownFiltershutdownFilter;// 停机时if(shutdownFilter!null){shutdownFilter.awaitRequestsCompletion(5000);// 等待在途请求完成}七、验证方式7.1 日志校验# 检查停机日志grepGracefulShutdownHandlerserver.log# 期望输出# [GracefulShutdownHandler] 收到 ContextClosedEvent开始优雅停机...# 设置 RUNNINGfalse通知消费者停止# Nacos 主动反注册...# Nacos 反注册完成# 等待 30 秒让上游服务感知下线...# 优雅停机预处理完成交由 Spring 继续销毁 Bean7.2 触发次数校验# 确保只触发一次无 Feign 冒泡grep-cGracefulShutdownHandler.*收到server.log# 期望17.3 整体停机时长grep-E收到 ContextClosedEvent|all closed successserver.log# 期望两行时间差 ≈ 30ssleep 时长 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 版本实现完整的优雅停机保障服务在滚动更新、手动发布等场景下的数据安全和业务连续性。参考资料Spring Boot Graceful ShutdownSpring Cloud Feign Context 生命周期Kafka Consumer 关闭最佳实践