生產(chǎn)事故-Caffeine緩存誤用之臨下班的救贖
入職多年,面對生產(chǎn)環(huán)境,盡管都是小心翼翼,慎之又慎,還是難免捅出簍子。輕則滿頭大汗,面紅耳赤。重則系統(tǒng)停擺,損失資金。每一個生產(chǎn)事故的背后,都是寶貴的經(jīng)驗和教訓(xùn),都是項目成員的血淚史。為了更好地防范和遏制今后的各類事故,特開此專題,長期更新和記錄大大小小的各類事故。有些是親身經(jīng)歷,有些是經(jīng)人耳傳口授,但無一例外都是真實案例。
注意:為了避免不必要的麻煩和商密問題,文中提到的特定名稱都將是化名、代稱。
0x00 大綱
0x01 事故背景
2025年7月9日17時有余,筆者正準(zhǔn)備結(jié)束疲憊的一天,關(guān)機(jī)走人之時,桌面右下角安靜了一天的內(nèi)部通訊軟件圖標(biāo)突然亮起,內(nèi)心頓感不妙……打開一看,原來是運維小哥找過來了,說是某接口服務(wù)連續(xù)多次調(diào)用超時或失敗,觸發(fā)告警閾值,具體原因不明,請求支援。(臨下班出事似乎已成為一種規(guī)律)
0x02 事故分析
該服務(wù)是一個基于 SpringBoot + JDK 1.8 的 API 服務(wù),提供了幾個信息查詢接口,沒有復(fù)雜的業(yè)務(wù)邏輯,也不涉及第三方接口調(diào)用,僅依賴于數(shù)據(jù)庫進(jìn)行簡單的 CURD 操作。
第一時間讓運維拷貝和固定了事故系統(tǒng)日志及生產(chǎn)版本應(yīng)用包。發(fā)現(xiàn)該服務(wù)在一周前升級部署過,不排除是版本升級引起的問題。于是先留一手,招呼運維小哥做好隨時進(jìn)行版本回退的準(zhǔn)備,以免不能及時修復(fù)問題。
運維小哥已經(jīng)排除了是網(wǎng)絡(luò)和線路的問題,也嘗試按照常見故障應(yīng)對手冊重啟過應(yīng)用,服務(wù)短暫恢復(fù)正常,但是隨著請求壓力上來以后,又會頻繁失敗觸發(fā)告警。為了避免事故進(jìn)一步擴(kuò)大,運維小哥選擇迅速搖人。
秉著先易后難的順序,先快速掃描了一遍應(yīng)用日志,常規(guī)日志未見明顯ERROR、WARN以及Exception等信息,SQL日志未見慢查詢和連接池異常。隨后檢查數(shù)據(jù)庫壓力,發(fā)現(xiàn)數(shù)據(jù)庫活躍連接數(shù)不高,也未見死鎖和異常會話。
jps找到服務(wù)進(jìn)程對應(yīng)的PID,使用top命令查看進(jìn)程的資源占用情況,發(fā)現(xiàn)服務(wù)的 CPU 和內(nèi)存資源占用不高。ss -antp|grep :9999| wc -l查看對應(yīng)端口的連接情況,大約兩百多個活躍連接,屬于正常范圍內(nèi)。磁盤監(jiān)控未見明顯壓力,看來基本可以確定是應(yīng)用本身的問題。
于是使用jstack -l保存了第一次線程快照,然后讓運維小哥重啟接口服務(wù),果然如小哥所說,接口調(diào)用短暫正常以后很快又出現(xiàn)異常。為了排除偶然因素干擾,這時做了第二次線程快照用于對照分析,同時使用jmap抓取了 dump 文件備用。完成以上步驟以后,果斷讓運維小哥將服務(wù)回退到歷史版本,應(yīng)急解決故障。
仔細(xì)分析兩次抓取的線程快照,發(fā)現(xiàn)大量的線程處于BLOCKED狀態(tài),且擁有高度相似的調(diào)用棧:
"thread-3197" Id=4959 BLOCKED on java.util.concurrent.ConcurrentHashMap$ReservationNode@1b1f101f owned by "TaskExecutor-827" Id=936
at java.util.concurrent.ConcurrentHashMap.compute(ConcurrentHashMap.java:1868)
- blocked on java.util.concurrent.ConcurrentHashMap$ReservationNode@1b1f101f
at com.github.benmanes.caffeine.cache.BoundedLocalCache.doComputeIfAbsent(BoundedLocalCache.java:2404)
at com.github.benmanes.caffeine.cache.BoundedLocalCache.computeIfAbsent(BoundedLocalCache.java:2387)
at com.github.benmanes.caffeine.cache.LocalCache.computeIfAbsent(LocalCache.java:108)
at com.github.benmanes.caffeine.cache.LocalLoadingCache.get(LocalLoadingCache.java:56)
(這里省略部分信息)
看起來是高并發(fā)的時候 Caffeine 緩存的處理出現(xiàn)了競態(tài)爭搶,問題初步定位,需要進(jìn)一步分析事故原因。
0x03 事故原因
簡單 review 了一下變更的代碼,發(fā)現(xiàn)同事A為某個關(guān)鍵系統(tǒng)參數(shù)的查詢添加了秒級的短時緩存,減少高并發(fā)下的數(shù)據(jù)庫查詢調(diào)用,并且使用有界的LoadingCache來加載和刷新相關(guān)數(shù)據(jù),關(guān)鍵的Bean定義如下:
@Bean
@ConditionalOnBean(ParameterRepository.class)
public LoadingCache<String, ParameterEntity> parameterCache(ParameterRepository parameterRepository,
Executor refreshExecutor) {
return Caffeine.newBuilder()
.maximumSize(256)
.refreshAfterWrite(Duration.ofSeconds(3))
.expireAfterAccess(Duration.ofSeconds(7))
.executor(refreshExecutor)
.build(bssSysparmRepository::getById);
}
乍看之下似乎很合理。但是為何會出問題呢?在高并發(fā)場景下,多個線程同時請求緩存中不存在的數(shù)據(jù),導(dǎo)致多個線程都需要去加載數(shù)據(jù),而LoadingCache的刷新策略是按需刷新,即只有當(dāng)緩存中的數(shù)據(jù)過期時才會觸發(fā)刷新。如果多個線程同時觸發(fā)刷新,就會導(dǎo)致多個線程同時去加載數(shù)據(jù),并使用相同的Key值調(diào)用ConcurrentHashMap.compute方法加載和刷新數(shù)據(jù),從而導(dǎo)致競態(tài)爭搶。這種機(jī)理導(dǎo)致LoadingCache或者說ConcurrentHashMap(在JDK1.8里面)并不適合用在需要高并發(fā)頻繁刷新的緩存場景。
有意思的是,這個鍋其實跟JDK中ConcurrentHashMap的實現(xiàn)機(jī)制有關(guān),存在同樣問題的還有computeIfPresent方法,具體可見。
解決的方法不難,就是使用AsyncLoadingCache來代替LoadingCache,異步加載數(shù)據(jù),避免競態(tài)爭搶。修改下代碼:
@Bean
@ConditionalOnBean(ParameterRepository.class)
public AsyncLoadingCache<String, ParameterEntity> parameterCache(ParameterRepository parameterRepository,
Executor refreshExecutor) {
return Caffeine.newBuilder()
.maximumSize(256)
.refreshAfterWrite(Duration.ofSeconds(3))
.expireAfterAccess(Duration.ofSeconds(7))
.executor(refreshExecutor)
.buildAsync(parameterRepository::getById);
}
取用時,從LoadingCache.get()方法改為AsyncLoadingCache.synchronous().get()方法即可。優(yōu)化版本上線后,各方人員情緒穩(wěn)定。
0x04 事故復(fù)盤
比起追究責(zé)任,更重要的是帶給我們的啟發(fā):
- 沒有基準(zhǔn)的性能優(yōu)化都是耍流氓;
- 上線前需要先進(jìn)行性能回歸,確認(rèn)優(yōu)化后的性能是否符合預(yù)期。
0x05 事故影響
生產(chǎn)系統(tǒng)緊急回滾一次,所幸的是沒有收到客訴(那就是沒有問題)。A公司相關(guān)負(fù)責(zé)人連夜編寫事故報告一份,并通知其他項目組未雨綢繆。

浙公網(wǎng)安備 33010602011771號