写在前面
说实话,优雅停机这个知识点我见过太多人掉以轻心了。去年我们组一个新同事上线,直接
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 实现ApplicationListener
- 5.2 系统状态管理
- 5.3 在业务代码中使用
- 六、Kubernetes中的优雅停机
- 6.1 K8s的Pod删除流程
- 6.2 K8s优雅停机配置YAML
- 七、踩坑指南
- 八、问题与解答
- 九、面试高频考点汇总
- 十、模拟面试官提问
- 十一、互动话题
- 十二、参考资料
一、什么是不优雅停机?
1.1 血淋淋的线上事故
先讲个真事。某电商大促期间,运维同学发布新版本,直接执行:
kill-9<pid>正在处理的支付请求瞬间被掐断,结果:
| 后果 | 具体表现 |
|---|---|
| 用户侧 | 钱扣了,订单没生成,页面显示"系统繁忙" |
| 业务侧 | 支付回调没收到,库存没扣,订单状态不一致 |
| 客服侧 | 投诉电话被打爆,当天工单量翻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信号kill<pid># 4. 观察:耗时请求应该能正常返回,然后进程才退出四、注册中心主动下线
4.1 为什么需要先下线?
Spring Boot的server.shutdown=graceful只解决了"不接收新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缓存+负载均衡会导致请求仍路由到已停机实例 */@Slf4j@ComponentpublicclassNacosGracefulShutdownimplementsApplicationListener<ContextClosedEvent>{@Value("${spring.cloud.nacos.discovery.server-addr}")privateStringnacosServerAddr;@Value("${spring.application.name}")privateStringserviceName;@Value("${server.port}")privateintport;@OverridepublicvoidonApplicationEvent(ContextClosedEventevent){log.info("【优雅停机】开始从Nacos下线服务: {}",serviceName);try{Propertiesproperties=newProperties();properties.put("serverAddr",nacosServerAddr);NamingServicenamingService=NacosFactory.createNamingService(properties);Instanceinstance=newInstance();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){return"127.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优雅下线处理器 */@Slf4j@ComponentpublicclassEurekaGracefulShutdownimplementsApplicationListener<ContextClosedEvent>{@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;/** * 自定义优雅停机处理器 * 说实话,这个类在生产环境救过我很多次 */@Slf4j@ComponentpublicclassCustomGracefulShutdownimplementsApplicationListener<ContextClosedEvent>{privatefinalExecutorServicebusinessExecutor;publicCustomGracefulShutdown(){// 自定义业务线程池this.businessExecutor=Executors.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_DOWN=newAtomicBoolean(false);publicstaticvoidsetShuttingDown(booleanshuttingDown){SHUTTING_DOWN.set(shuttingDown);}publicstaticbooleanisShuttingDown(){returnSHUTTING_DOWN.get();}}5.3 在业务代码中使用
@RestControllerpublicclassOrderController{@PostMapping("/api/order")publicResponseEntity<String>createOrder(@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优雅停机配置YAML
apiVersion: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"]# 关键配置3:terminationGracePeriodSeconds必须大于应用停机时间terminationGracePeriodSeconds:60# 默认30s,建议设置大一点resources:requests:memory:"512Mi"cpu:"500m"limits:memory:"1Gi"cpu:"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一段时间,给所有上游组件刷新状态的机会。
八、问题与解答
Q1:Spring Boot的server.shutdown=graceful和kill -9有什么关系?
server.shutdown=graceful只在收到SIGTERM信号时生效(即普通的kill <pid>)。kill -9发送的是SIGKILL信号,进程无法捕获,会直接被操作系统强制终止,任何优雅停机逻辑都不会执行。所以生产环境发版千万别用kill -9!
Q2:如果有个请求耗时很长,优雅停机超时时间到了还没处理完,怎么办?
这种情况进程会被强制关闭,请求会中断。解决方案:
- 合理设置超时时间(参考P99耗时)
- 接口设计层面避免超长耗时同步请求,大任务改为异步处理
- 如果是关键操作,客户端要有重试机制和幂等设计
Q3:注册中心下线和Spring Boot优雅停机的执行顺序怎么保证?
使用ApplicationListener<ContextClosedEvent>或@PreDestroy注解,确保注册中心下线逻辑在Spring容器关闭之前执行。更保险的做法是用SmartLifecycle接口,设置phase值控制顺序,phase值越小越早执行。
九、面试高频考点汇总
考点1:Spring Boot优雅停机的配置是什么?
server:shutdown:gracefulspring:lifecycle:timeout-per-shutdown-phase:30sSpring Boot 2.3+内置支持,配置后会在收到SIGTERM时等待活跃请求完成再关闭Web容器。
考点2:SIGTERM和SIGKILL的区别?
| 信号 | 能否捕获 | 行为 |
|---|---|---|
| SIGTERM | 可以 | 通知进程"你该退出了",进程可以执行清理逻辑 |
| SIGKILL | 不可以 | 操作系统直接强制杀死进程,不给任何机会 |
生产环境发版只能用SIGTERM,绝对不能用SIGKILL。
考点3:线程池如何优雅关闭?
标准三步走:
executor.shutdown();// 不再接受新任务executor.awaitTermination(30,TimeUnit.SECONDS);// 等待任务完成executor.shutdownNow();// 超时则强制关闭考点4:K8s中terminationGracePeriodSeconds的作用?
这是K8s给Pod的总优雅停机预算时间。从发送SIGTERM开始计时,如果到了这个时间进程还没退出,K8s会发送SIGKILL强制杀死。这个值必须大于PreStop执行时间 + 应用自身停机所需时间。
考点5:为什么注册中心下线后请求还会打过来?
因为各层都有缓存:
- 注册中心服务端缓存
- 消费者端的本地缓存(如Ribbon缓存列表)
- 负载均衡器的健康检查缓存
所以下线后要预留几秒给缓存刷新,或者在PreStop里sleep一段时间。
十、模拟面试官提问
场景题1:你们线上发版是怎么做的?会不会丢请求?
参考答案:
我们发版流程是这样的:
- 先从注册中心(Nacos)主动下线实例
- 等待2-3秒,让上游服务和负载均衡器刷新服务列表
- 然后停止应用(发送SIGTERM)
- Spring 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 Shutdown
- Kubernetes官方文档 - Pod生命周期与终止
如果这篇文章对你有帮助,欢迎点赞收藏!关注我,持续输出Java后端实战经验。