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

写在前面

说实话,优雅停机这个知识点我见过太多人掉以轻心了。去年我们组一个新同事上线,直接kill -9干掉了线上进程,结果正在处理的200多笔支付请求全断了,用户钱扣了订单没生成,客服电话被打爆,第二天还被拉去复盘写了3000字检讨。从那以后,我逢人就说:优雅停机不是可选项,是线上服务的底线。这篇文章把我自己踩过的坑和实战经验全倒出来,希望能帮你守住这条底线。

文章目录


一、什么是不优雅停机?

1.1 血淋淋的线上事故

先讲个真事。某电商大促期间,运维同学发布新版本,直接执行:

bash 复制代码
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中加上这几行:

yaml 复制代码
server:
  shutdown: graceful  # 开启优雅停机

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s  # 每个停机阶段的超时时间

就这么简单,Spring Boot会自动:

  1. 停止接收新的HTTP请求
  2. 等待正在处理的请求完成(最多等30秒)
  3. 然后才关闭Web容器

3.2 完整application.yml配置

yaml 复制代码
server:
  port: 8080
  shutdown: graceful  # 关键配置:graceful或immediate

spring:
  application:
    name: order-service
  lifecycle:
    timeout-per-shutdown-phase: 30s  # 默认30秒
  datasource:
    url: jdbc:mysql://localhost:3306/order_db
    username: root
    password: root
    hikari:
      maximum-pool-size: 20
      connection-timeout: 30000
      # HikariCP本身也支持优雅关闭

# Actuator端点,用于健康检查
management:
  endpoints:
    web:
      exposure:
        include: health,info,shutdown
  endpoint:
    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

方法二:实际测试

bash 复制代码
# 1. 启动应用
java -jar order-service.jar &

# 2. 发送一个耗时请求(比如5秒的接口)
curl http://localhost:8080/api/slow &

# 3. 立刻发送SIGTERM信号
kill <pid>

# 4. 观察:耗时请求应该能正常返回,然后进程才退出

四、注册中心主动下线

4.1 为什么需要先下线?

Spring Boot的server.shutdown=graceful只解决了"不接收新HTTP请求",但如果你的服务还在注册中心挂着,负载均衡器还是会把请求路由过来,这时候请求过来发现连不上,直接报错。

所以正确的时序是:

复制代码
先下线(注册中心) → 再停机(应用进程)

4.2 Nacos服务主动下线

java 复制代码
import com.alibaba.nacos.api.NacosFactory;
import com.alibaba.nacos.api.naming.NamingService;
import com.alibaba.nacos.api.naming.pojo.Instance;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextClosedEvent;
import org.springframework.stereotype.Component;

import java.util.Properties;

/**
 * Nacos优雅下线处理器
 * 这个坑我踩过:如果不主动下线,Nacos缓存+负载均衡会导致请求仍路由到已停机实例
 */
@Slf4j
@Component
public class NacosGracefulShutdown implements ApplicationListener<ContextClosedEvent> {

    @Value("${spring.cloud.nacos.discovery.server-addr}")
    private String nacosServerAddr;

    @Value("${spring.application.name}")
    private String serviceName;

    @Value("${server.port}")
    private int port;

    @Override
    public void onApplicationEvent(ContextClosedEvent event) {
        log.info("【优雅停机】开始从Nacos下线服务: {}", serviceName);
        
        try {
            Properties properties = new Properties();
            properties.put("serverAddr", nacosServerAddr);
            
            NamingService namingService = NacosFactory.createNamingService(properties);
            
            Instance instance = new Instance();
            instance.setIp(getLocalIp());  // 获取本机IP
            instance.setPort(port);
            instance.setEphemeral(true);
            
            // 关键:主动注销实例
            namingService.deregisterInstance(serviceName, instance);
            
            log.info("【优雅停机】Nacos下线成功: {}:{}", instance.getIp(), port);
            
            // 给Nacos客户端和负载均衡器一点缓存刷新时间
            Thread.sleep(2000);
            
        } catch (Exception e) {
            log.error("【优雅停机】Nacos下线失败", e);
        }
    }
    
    private String getLocalIp() {
        try {
            return java.net.InetAddress.getLocalHost().getHostAddress();
        } catch (Exception e) {
            return "127.0.0.1";
        }
    }
}

4.3 Eureka服务下线

java 复制代码
import com.netflix.discovery.DiscoveryClient;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextClosedEvent;
import org.springframework.stereotype.Component;

/**
 * Eureka优雅下线处理器
 */
@Slf4j
@Component
public class EurekaGracefulShutdown implements ApplicationListener<ContextClosedEvent> {

    @Autowired
    private DiscoveryClient discoveryClient;

    @Override
    public void onApplicationEvent(ContextClosedEvent event) {
        log.info("【优雅停机】开始从Eureka下线服务");
        
        try {
            // Eureka客户端提供shutdown方法,会自动发送注销请求
            discoveryClient.shutdown();
            
            log.info("【优雅停机】Eureka下线成功");
            
            // 等待Eureka服务端和客户端缓存刷新(默认30秒)
            Thread.sleep(5000);
            
        } catch (Exception e) {
            log.error("【优雅停机】Eureka下线失败", e);
        }
    }
}

踩坑提醒 :Eureka的缓存机制很坑!服务下线后,Eureka Server默认30秒才刷新,其他客户端的缓存还要再拉取一次。所以即使下线了,请求仍可能路由过来一段时间。建议配合lease-expiration-duration-in-seconds调短,或者结合负载均衡器的健康检查。


五、自定义优雅停机逻辑

5.1 实现ApplicationListener

有时候Spring Boot内置的优雅停机不够用,比如你要关闭自定义的线程池、清理分布式锁、刷盘缓存数据等。这时候需要自定义停机逻辑。

java 复制代码
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextClosedEvent;
import org.springframework.stereotype.Component;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

/**
 * 自定义优雅停机处理器
 * 说实话,这个类在生产环境救过我很多次
 */
@Slf4j
@Component
public class CustomGracefulShutdown implements ApplicationListener<ContextClosedEvent> {

    private final ExecutorService businessExecutor;

    public CustomGracefulShutdown() {
        // 自定义业务线程池
        this.businessExecutor = Executors.newFixedThreadPool(10);
    }

    @Override
    public void onApplicationEvent(ContextClosedEvent event) {
        log.info("【优雅停机】开始执行自定义停机逻辑...");

        // 步骤1:标记系统正在停机,拒绝新任务(业务层面)
        SystemStatus.setShuttingDown(true);
        log.info("【优雅停机】系统状态已设置为SHUTTING_DOWN");

        // 步骤2:关闭自定义线程池
        shutdownExecutorGracefully(businessExecutor, "业务线程池", 30);

        // 步骤3:关闭其他资源(按依赖顺序)
        // 例如:刷盘本地缓存、释放分布式锁、发送统计消息等

        log.info("【优雅停机】自定义停机逻辑执行完毕");
    }

    /**
     * 优雅关闭线程池的标准写法
     * shutdown + awaitTermination 这个组合要记住
     */
    private void shutdownExecutorGracefully(ExecutorService executor, String name, int timeoutSeconds) {
        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 (InterruptedException e) {
            // 当前线程被中断,强制关闭
            executor.shutdownNow();
            Thread.currentThread().interrupt();
        }
        
        log.info("【优雅停机】{} 已关闭", name);
    }
}

5.2 系统状态管理

java 复制代码
import java.util.concurrent.atomic.AtomicBoolean;

/**
 * 系统状态管理器
 * 用于业务层面判断是否正在停机
 */
public class SystemStatus {

    private static final AtomicBoolean SHUTTING_DOWN = new AtomicBoolean(false);

    public static void setShuttingDown(boolean shuttingDown) {
        SHUTTING_DOWN.set(shuttingDown);
    }

    public static boolean isShuttingDown() {
        return SHUTTING_DOWN.get();
    }
}

5.3 在业务代码中使用

java 复制代码
@RestController
public class OrderController {

    @PostMapping("/api/order")
    public ResponseEntity<String> createOrder(@RequestBody OrderRequest request) {
        // 如果系统正在停机,直接拒绝新请求
        if (SystemStatus.isShuttingDown()) {
            return ResponseEntity.status(503)
                    .body("服务正在重启,请稍后重试");
        }
        
        // 正常业务逻辑...
        return ResponseEntity.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

yaml 复制代码
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      containers:
        - name: order-service
          image: registry/order-service:1.0.0
          ports:
            - containerPort: 8080
          
          # 关键配置1:健康检查
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 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=gracefulkill -9有什么关系?

server.shutdown=graceful只在收到SIGTERM 信号时生效(即普通的kill <pid>)。kill -9发送的是SIGKILL信号,进程无法捕获,会直接被操作系统强制终止,任何优雅停机逻辑都不会执行。所以生产环境发版千万别用kill -9

Q2:如果有个请求耗时很长,优雅停机超时时间到了还没处理完,怎么办?

这种情况进程会被强制关闭,请求会中断。解决方案:

  1. 合理设置超时时间(参考P99耗时)
  2. 接口设计层面避免超长耗时同步请求,大任务改为异步处理
  3. 如果是关键操作,客户端要有重试机制和幂等设计

Q3:注册中心下线和Spring Boot优雅停机的执行顺序怎么保证?

使用ApplicationListener<ContextClosedEvent>@PreDestroy注解,确保注册中心下线逻辑在Spring容器关闭之前执行。更保险的做法是用SmartLifecycle接口,设置phase值控制顺序,phase值越小越早执行。


九、面试高频考点汇总

考点1:Spring Boot优雅停机的配置是什么?

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

Spring Boot 2.3+内置支持,配置后会在收到SIGTERM时等待活跃请求完成再关闭Web容器。

考点2:SIGTERM和SIGKILL的区别?

信号 能否捕获 行为
SIGTERM 可以 通知进程"你该退出了",进程可以执行清理逻辑
SIGKILL 不可以 操作系统直接强制杀死进程,不给任何机会

生产环境发版只能用SIGTERM,绝对不能用SIGKILL。

考点3:线程池如何优雅关闭?

标准三步走:

java 复制代码
executor.shutdown();  // 不再接受新任务
executor.awaitTermination(30, TimeUnit.SECONDS);  // 等待任务完成
executor.shutdownNow();  // 超时则强制关闭

考点4:K8s中terminationGracePeriodSeconds的作用?

这是K8s给Pod的总优雅停机预算时间。从发送SIGTERM开始计时,如果到了这个时间进程还没退出,K8s会发送SIGKILL强制杀死。这个值必须大于PreStop执行时间 + 应用自身停机所需时间。

考点5:为什么注册中心下线后请求还会打过来?

因为各层都有缓存:

  • 注册中心服务端缓存
  • 消费者端的本地缓存(如Ribbon缓存列表)
  • 负载均衡器的健康检查缓存

所以下线后要预留几秒给缓存刷新,或者在PreStop里sleep一段时间。


十、模拟面试官提问

场景题1:你们线上发版是怎么做的?会不会丢请求?

参考答案:

我们发版流程是这样的:

  1. 先从注册中心(Nacos)主动下线实例
  2. 等待2-3秒,让上游服务和负载均衡器刷新服务列表
  3. 然后停止应用(发送SIGTERM)
  4. Spring Boot的graceful shutdown会等待活跃HTTP请求完成
  5. 同时自定义的ShutdownHook会关闭业务线程池、刷盘缓存数据
  6. 整个过程最多60秒,超时才会强制关闭
  7. 配合K8s的滚动更新策略,保证始终有可用实例

这套流程跑了一年多,没有因为发版导致请求中断的事故。

场景题2:如果你的应用依赖了一个第三方服务,停机前需要通知它吗?

参考答案:

这取决于具体的依赖类型。如果是注册中心,需要主动下线。如果是Webhook回调类的第三方,可以在PreStop里发送注销请求。但大多数情况下,下游服务应该通过健康检查机制感知到你的不可用,而不是依赖你主动通知。更好的做法是:所有调用方都要有熔断降级和重试策略。

场景题3:设计一个发版零停机的方案。

参考答案:

  1. 多实例部署:至少部署2个实例,保证发版时至少有一个在运行
  2. 滚动更新:K8s的RollingUpdate,逐个替换Pod
  3. 优雅停机:每个实例下线前执行完整的优雅停机流程
  4. 注册中心联动:实例停机前先从注册中心注销
  5. 负载均衡配合:健康检查确保流量不路由到正在下线的实例
  6. 数据库兼容性:新版本兼容老版本的数据库schema(或者先发版再改库)

场景题4:有个定时任务正在执行,这时候发版了怎么办?

参考答案:

定时任务也要纳入优雅停机的管理。我的做法是:

  1. 停机标记位:SystemStatus.setShuttingDown(true)
  2. 定时任务每次执行前检查标记位,如果正在停机则跳过本次执行
  3. 对于正在执行的定时任务,用线程池的awaitTermination等待它完成
  4. 如果任务耗时太长(比如几十分钟),考虑任务中断机制(Thread.interrupt)
  5. 或者把定时任务拆出来单独部署,和应用服务分开发版

场景题5:优雅停机超时了,但还有请求没完成,如何做到数据不丢?

参考答案:

这个问题要从多个层面解决:

  1. 接口幂等性:所有写操作都要保证幂等,客户端可以安全重试
  2. 事务控制:数据库操作在事务中,如果进程退出,未提交的事务会自动回滚
  3. 异步消息兜底:关键操作先写MQ,就算请求中断了,消费者还能继续处理
  4. 状态机设计:订单等核心业务用状态机管理,异常状态可以补偿处理
  5. 对账机制:每日对账发现不一致数据,自动补偿或告警人工处理

十一、互动话题

你在生产环境发版时,有没有因为"直接kill进程"踩过坑?你的团队现在的发版流程是怎样的?欢迎在评论区分享你的经历和解决方案,咱们一起交流怎么把线上事故降到最低。


十二、参考资料

  1. Spring Boot官方文档 - Graceful Shutdown
  2. Kubernetes官方文档 - Pod生命周期与终止

如果这篇文章对你有帮助,欢迎点赞收藏!关注我,持续输出Java后端实战经验。