10、优雅关闭模式:信号处理、关闭钩子与连接排空
各位同学,今天我们来聊聊一个看似简单、实则暗藏杀机的话题——优雅关闭。
说白了,就是你的服务正在跑着,突然有人喊“停”,你怎么体面地退场?
我见过太多系统,一停就丢数据、断连接、报异常。嗯,今天我们就把它彻底讲透。
为什么需要优雅关闭?
想象一下这个场景:你的电商系统正在处理一笔订单,支付请求刚发出去,结果运维手抖按了个重启。订单状态卡在中间,用户钱扣了货没发——这就是非优雅关闭的后果。
我个人习惯把优雅关闭比作“下班前的收拾工位”。你不能电脑一关就走人,得保存文件、关闭数据库连接、通知同事你走了。服务也一样。
信号处理:SIGTERM 与 SIGINT
在 Linux 世界里,关闭服务通常通过信号来完成。最常用的两个信号是:
- SIGTERM(15):礼貌地请求进程终止。可以捕获并处理。
- SIGINT(2):通常来自 Ctrl+C。也可以捕获。
- SIGKILL(9):暴力终止。无法捕获,无法处理。尽量别用。
我在项目中遇到过,运维图省事直接 kill -9,结果数据库连接池没释放,导致连接泄漏。后来我强制要求:先发 SIGTERM,等 30 秒,不行再 SIGKILL。
核心原则:能捕获的信号,一定要捕获并处理。不能捕获的(SIGKILL),做好幂等和恢复。
Java 中的关闭钩子(Shutdown Hook)
Java 提供了一种机制叫关闭钩子。说白了,就是在 JVM 关闭前,让你注册一个线程去打扫战场。
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("开始优雅关闭...");
// 关闭连接池
// 等待正在处理的任务完成
// 释放资源
System.out.println("关闭完成");
}));
你想想看,这个钩子会在什么时候触发?
- 程序正常退出(最后一个非守护线程结束)
- 收到 SIGTERM 或 SIGINT
- 调用 System.exit()
注意:关闭钩子不会在 SIGKILL 下执行。也不会在 JVM 崩溃时执行。所以它只是一个“尽力而为”的机制。
我曾经踩过一个坑:在关闭钩子里调用了远程服务,结果远程服务已经挂了,导致钩子线程卡死,JVM 关不掉。后来我加了超时控制:
Thread hook = new Thread(() -> {
// 最多等 10 秒
try {
executor.awaitTermination(10, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Runtime.getRuntime().addShutdownHook(hook);
连接排空:让请求安全落地
优雅关闭的核心,其实是“连接排空”。什么意思?就是不再接收新请求,但让正在处理的请求走完。
我一般分三步走:
- 停止接收新连接:关闭端口监听,或者让负载均衡器摘掉本节点。
- 等待正在处理的请求完成:设置一个超时时间,比如 30 秒。
- 强制关闭剩余连接:超时后,还没完成的请求直接断开。
你想想看,如果第二步不做,直接强制关闭,用户那边就会看到“连接被重置”。如果第二步无限等待,服务就永远关不掉。
我的经验值:超时时间设为 30 秒比较合理。太短(5 秒)很多请求来不及;太长(120 秒)运维等得着急。
Spring Boot 的优雅关闭
Spring Boot 2.3 之后,内置了优雅关闭支持。配置起来非常简单:
# application.yml
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
就这么两行,Spring Boot 就会在收到 SIGTERM 时:
- 停止接收新的 HTTP 请求
- 等待正在处理的请求完成(最多 30 秒)
- 然后才销毁 Bean、关闭容器
我记得第一次用这个配置时,还特意写了个压测脚本验证。结果发现,配置前关闭会丢约 5% 的请求,配置后一个都不丢。效果立竿见影。
更复杂的场景:数据库连接与消息队列
光关 HTTP 还不够。你的服务可能还连着数据库、Redis、消息队列。这些也得优雅关闭。
我一般这样处理:
| 资源类型 | 关闭策略 | 注意事项 |
|---|---|---|
| 数据库连接池 | 先标记为不可用,等待正在执行的事务完成,再关闭连接 | HikariCP 默认支持优雅关闭 |
| Redis 连接 | 直接关闭,Redis 操作通常很快 | 注意未完成的异步操作 |
| 消息队列消费者 | 停止拉取新消息,处理完当前消息后提交 offset | Kafka 的 close() 会等待处理完成 |
| 线程池 | 调用 shutdown() 然后 awaitTermination() | 设置合理的超时时间 |
一句话总结:优雅关闭的本质,就是“先断粮,再打扫,最后关门”。
整体流程:一张图说清楚
下面这张图,是我自己总结的优雅关闭流程。你看一遍就能记住:
避坑指南
最后,分享几个我亲身踩过的坑:
- 我曾经在关闭钩子里打印日志,结果日志框架自己先关了,导致空指针。后来我改用 System.out 或者先检查日志状态。
- 我曾经忘记设置超时,结果一个慢查询拖了 5 分钟,服务一直关不掉。运维直接 kill -9,数据丢了。现在我的所有关闭逻辑都带超时。
- 我曾经在多个地方注册关闭钩子,结果执行顺序混乱。后来统一在一个地方管理所有关闭逻辑。
我的建议:把优雅关闭当成一个“功能”来设计,而不是事后补丁。从架构层面就考虑好:哪些资源需要排空?超时多久?失败怎么办?
好了,优雅关闭就讲到这里。记住那三个字:断粮、打扫、关门。下次你的服务再被重启,就不会手忙脚乱了。