求求你們別再用 kill -9 了,這才是 Spring Boot 停機(jī)的正確方式?。。?/h1>
兄弟們,咱今天聊個技術(shù)圈里的 “老生常談但總有人踩坑” 的話題 —— 給 Spring Boot 應(yīng)用停機(jī)。
先問大家一個靈魂拷問:你們公司運(yùn)維大哥停機(jī)的時候,是不是總喜歡甩一句 “kill -9 搞定!”?要是你點頭了,那可得趕緊把這篇文章甩給他看,不然哪天線上數(shù)據(jù)丟了、緩存崩了,哭都找不到地方。
說真的,kill -9 這操作,就跟你關(guān)電腦直接拔電源似的,看著挺痛快,后遺癥能讓你頭皮發(fā)麻。今天咱就掰開揉碎了說說,為啥這玩意兒不能瞎用,以及 Spring Boot 到底該咋停機(jī)才體面。
一、kill -9 的 “暴力美學(xué)” 有多坑?
先給新同學(xué)科普下,kill 命令后面帶的數(shù)字其實是信號編號。比如 kill -1 是刷新配置,kill -15 是溫柔告別,而 kill -9 呢,相當(dāng)于系統(tǒng)給進(jìn)程下了 “格殺勿論” 的圣旨 —— 管你在干啥,一秒鐘內(nèi)必須死,連收拾行李的時間都不給。
這在 Spring Boot 應(yīng)用里簡直是災(zāi)難現(xiàn)場:
- 正在寫數(shù)據(jù)庫的事務(wù)直接中斷,輕則數(shù)據(jù)不一致,重則表鎖死
- 緩存里的熱點數(shù)據(jù)還沒同步到磁盤,一殺全沒了
- 消息隊列里剛接的任務(wù)沒處理完,直接丟消息
- 連接池沒來得及釋放連接,數(shù)據(jù)庫連接數(shù)爆了
我去年就見過一個經(jīng)典案例:某電商平臺用 kill -9 停支付服務(wù),結(jié)果有筆訂單狀態(tài)卡在 “支付中”,用戶錢扣了但訂單沒生效。排查了三天才發(fā)現(xiàn),是事務(wù)沒提交就被強(qiáng)殺,最后只能人工對賬修復(fù),光加班費(fèi)就花了小兩萬。
更絕的是有回跟運(yùn)維吵架,他說 “kill -9 快啊,出問題我背鍋!” 結(jié)果一周后真出問題了,他連夜跑路,鍋還不是得我們開發(fā)扛?
二、Spring Boot 的 “體面告別” 機(jī)制
其實從 Spring Boot 2.3 版本開始,官方就內(nèi)置了 “優(yōu)雅停機(jī)” 功能,說白了就是讓應(yīng)用有機(jī)會 “臨死前” 整理好遺物。原理特簡單:收到停機(jī)信號后,先拒絕新請求,把正在處理的請求做完,最后清理資源。
就像餐館打烊:先掛出 “停止?fàn)I業(yè)” 的牌子(拒絕新客),等最后一桌客人吃完(處理完請求),再打掃衛(wèi)生鎖門(釋放資源)。
1. 基礎(chǔ)配置三板斧
在 application.yml 里加這幾行,就能開啟優(yōu)雅停機(jī):
server:
shutdown: graceful # 開啟優(yōu)雅停機(jī)
spring:
lifecycle:
timeout-per-shutdown-phase: 30s # 最大等待時間,超時就強(qiáng)制停這配置就像給應(yīng)用定了個 “臨終遺囑”:最多等 30 秒,沒處理完的就算了,別耗著。
2. 誰先死誰后死?由你說了算
復(fù)雜應(yīng)用里有各種組件,比如數(shù)據(jù)庫連接池、Redis 客戶端、消息消費(fèi)者,它們的關(guān)閉順序很重要。可以用 @Order 注解指定順序:
@Component
@Order(Ordered.HIGHEST_PRECEDENCE) # 最先關(guān)閉
public class RedisCleaner implements DisposableBean {
@Override
public void destroy() {
// 關(guān)閉Redis連接
System.out.println("Redis連接已清理");
}
}
@Component
@Order(Ordered.LOWEST_PRECEDENCE) # 最后關(guān)閉
public class DbCleaner implements DisposableBean {
@Override
public void destroy() {
// 關(guān)閉數(shù)據(jù)庫連接
System.out.println("數(shù)據(jù)庫連接已關(guān)閉");
}
}記住一個原則:依賴別人的先關(guān),被依賴的后關(guān)。就像下班時,先關(guān)電腦再關(guān)總閘,別搞反了。
三、不同場景的 “停機(jī)姿勢”
光有配置還不夠,不同部署方式得用不同的停機(jī)命令,這才是精髓。
1. 裸奔部署(直接 java -jar 啟動)
這種最常見,正確姿勢是先用 kill -15 發(fā)送終止信號:
# 找到進(jìn)程ID
ps -ef | grep java
# 溫柔告別
kill -15 12345 # 12345是你的進(jìn)程ID這時候應(yīng)用會乖乖執(zhí)行優(yōu)雅停機(jī)流程,控制臺會打?。?/p>
2023-10-10 15:30:00.123 INFO 12345 --- [ionShutdownHook] o.s.b.w.e.t.GracefulShutdownServlet : Commencing graceful shutdown. Waiting for active requests to complete
2023-10-10 15:30:05.456 INFO 12345 --- [ionShutdownHook] o.s.b.w.e.t.GracefulShutdownServlet : Graceful shutdown complete要是等了半天沒反應(yīng)(超過配置的 timeout),再用 kill -9 兜底,但這種情況一定要事后查日志,為啥會超時。
2. 容器化部署(Docker/K8s)
Docker 里別用 --rm 參數(shù)啟動,不然優(yōu)雅停機(jī)信號傳不進(jìn)去。正確的 Dockerfile 應(yīng)該加這行:
STOPSIGNAL SIGTERM # 告訴Docker用SIGTERM信號停機(jī),相當(dāng)于kill -15K8s 更簡單,它默認(rèn)就是發(fā) SIGTERM 信號,還會等 30 秒(可以通過 terminationGracePeriodSeconds 調(diào)整)。但有個坑:如果用了 liveness 探針,要確保探針不會在停機(jī)期間誤判應(yīng)用掛了,最好把探針超時設(shè)長點。
3. 用 systemd 管理的服務(wù)
很多 Linux 發(fā)行版用 systemd 管理服務(wù),配置文件里要加這行:
[Service]
ExecStop=/bin/kill -15 $MAINPID # 停止時發(fā)送15信號這樣執(zhí)行 systemctl stop your-service 時,就會觸發(fā)優(yōu)雅停機(jī)。
四、進(jìn)階技巧:給停機(jī)加道 “保險”
光靠框架自帶的機(jī)制還不夠,生產(chǎn)環(huán)境得加幾道 “防護(hù)網(wǎng)”。
1. 監(jiān)聽停機(jī)事件做特殊處理
有些臨界資源(比如分布式鎖),必須在停機(jī)時釋放,這時候可以監(jiān)聽 ContextClosedEvent 事件:
@Component
public class ShutdownListener {
@EventListener(ContextClosedEvent.class)
public void onShutdown() {
// 釋放分布式鎖
redissonClient.getLock("order:lock").unlock();
// 打印停機(jī)時間,方便排查問題
System.out.println("應(yīng)用于" + LocalDateTime.now() + "開始停機(jī)");
}
}這個事件會在所有 DisposableBean 執(zhí)行完后觸發(fā),相當(dāng)于 “最后遺言”。
2. 給 Web 服務(wù)器加層保護(hù)
如果用 Tomcat,可以配置連接器的關(guān)閉延遲:
server:
tomcat:
connection-timeout: 2s
graceful-shutdown: 30s # 等待連接處理的時間Nginx 層面也要配合,停機(jī)前先把應(yīng)用從負(fù)載均衡里摘掉,避免新請求進(jìn)來:
# 從Nginx upstream中移除
nginx -s reload # 假設(shè)配置里已經(jīng)注釋掉該節(jié)點
# 等30秒再停機(jī)
sleep 30
kill -15 123453. 異步任務(wù)的 “收尾工作”
用 @Async 注解的異步任務(wù),默認(rèn)情況下停機(jī)時會被強(qiáng)制中斷??梢宰远x線程池解決:
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setWaitForTasksToCompleteOnShutdown(true); # 等待任務(wù)完成
executor.setAwaitTerminationSeconds(30); # 最大等待時間
return executor;
}
}五、排坑指南:優(yōu)雅停機(jī)失敗的 N 種可能
就算配置都對,也可能翻車,這些坑我踩過不止一次:
- 死鎖導(dǎo)致無法停機(jī)
應(yīng)用里有死鎖的話,就算等再久也停不了??梢杂?jstack 命令查:
jstack 12345 > deadlock.log搜 "deadlock" 關(guān)鍵詞,找到互相等待的線程。
- 第三方庫不支持優(yōu)雅關(guān)閉
有些老的 SDK(比如某些數(shù)據(jù)庫驅(qū)動)不響應(yīng)關(guān)閉信號,得在代碼里手動調(diào)用它們的 close 方法。
- 超時設(shè)置不合理
如果大部分請求處理要 20 秒,卻把 timeout 設(shè)成 10 秒,肯定會超時??梢韵葔簻y看看 99% 響應(yīng)時間,再把超時設(shè)大 50%。
- 被監(jiān)控工具干擾
有些 APM 工具(比如某些鏈路追蹤)會注入代碼,可能影響停機(jī)流程??梢韵冉盟鼈兣挪?。
六、實戰(zhàn)案例:從血崩到絲滑
最后分享個真實案例:我們有個支付服務(wù),以前用 kill -9 停機(jī),每月至少出 2 次數(shù)據(jù)異常。改成優(yōu)雅停機(jī)后,半年零事故。
改造步驟就三步:
- 加優(yōu)雅停機(jī)配置(server.shutdown=graceful)
- 實現(xiàn) DisposableBean 清理緩存和連接
- 調(diào)整 Nginx 和 K8s 配置,實現(xiàn) “先摘流量再停機(jī)”
現(xiàn)在每次發(fā)布,控制臺都會乖乖打?。?/p>
2023-10-10 20:00:00.000 INFO --- [ionShutdownHook] c.m.payment.service.ShutdownService : 開始清理未完成訂單 2023-10-10 20:00:02.123 INFO --- [ionShutdownHook] c.m.payment.service.ShutdownService : 3筆訂單已補(bǔ)償完成 2023-10-10 20:00:03.456 INFO --- [ionShutdownHook] o.s.b.w.e.t.GracefulShutdownServlet : 優(yōu)雅停機(jī)完成看著就踏實。
總結(jié)一下
kill -9 就像急診室的除顫儀,只有在應(yīng)用徹底掛掉(無響應(yīng))時才能用,平時停機(jī)必須用優(yōu)雅方式。記住這幾句口訣:
- 配置先行:server.shutdown=graceful
- 命令用對:kill -15 不是 9
- 順序別亂:先關(guān)客戶端再關(guān)服務(wù)端
- 超時合理:留足處理時間
- 監(jiān)控跟上:看日志確認(rèn)是否成功































