1. 为什么需要优雅停服?
在微服务架构中,服务的启停是常态。想象一下这样的场景:你的SpringBoot应用正在处理一批重要订单,突然收到运维的停机指令。如果直接kill -9强制终止进程,会导致什么后果?
最直接的损失是正在处理的请求会被粗暴中断。比如用户支付成功的订单还没来得及落库,或者消息队列中的任务只消费了一半。更糟糕的是,数据库连接池中的事务可能处于中间状态,留下数据不一致的隐患。我曾经遇到过某电商系统在促销期间强制重启,导致库存数据出现负数的生产事故。
优雅停服(Graceful Shutdown)的核心思想是:当收到停止信号时,先拒绝新请求进入,同时给正在处理的请求留出完成时间,最后才释放资源退出。这就像餐厅打烊时,会先停止接待新顾客,但会让已入座的客人吃完最后一顿饭。
2. SpringBoot的停机机制剖析
2.1 生命周期管理基础
SpringBoot通过SpringApplicationShutdownHook注册JVM关闭钩子。当收到SIGTERM信号时(比如kill命令不带-9参数),会按顺序触发:
- 停止接收新请求(Tomcat/Jetty等容器关闭端口监听)
- 发布ContextClosedEvent事件
- 等待正在执行的请求完成(默认30秒)
- 销毁Bean、关闭线程池、释放数据库连接等资源
重要提示:kill -9(SIGKILL)会直接终止进程,所有钩子都不会执行!生产环境应该使用默认的kill命令(发送SIGTERM)。
2.2 关键配置参数
在application.properties中,这些参数控制着停机行为:
properties复制# 等待当前请求完成的超时时间(单位秒)
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s
# 是否允许强制关闭(超时后是否继续等待)
server.shutdown.grace-period.enabled=true
我曾经在一个支付网关项目中,将超时时间设置为60秒。因为某些银行接口响应较慢,30秒可能导致支付状态同步中断。这个值需要根据业务特点调整。
3. 实现优雅停服的三种姿势
3.1 基础版:启用Graceful Shutdown
最简单的配置方式:
java复制@SpringBootApplication
public class MyApp {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(MyApp.class);
app.setRegisterShutdownHook(true); // 默认就是true
app.run(args);
}
}
配合配置文件:
properties复制server.shutdown=graceful
这种方案适合大多数无状态服务。但要注意:
- 异步任务不会被自动等待
- 数据库连接池需要额外配置
3.2 进阶版:自定义ShutdownHook
对于有复杂资源管理的应用,可以自定义关闭逻辑:
java复制@Component
public class CustomShutdownHook implements ApplicationListener<ContextClosedEvent> {
@Autowired
private ThreadPoolTaskExecutor taskExecutor;
@Autowired
private DataSource dataSource;
@Override
public void onApplicationEvent(ContextClosedEvent event) {
// 1. 停止接收新任务
taskExecutor.shutdown();
// 2. 等待现有任务完成
try {
if(!taskExecutor.awaitTermination(60, TimeUnit.SECONDS)){
taskExecutor.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 3. 关闭数据库连接池
if(dataSource instanceof HikariDataSource) {
((HikariDataSource)dataSource).close();
}
}
}
3.3 终极版:健康检查+服务下线
在Kubernetes环境中,完整的优雅停服流程应该是:
- 从Service Endpoints中移除Pod(kube-proxy更新iptables规则)
- 等待存活探针失败(通常2-3秒)
- 发送SIGTERM信号
- 等待terminationGracePeriodSeconds(默认30秒)
- 强制终止(SIGKILL)
对应的SpringBoot配置:
properties复制management.endpoint.health.probes.enabled=true
management.health.livenessState.enabled=true
management.health.readinessState.enabled=true
4. 生产环境避坑指南
4.1 线程池的优雅关闭
很多开发者容易忽略线程池的关闭。我曾经排查过一个线上问题:某定时任务系统停机后,仍有线程在执行数据库操作,导致数据损坏。正确的做法是:
java复制@Bean(destroyMethod = "shutdown")
public ExecutorService taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(60);
return executor;
}
4.2 消息队列消费者的处理
对于RabbitMQ或Kafka消费者,需要在停机时确保:
- 提交当前offset
- 取消订阅
- 关闭连接
Spring的SmartLifecycle接口可以帮我们实现:
java复制@Component
public class MqConsumerLifecycle implements SmartLifecycle {
@Autowired
private KafkaListenerEndpointRegistry registry;
private volatile boolean running;
@Override
public void stop(Runnable callback) {
registry.getListenerContainers().forEach(container -> {
container.stop();
logger.info("Stopped container: " + container);
});
callback.run();
running = false;
}
}
4.3 分布式锁的释放
如果使用了Redis或Zookeeper的分布式锁,必须在停机时显式释放:
java复制@PreDestroy
public void releaseLocks() {
distributedLockRegistry.getAllLocks()
.forEach(lock -> {
if(lock.isLocked()) {
lock.unlock();
}
});
}
5. 验证与监控
5.1 测试优雅停服
使用curl模拟请求:
bash复制# 启动一个长时间运行的请求
curl http://localhost:8080/long-task &
# 发送停止信号
kill $(cat application.pid)
# 观察日志输出
tail -f application.log
预期应该看到:
- 立即返回503对新请求
- long-task请求继续执行直到完成
- 最后才关闭应用
5.2 关键指标监控
在Prometheus中监控这些指标:
http_server_connections_active:活跃连接数executor_pool_size:线程池大小jvm_memory_used:内存使用量
配置告警规则:
yaml复制- alert: ShutdownHung
expr: time() - process_start_time_seconds > 300
and rate(http_server_connections_active[1m]) == 0
for: 2m
labels:
severity: critical
annotations:
summary: "应用关闭卡住超过5分钟"
6. 高级场景应对
6.1 滚动升级时的流量切换
在Kubernetes中,可以通过readiness探针实现无缝切换:
yaml复制readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 20
periodSeconds: 5
failureThreshold: 3
SpringBoot的ReadinessState会在收到SIGTERM后自动变为REFUSING_TRAFFIC。
6.2 多模块应用的关闭顺序
对于包含多个SpringContext的复杂应用,需要控制关闭顺序:
java复制@Bean
public static DependencyOrderedShutdownHook dependencyOrderedShutdownHook() {
return new DependencyOrderedShutdownHook()
.addDependency("cacheManager", "jdbcTemplate")
.addDependency("messageProducer", "transactionManager");
}
6.3 与配置中心的配合
当使用Nacos/Consul时,应该在停机前主动注销服务:
java复制@PreDestroy
public void deregisterService() {
discoveryClient.shutdown();
log.info("Successfully deregistered from Nacos");
}
在SpringCloud项目中,通常已经自动集成此功能。
7. 性能优化技巧
7.1 并行关闭非依赖Bean
通过设置@DependsOn明确依赖关系后,可以开启并行关闭:
properties复制spring.lifecycle.shutdown-timeout-per-phase=30s
spring.lifecycle.shutdown.thread-pool-size=4
7.2 分阶段关闭策略
将关闭过程分为多个阶段,每个阶段设置不同超时:
java复制@Bean
public SmartLifecyclePhase shutdownPhases() {
return new SmartLifecyclePhase()
.addPhase(0, "外部服务", 10)
.addPhase(1, "业务组件", 20)
.addPhase(2, "基础设施", 5);
}
7.3 内存快照分析
在关闭超时时,可以自动生成堆转储:
properties复制management.endpoint.heapdump.enabled=true
spring.lifecycle.dump-heap-on-timeout=true
8. 常见问题解决方案
8.1 关闭过程卡住怎么办?
典型症状:
- 停机日志输出一半后停止
- 超过配置的超时时间仍未退出
排查步骤:
- 使用jstack查看线程状态
bash复制
jstack -l <pid> > thread.dump - 检查是否有线程处于WAITING或TIMED_WAITING状态
- 特别关注:
- 数据库连接池线程
- 文件IO操作
- 第三方服务调用
8.2 如何缩短停机时间?
优化方向:
- 减少长事务(将大事务拆分为小事务)
- 异步化耗时操作(如日志记录)
- 预热连接池(避免停机时首次建立连接)
8.3 SpringCloud特殊场景
在Feign+Ribbon环境中,需要额外处理:
properties复制ribbon.eager-load.enabled=true
feign.client.config.default.request-interceptor[0]=com.example.ShutdownHeaderInterceptor
对应的拦截器:
java复制public class ShutdownHeaderInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
if(ShutdownManager.isShuttingDown()) {
template.header("X-Shutdown-Mode", "true");
}
}
}
9. 从架构设计角度的思考
优雅停服不仅仅是技术实现,更需要从架构层面考虑:
- 无状态设计:尽可能使服务无状态,减少停机影响
- 请求幂等:重要操作要实现幂等,允许重复执行
- 分段提交:大事务拆分为多个可独立完成的小操作
- 补偿机制:对于无法立即完成的操作,要有补偿流程
我在设计金融系统时,采用这样的架构:
- 前端:请求超时后自动重试
- 网关:记录请求流水号,防止重复提交
- 服务:关键操作记录到事务日志表
- 定时任务:定期扫描补发失败请求
这样的架构下,即使偶尔强制停机,系统也能保持最终一致性。
