【Java项目技术亮点】优雅停机Graceful Shutdown

📅 2026/7/1 3:13:21
【Java项目技术亮点】优雅停机Graceful Shutdown
写在前面说实话优雅停机这个知识点我见过太多人掉以轻心了。去年我们组一个新同事上线直接kill -9干掉了线上进程结果正在处理的200多笔支付请求全断了用户钱扣了订单没生成客服电话被打爆第二天还被拉去复盘写了3000字检讨。从那以后我逢人就说优雅停机不是可选项是线上服务的底线。这篇文章把我自己踩过的坑和实战经验全倒出来希望能帮你守住这条底线。文章目录一、什么是不优雅停机1.1 血淋淋的线上事故1.2 生活类比餐厅打烊1.3 优雅停机的核心价值二、优雅停机的核心步骤2.1 六步走流程2.2 流程图三、Spring Boot 2.3优雅停机实现3.1 官方内置支持3.2 完整application.yml配置3.3 如何验证优雅停机生效四、注册中心主动下线4.1 为什么需要先下线4.2 Nacos服务主动下线4.3 Eureka服务下线五、自定义优雅停机逻辑5.1 实现ApplicationListener5.2 系统状态管理5.3 在业务代码中使用六、Kubernetes中的优雅停机6.1 K8s的Pod删除流程6.2 K8s优雅停机配置YAML七、踩坑指南八、问题与解答九、面试高频考点汇总十、模拟面试官提问十一、互动话题十二、参考资料一、什么是不优雅停机1.1 血淋淋的线上事故先讲个真事。某电商大促期间运维同学发布新版本直接执行kill-9pid正在处理的支付请求瞬间被掐断结果后果具体表现用户侧钱扣了订单没生成页面显示系统繁忙业务侧支付回调没收到库存没扣订单状态不一致客服侧投诉电话被打爆当天工单量翻5倍技术侧凌晨3点被叫起来修数据修了6个小时这就是典型的不优雅停机——进程死得太快正在处理的请求全成了孤儿。1.2 生活类比餐厅打烊想象你去餐厅吃饭不优雅打烊服务员直接关灯、赶客你嘴里还叼着半块牛排就被轰出去了。优雅打烊门口挂出不再接待新客的牌子但已经在吃的客人慢慢吃完、结账、离开最后服务员再收拾关门。优雅停机的本质就是这个逻辑先拒绝新请求再让老请求体面地走完。1.3 优雅停机的核心价值┌─────────────────────────────────────────────────┐ │ 优雅停机的三大核心价值 │ ├─────────────────────────────────────────────────┤ │ 1. 零停机发布 → 用户无感知发版 │ │ 2. 不丢请求 → 正在处理的请求完整执行完 │ │ 3. 数据不丢 → 数据库事务正常提交/回滚 │ └─────────────────────────────────────────────────┘二、优雅停机的核心步骤2.1 六步走流程一个完整的优雅停机我总结为六个步骤步骤动作目的1停止接收新请求关闭监听端口或从注册中心下线2等待正在处理的请求完成设置超时时间如30秒3关闭线程池等待线程池中的任务执行完毕4关闭数据库连接池释放数据库连接资源5释放其他资源Redis连接、MQ连接、文件句柄等6退出进程真正结束进程2.2 流程图收到停机信号 (SIGTERM) │ ▼ ┌───────────────┐ │ 从注册中心 │ │ 主动下线 │ └───────┬───────┘ │ ▼ ┌───────────────┐ │ 停止接收新请求 │ ← 关闭HTTP端口 / 负载均衡摘除 └───────┬───────┘ │ ▼ ┌───────────────┐ │ 等待活跃请求 │ ← 设置超时如30s │ 处理完成 │ └───────┬───────┘ │ ▼ ┌───────────────┐ │ 关闭线程池 │ ← shutdown awaitTermination └───────┬───────┘ │ ▼ ┌───────────────┐ │ 关闭连接池 │ ← DB / Redis / MQ └───────┬───────┘ │ ▼ ┌───────────────┐ │ 释放资源 │ └───────┬───────┘ │ ▼ 进程退出关键点步骤1和步骤2的顺序不能反。必须先让上游知道你不要新请求了再等老的走完。三、Spring Boot 2.3优雅停机实现3.1 官方内置支持Spring Boot从2.3版本开始内置了优雅停机支持配置极其简单。在application.yml中加上这几行server:shutdown:graceful# 开启优雅停机spring:lifecycle:timeout-per-shutdown-phase:30s# 每个停机阶段的超时时间就这么简单Spring Boot会自动停止接收新的HTTP请求等待正在处理的请求完成最多等30秒然后才关闭Web容器3.2 完整application.yml配置server:port:8080shutdown:graceful# 关键配置graceful或immediatespring:application:name:order-servicelifecycle:timeout-per-shutdown-phase:30s# 默认30秒datasource:url:jdbc:mysql://localhost:3306/order_dbusername:rootpassword:roothikari:maximum-pool-size:20connection-timeout:30000# HikariCP本身也支持优雅关闭# Actuator端点用于健康检查management:endpoints:web:exposure:include:health,info,shutdownendpoint:shutdown:enabled:true# 开启shutdown端点可选3.3 如何验证优雅停机生效方法一看日志当你发送SIGTERM信号比如kill pid注意不是-9你应该能看到类似日志2024-01-15 14:32:10.123 INFO 12345 --- [extShutdownHook] o.s.b.w.e.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete 2024-01-15 14:32:10.456 INFO 12345 --- [tomcat-shutdown] o.s.b.w.e.tomcat.GracefulShutdown : Graceful shutdown complete方法二实际测试# 1. 启动应用java-jarorder-service.jar# 2. 发送一个耗时请求比如5秒的接口curlhttp://localhost:8080/api/slow# 3. 立刻发送SIGTERM信号killpid# 4. 观察耗时请求应该能正常返回然后进程才退出四、注册中心主动下线4.1 为什么需要先下线Spring Boot的server.shutdowngraceful只解决了不接收新HTTP请求但如果你的服务还在注册中心挂着负载均衡器还是会把请求路由过来这时候请求过来发现连不上直接报错。所以正确的时序是先下线注册中心 → 再停机应用进程4.2 Nacos服务主动下线importcom.alibaba.nacos.api.NacosFactory;importcom.alibaba.nacos.api.naming.NamingService;importcom.alibaba.nacos.api.naming.pojo.Instance;importlombok.extern.slf4j.Slf4j;importorg.springframework.beans.factory.annotation.Value;importorg.springframework.context.ApplicationListener;importorg.springframework.context.event.ContextClosedEvent;importorg.springframework.stereotype.Component;importjava.util.Properties;/** * Nacos优雅下线处理器 * 这个坑我踩过如果不主动下线Nacos缓存负载均衡会导致请求仍路由到已停机实例 */Slf4jComponentpublicclassNacosGracefulShutdownimplementsApplicationListenerContextClosedEvent{Value(${spring.cloud.nacos.discovery.server-addr})privateStringnacosServerAddr;Value(${spring.application.name})privateStringserviceName;Value(${server.port})privateintport;OverridepublicvoidonApplicationEvent(ContextClosedEventevent){log.info(【优雅停机】开始从Nacos下线服务: {},serviceName);try{PropertiespropertiesnewProperties();properties.put(serverAddr,nacosServerAddr);NamingServicenamingServiceNacosFactory.createNamingService(properties);InstanceinstancenewInstance();instance.setIp(getLocalIp());// 获取本机IPinstance.setPort(port);instance.setEphemeral(true);// 关键主动注销实例namingService.deregisterInstance(serviceName,instance);log.info(【优雅停机】Nacos下线成功: {}:{},instance.getIp(),port);// 给Nacos客户端和负载均衡器一点缓存刷新时间Thread.sleep(2000);}catch(Exceptione){log.error(【优雅停机】Nacos下线失败,e);}}privateStringgetLocalIp(){try{returnjava.net.InetAddress.getLocalHost().getHostAddress();}catch(Exceptione){return127.0.0.1;}}}4.3 Eureka服务下线importcom.netflix.discovery.DiscoveryClient;importlombok.extern.slf4j.Slf4j;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.context.ApplicationListener;importorg.springframework.context.event.ContextClosedEvent;importorg.springframework.stereotype.Component;/** * Eureka优雅下线处理器 */Slf4jComponentpublicclassEurekaGracefulShutdownimplementsApplicationListenerContextClosedEvent{AutowiredprivateDiscoveryClientdiscoveryClient;OverridepublicvoidonApplicationEvent(ContextClosedEventevent){log.info(【优雅停机】开始从Eureka下线服务);try{// Eureka客户端提供shutdown方法会自动发送注销请求discoveryClient.shutdown();log.info(【优雅停机】Eureka下线成功);// 等待Eureka服务端和客户端缓存刷新默认30秒Thread.sleep(5000);}catch(Exceptione){log.error(【优雅停机】Eureka下线失败,e);}}}踩坑提醒Eureka的缓存机制很坑服务下线后Eureka Server默认30秒才刷新其他客户端的缓存还要再拉取一次。所以即使下线了请求仍可能路由过来一段时间。建议配合lease-expiration-duration-in-seconds调短或者结合负载均衡器的健康检查。五、自定义优雅停机逻辑5.1 实现ApplicationListener有时候Spring Boot内置的优雅停机不够用比如你要关闭自定义的线程池、清理分布式锁、刷盘缓存数据等。这时候需要自定义停机逻辑。importlombok.extern.slf4j.Slf4j;importorg.springframework.context.ApplicationListener;importorg.springframework.context.event.ContextClosedEvent;importorg.springframework.stereotype.Component;importjava.util.concurrent.ExecutorService;importjava.util.concurrent.Executors;importjava.util.concurrent.TimeUnit;/** * 自定义优雅停机处理器 * 说实话这个类在生产环境救过我很多次 */Slf4jComponentpublicclassCustomGracefulShutdownimplementsApplicationListenerContextClosedEvent{privatefinalExecutorServicebusinessExecutor;publicCustomGracefulShutdown(){// 自定义业务线程池this.businessExecutorExecutors.newFixedThreadPool(10);}OverridepublicvoidonApplicationEvent(ContextClosedEventevent){log.info(【优雅停机】开始执行自定义停机逻辑...);// 步骤1标记系统正在停机拒绝新任务业务层面SystemStatus.setShuttingDown(true);log.info(【优雅停机】系统状态已设置为SHUTTING_DOWN);// 步骤2关闭自定义线程池shutdownExecutorGracefully(businessExecutor,业务线程池,30);// 步骤3关闭其他资源按依赖顺序// 例如刷盘本地缓存、释放分布式锁、发送统计消息等log.info(【优雅停机】自定义停机逻辑执行完毕);}/** * 优雅关闭线程池的标准写法 * shutdown awaitTermination 这个组合要记住 */privatevoidshutdownExecutorGracefully(ExecutorServiceexecutor,Stringname,inttimeoutSeconds){log.info(【优雅停机】开始关闭{}...,name);// 第一步优雅关闭不再接受新任务等待队列中的任务执行完executor.shutdown();try{// 第二步等待一段时间让现有任务完成if(!executor.awaitTermination(timeoutSeconds,TimeUnit.SECONDS)){// 超时了强制关闭log.warn(【优雅停机】{} 等待超时强制关闭,name);executor.shutdownNow();// 再给一次机会等待任务响应中断if(!executor.awaitTermination(5,TimeUnit.SECONDS)){log.error(【优雅停机】{} 强制关闭失败,name);}}}catch(InterruptedExceptione){// 当前线程被中断强制关闭executor.shutdownNow();Thread.currentThread().interrupt();}log.info(【优雅停机】{} 已关闭,name);}}5.2 系统状态管理importjava.util.concurrent.atomic.AtomicBoolean;/** * 系统状态管理器 * 用于业务层面判断是否正在停机 */publicclassSystemStatus{privatestaticfinalAtomicBooleanSHUTTING_DOWNnewAtomicBoolean(false);publicstaticvoidsetShuttingDown(booleanshuttingDown){SHUTTING_DOWN.set(shuttingDown);}publicstaticbooleanisShuttingDown(){returnSHUTTING_DOWN.get();}}5.3 在业务代码中使用RestControllerpublicclassOrderController{PostMapping(/api/order)publicResponseEntityStringcreateOrder(RequestBodyOrderRequestrequest){// 如果系统正在停机直接拒绝新请求if(SystemStatus.isShuttingDown()){returnResponseEntity.status(503).body(服务正在重启请稍后重试);}// 正常业务逻辑...returnResponseEntity.ok(success);}}六、Kubernetes中的优雅停机6.1 K8s的Pod删除流程K8s删除Pod时会走这个流程用户执行 kubectl delete pod │ ▼ API Server标记Pod为Terminating │ ▼ Kubelet收到通知 │ ├── 同时执行两件事 ────┐ │ │ ▼ ▼ 执行PreStop Hook 发送SIGTERM给容器主进程 │ │ │ │ ▼ ▼ Hook执行完毕 进程收到SIGTERM开始优雅停机 │ │ └───────┬─────────────┘ │ ▼ 等待 terminationGracePeriodSeconds默认30s │ ▼ 时间到了进程还没退出 │ ├── 是 → 发送SIGKILL强制杀死 │ └── 否 → 进程自己退出Pod删除完成6.2 K8s优雅停机配置YAMLapiVersion:apps/v1kind:Deploymentmetadata:name:order-servicespec:replicas:3selector:matchLabels:app:order-servicetemplate:metadata:labels:app:order-servicespec:containers:-name:order-serviceimage:registry/order-service:1.0.0ports:-containerPort:8080# 关键配置1健康检查livenessProbe:httpGet:path:/actuator/health/livenessport:8080initialDelaySeconds:30periodSeconds:10readinessProbe:httpGet:path:/actuator/health/readinessport:8080initialDelaySeconds:10periodSeconds:5# 关键配置2优雅停机时间lifecycle:preStop:exec:# PreStop Hook从注册中心下线command:[/bin/sh,-c,curl -X POST http://localhost:8080/actuator/shutdown || true; sleep 5]# 关键配置3terminationGracePeriodSeconds必须大于应用停机时间terminationGracePeriodSeconds:60# 默认30s建议设置大一点resources:requests:memory:512Micpu:500mlimits:memory:1Gicpu:1000m关键理解terminationGracePeriodSeconds是K8s给你的总预算时间包括了PreStop执行时间 应用收到SIGTERM后的停机时间。如果你的应用最多需要40秒PreStop需要5秒那这个值至少要设50秒以上。七、踩坑指南坑1优雅停机超时时间设置不合理太长影响发布速度一次发版等1分钟太短请求没完成就被kill。我的经验普通Web服务30秒有大量异步任务的60秒。根据P99接口耗时来定。坑2异步任务未纳入优雅停机管理只关了Web容器但后台线程池还在跑任务结果进程退出任务被中断。记得把所有线程池都纳入管理用统一的shutdown方法。坑3注册中心缓存导致请求仍路由到已下线实例这坑我踩过Nacos/Eureka都有缓存下线后其他服务不一定立刻感知。解决方案下线后sleep几秒简单粗暴但有效缩短注册中心心跳和缓存时间配合负载均衡器的健康检查坑4负载均衡器的健康检查延迟即使注册中心下线了如果前面还有Nginx/SLB做负载均衡它的健康检查周期可能是5秒甚至更长。建议PreStop里先sleep一段时间给所有上游组件刷新状态的机会。八、问题与解答Q1Spring Boot的server.shutdowngraceful和kill -9有什么关系server.shutdowngraceful只在收到SIGTERM信号时生效即普通的kill pid。kill -9发送的是SIGKILL信号进程无法捕获会直接被操作系统强制终止任何优雅停机逻辑都不会执行。所以生产环境发版千万别用kill -9Q2如果有个请求耗时很长优雅停机超时时间到了还没处理完怎么办这种情况进程会被强制关闭请求会中断。解决方案合理设置超时时间参考P99耗时接口设计层面避免超长耗时同步请求大任务改为异步处理如果是关键操作客户端要有重试机制和幂等设计Q3注册中心下线和Spring Boot优雅停机的执行顺序怎么保证使用ApplicationListenerContextClosedEvent或PreDestroy注解确保注册中心下线逻辑在Spring容器关闭之前执行。更保险的做法是用SmartLifecycle接口设置phase值控制顺序phase值越小越早执行。九、面试高频考点汇总考点1Spring Boot优雅停机的配置是什么server:shutdown:gracefulspring:lifecycle:timeout-per-shutdown-phase:30sSpring Boot 2.3内置支持配置后会在收到SIGTERM时等待活跃请求完成再关闭Web容器。考点2SIGTERM和SIGKILL的区别信号能否捕获行为SIGTERM可以通知进程你该退出了进程可以执行清理逻辑SIGKILL不可以操作系统直接强制杀死进程不给任何机会生产环境发版只能用SIGTERM绝对不能用SIGKILL。考点3线程池如何优雅关闭标准三步走executor.shutdown();// 不再接受新任务executor.awaitTermination(30,TimeUnit.SECONDS);// 等待任务完成executor.shutdownNow();// 超时则强制关闭考点4K8s中terminationGracePeriodSeconds的作用这是K8s给Pod的总优雅停机预算时间。从发送SIGTERM开始计时如果到了这个时间进程还没退出K8s会发送SIGKILL强制杀死。这个值必须大于PreStop执行时间 应用自身停机所需时间。考点5为什么注册中心下线后请求还会打过来因为各层都有缓存注册中心服务端缓存消费者端的本地缓存如Ribbon缓存列表负载均衡器的健康检查缓存所以下线后要预留几秒给缓存刷新或者在PreStop里sleep一段时间。十、模拟面试官提问场景题1你们线上发版是怎么做的会不会丢请求参考答案我们发版流程是这样的先从注册中心Nacos主动下线实例等待2-3秒让上游服务和负载均衡器刷新服务列表然后停止应用发送SIGTERMSpring Boot的graceful shutdown会等待活跃HTTP请求完成同时自定义的ShutdownHook会关闭业务线程池、刷盘缓存数据整个过程最多60秒超时才会强制关闭配合K8s的滚动更新策略保证始终有可用实例这套流程跑了一年多没有因为发版导致请求中断的事故。场景题2如果你的应用依赖了一个第三方服务停机前需要通知它吗参考答案这取决于具体的依赖类型。如果是注册中心需要主动下线。如果是Webhook回调类的第三方可以在PreStop里发送注销请求。但大多数情况下下游服务应该通过健康检查机制感知到你的不可用而不是依赖你主动通知。更好的做法是所有调用方都要有熔断降级和重试策略。场景题3设计一个发版零停机的方案。参考答案多实例部署至少部署2个实例保证发版时至少有一个在运行滚动更新K8s的RollingUpdate逐个替换Pod优雅停机每个实例下线前执行完整的优雅停机流程注册中心联动实例停机前先从注册中心注销负载均衡配合健康检查确保流量不路由到正在下线的实例数据库兼容性新版本兼容老版本的数据库schema或者先发版再改库场景题4有个定时任务正在执行这时候发版了怎么办参考答案定时任务也要纳入优雅停机的管理。我的做法是停机标记位SystemStatus.setShuttingDown(true)定时任务每次执行前检查标记位如果正在停机则跳过本次执行对于正在执行的定时任务用线程池的awaitTermination等待它完成如果任务耗时太长比如几十分钟考虑任务中断机制Thread.interrupt或者把定时任务拆出来单独部署和应用服务分开发版场景题5优雅停机超时了但还有请求没完成如何做到数据不丢参考答案这个问题要从多个层面解决接口幂等性所有写操作都要保证幂等客户端可以安全重试事务控制数据库操作在事务中如果进程退出未提交的事务会自动回滚异步消息兜底关键操作先写MQ就算请求中断了消费者还能继续处理状态机设计订单等核心业务用状态机管理异常状态可以补偿处理对账机制每日对账发现不一致数据自动补偿或告警人工处理十一、互动话题你在生产环境发版时有没有因为直接kill进程踩过坑你的团队现在的发版流程是怎样的欢迎在评论区分享你的经历和解决方案咱们一起交流怎么把线上事故降到最低。十二、参考资料Spring Boot官方文档 - Graceful ShutdownKubernetes官方文档 - Pod生命周期与终止如果这篇文章对你有帮助欢迎点赞收藏关注我持续输出Java后端实战经验。