1. SpringBoot优雅停服的必要性与挑战
在分布式系统架构中,服务的启停看似简单,实则暗藏玄机。粗暴地终止SpringBoot应用可能导致数据不一致、事务中断、连接泄漏等问题。我曾亲历过一个线上事故:某金融系统在夜间发布时直接kill -9进程,导致当天20%的支付订单状态卡在"处理中",最终不得不人工介入修复。
1.1 何为优雅停服
优雅停服(Graceful Shutdown)指应用在收到停止信号后:
- 拒绝新请求进入
- 完成已接收请求的处理
- 释放占用的资源(数据库连接、线程池等)
- 持久化必要状态
- 最后安全退出
这与直接kill进程的最大区别在于:前者像绅士礼貌告别,后者像突然拔掉电源。
1.2 典型问题场景
| 问题类型 | 直接kill后果 | 优雅停服效果 |
|---|---|---|
| HTTP请求 | 客户端收到连接重置 | 完成已接收请求处理 |
| 数据库事务 | 事务中断导致数据不一致 | 等待事务提交/回滚 |
| 消息队列消费 | 消息重复消费或丢失 | 提交当前offset后再停止 |
| 线程池任务 | 任务被强制中断 | 等待任务完成或超时 |
| 缓存数据 | 未持久化的数据丢失 | 触发持久化机制 |
2. SpringBoot优雅停服实现方案
2.1 基础配置方案
在application.properties中添加:
properties复制# 启用优雅停机
server.shutdown=graceful
# 设置等待超时时间(默认30s)
spring.lifecycle.timeout-per-shutdown-phase=60s
这个配置会让SpringBoot在收到SIGTERM信号时:
- 关闭Server端口停止接收新请求
- 等待正在处理的请求完成
- 如果超时仍有请求未完成,强制终止
注意:该功能需要Spring Boot 2.3+版本
2.2 高级定制方案
对于更复杂场景,可以自定义ShutdownHook:
java复制@SpringBootApplication
public class MyApp {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(MyApp.class);
app.setRegisterShutdownHook(false); // 禁用默认Hook
ConfigurableApplicationContext ctx = app.run(args);
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
// 1. 先关闭健康检查端点
((ConfigurableWebServerFactory) ctx.getBean(
ConfigurableWebServerFactory.class)).setRegisterDefaultServlet(false);
// 2. 关闭消息监听
ctx.getBean(RabbitListenerEndpointRegistry.class).stop();
// 3. 等待业务线程完成
ctx.getBean(ThreadPoolTaskExecutor.class).shutdown();
try {
if(!threadPool.awaitTermination(60, TimeUnit.SECONDS)) {
threadPool.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 4. 最后关闭Spring上下文
ctx.close();
}));
}
}
2.3 各组件优雅停服策略
2.3.1 Web服务器(Tomcat/Undertow)
yaml复制server:
shutdown: graceful
tomcat:
threadpool:
# 线程优雅退出等待时间
graceful-shutdown-timeout: 30s
# 是否等待活动请求完成
graceful-shutdown-wait-for-active-requests: true
2.3.2 数据库连接池
对于HikariCP:
properties复制# 关闭时等待连接返回池中
spring.datasource.hikari.allow-pool-suspension=true
# 最大等待时间
spring.datasource.hikari.connection-timeout=30000
2.3.3 消息队列消费者
RabbitMQ示例:
java复制@PreDestroy
public void destroy() {
// 1. 取消消费者标签
channel.basicCancel(consumerTag);
// 2. 等待未确认消息处理
while(unackedMessages.get() > 0) {
Thread.sleep(500);
}
// 3. 关闭通道和连接
channel.close();
connection.close();
}
3. 生产环境最佳实践
3.1 Kubernetes中的部署策略
在K8s部署时,需要配置正确的生命周期钩子:
yaml复制spec:
containers:
- name: app
lifecycle:
preStop:
exec:
command:
- /bin/sh
- -c
- "curl -X POST http://localhost:8080/actuator/shutdown"
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
3.2 停机时序控制
理想停机流程:
- 从负载均衡摘除节点(等待10s)
- 发送SIGTERM信号
- 等待优雅停机超时(建议30-60s)
- 如果仍未停止,发送SIGKILL
3.3 监控与告警
建议监控以下指标:
- 停机耗时
- 被中断的请求数
- 未完成的事务数
- 资源释放情况
Prometheus配置示例:
yaml复制- name: spring_graceful_shutdown
rules:
- alert: GracefulShutdownTimeout
expr: time() - spring_application_shutdown_start_time_seconds > 60
for: 1m
labels:
severity: warning
annotations:
summary: "应用优雅停机超时 (instance {{ $labels.instance }})"
description: "应用 {{ $labels.instance }} 优雅停机已超过60秒"
4. 常见问题排查
4.1 停机时间过长
可能原因:
- 有长时间运行的请求(如文件导出)
- 数据库事务未及时提交
- 线程池未正确配置
解决方案:
java复制@Bean
public TomcatConnectorCustomizer connectorCustomizer() {
return connector -> {
connector.setProperty("relaxedQueryChars", "|{}[]");
connector.setProperty("relaxedPathChars", "|{}[]");
// 设置连接超时
connector.setProperty("connectionTimeout", "5000");
};
}
4.2 资源未正确释放
典型症状:
- 数据库连接泄漏
- 文件句柄未关闭
- Socket泄漏
排查工具:
bash复制# 检查文件描述符
lsof -p <PID>
# 检查线程状态
jstack <PID>
# 检查堆内存
jmap -histo:live <PID>
4.3 与第三方组件集成问题
当使用Spring Cloud组件时,需要额外处理:
java复制@PreDestroy
public void deregister() {
// 1. 从注册中心注销
discoveryClient.shutdown();
// 2. 关闭熔断器
circuitBreakerRegistry.close();
// 3. 停止配置刷新
contextRefresher.stop();
}
5. 进阶技巧
5.1 分批停机策略
对于多实例部署,可采用分批次停机:
- 第一批停机50%实例
- 等待2分钟
- 再停机剩余50%
实现代码:
java复制@Scheduled(fixedDelay = 60000)
public void checkShutdownPhase() {
if(isShuttingDown) {
int activeInstances = discoveryClient.getInstances()
.stream()
.filter(i -> i.getStatus() == Status.UP)
.count();
if(activeInstances > minInstances) {
initiateShutdown();
}
}
}
5.2 状态保存与恢复
在停机前保存状态:
java复制public class StatePreserver implements ApplicationListener<ContextClosedEvent> {
@Override
public void onApplicationEvent(ContextClosedEvent event) {
// 保存缓存状态
cacheManager.getCacheNames()
.forEach(name ->
redisTemplate.opsForValue()
.set("cache_backup:"+name,
cacheManager.getCache(name)));
// 保存会话数据
sessionRepository.saveAll(activeSessions);
}
}
5.3 测试方案
使用Testcontainers进行集成测试:
java复制@Testcontainers
class GracefulShutdownTest {
@Container
static GenericContainer<?> app = new GenericContainer<>("myapp:latest")
.withExposedPorts(8080)
.waitingFor(Wait.forHttp("/actuator/health"));
@Test
void testShutdown() throws Exception {
// 发送大量请求
IntStream.range(0, 100).parallel()
.forEach(i -> sendRequest());
// 触发停机
app.getDockerClient()
.killContainerCmd(app.getContainerId())
.withSignal("SIGTERM")
.exec();
// 验证日志输出
assertThat(app.getLogs())
.contains("Graceful shutdown completed");
}
}
在实际项目中,我们通过这套方案将停机导致的业务异常从每月3-5次降为零。关键点在于:理解业务场景,合理设置超时时间,做好组件间的停机顺序协调,以及充分的测试验证。
