線程池運用不當引發(fā)的一次線上事故解決記錄分析
引言
在高并發(fā)、異步化等場景,線程池的運用可以說無處不在。線程池從本質(zhì)上來講,即通過空間換取時間,因為線程的創(chuàng)建和銷毀都是要消耗資源和時間的,對于大量使用線程的場景,使用池化管理可以延遲線程的銷毀,大大提高單個線程的復用能力,進一步提升整體性能。
今天遇到了一個比較典型的線上問題,剛好和線程池有關,另外涉及到死鎖、jstack命令的使用、JDK不同線程池的適合場景等知識點,同時整個調(diào)查思路可以借鑒,特此記錄和分享一下。
業(yè)務背景描述
該線上問題發(fā)生在廣告系統(tǒng)的核心扣費服務,首先簡單交代下大致的業(yè)務流程,方便理解問題。
綠框部分即扣費服務在廣告召回扣費流程中所處的位置,簡單理解:當用戶點擊一個廣告后,會從C端發(fā)起一次實時扣費請求(CPC,按點擊扣費模式),扣費服務則承接了該動作的核心業(yè)務邏輯:包括執(zhí)行反作弊策略、創(chuàng)建扣費記錄、click日志埋點等。
問題現(xiàn)象和業(yè)務影響
12月2號晚上11點左右,我們收到了一個線上告警通知:扣費服務的線程池任務隊列大小遠遠超出了設定閾值,而且隊列大小隨著時間推移還在持續(xù)變大。詳細告警內(nèi)容如下:
相應的,我們的廣告指標:點擊數(shù)、收入等也出現(xiàn)了非常明顯的下滑,幾乎同時發(fā)出了業(yè)務告警通知。其中,點擊數(shù)指標對應的曲線表現(xiàn)如下:
該線上故障發(fā)生在流量高峰期,持續(xù)了將近30分鐘后才恢復正常。
問題調(diào)查和事故解決過程
下面詳細說下整個事故的調(diào)查和分析過程。
第1步:收到線程池任務隊列的告警后,我們第一時間查看了扣費服務各個維度的實時數(shù)據(jù):包括服務調(diào)用量、超時量、錯誤日志、JVM監(jiān)控,均未發(fā)現(xiàn)異常。
第2步:然后進一步排查了扣費服務依賴的存儲資源(mysql、redis、mq),外部服務,發(fā)現(xiàn)了事故期間存在大量的數(shù)據(jù)庫慢查詢。
上述慢查詢來自于事故期間一個剛上線的大數(shù)據(jù)抽取任務,從扣費服務的mysql數(shù)據(jù)庫中大批量并發(fā)抽取數(shù)據(jù)到hive表。因為扣費流程也涉及到寫mysql,猜測這個時候mysql的所有讀寫性能都受到了影響,果然進一步發(fā)現(xiàn)insert操作的耗時也遠遠大于正常時期。
第3步:我們猜測數(shù)據(jù)庫慢查詢影響了扣費流程的性能,從而造成了任務隊列的積壓,所以決定立馬暫定大數(shù)據(jù)抽取任務。但是很奇怪:停止抽取任務后,數(shù)據(jù)庫的insert性能恢復到正常水平了,但是阻塞隊列大小仍然還在持續(xù)增大,告警并未消失。
第4步:考慮廣告收入還在持續(xù)大幅度下跌,進一步分析代碼需要比較長的時間,所以決定立即重啟服務看看有沒有效果。為了保留事故現(xiàn)場,我們保留了一臺服務器未做重啟,只是把這臺機器從服務管理平臺摘掉了,這樣它不會接收到新的扣費請求。
果然重啟服務的殺手锏很管用,各項業(yè)務指標都恢復正常了,告警也沒有再出現(xiàn)。至此,整個線上故障得到解決,持續(xù)了大概30分鐘。
問題根本原因的分析過程
下面再詳細說下事故根本原因的分析過程。
第1步:第二天上班后,我們猜測那臺保留了事故現(xiàn)場的服務器,隊列中積壓的任務應該都被線程池處理掉了,所以嘗試把這臺服務器再次掛載上去驗證下我們的猜測,結(jié)果和預期完全相反,積壓的任務仍然都在,而且隨著新請求進來,系統(tǒng)告警立刻再次出現(xiàn)了,所以又馬上把這臺服務器摘了下來。
第2步:線程池積壓的幾千個任務,經(jīng)過1個晚上都沒被線程池處理掉,我們猜測應該存在死鎖情況。所以打算通過jstack命令dump線程快照做下詳細分析。
#找到扣費服務的進程號 $ jstack pid > /tmp/stack.txt # 通過進程號dump線程快照,輸出到文件中 $ jstack pid > /tmp/stack.txt
在jstack的日志文件中,立馬發(fā)現(xiàn)了:用于扣費的業(yè)務線程池的所有線程都處于waiting狀態(tài),線程全部卡在了截圖中紅框部分對應的代碼行上,這行代碼調(diào)用了countDownLatch的await()方法,即等待計數(shù)器變?yōu)?后釋放共享鎖。
第3步:找到上述異常后,距離找到根本原因就很接近了,我們回到代碼中繼續(xù)調(diào)查,首先看了下業(yè)務代碼中使用了newFixedThreadPool線程池,核心線程數(shù)設置為25。針對newFixedThreadPool,JDK文檔的說明如下:
創(chuàng)建一個可重用固定線程數(shù)的線程池,以共享的無界隊列方式來運行這些線程。如果在所有線程處于活躍狀態(tài)時提交新任務,則在有可用線程之前,新任務將在隊列中等待。
關于newFixedThreadPool,核心包括兩點:
1、最大線程數(shù) = 核心線程數(shù),當所有核心線程都在處理任務時,新進來的任務會提交到任務隊列中等待;
2、使用了無界隊列:提交給線程池的任務隊列是不限制大小的,如果任務被阻塞或者處理變慢,那么顯然隊列會越來越大。
所以,進一步結(jié)論是:核心線程全部死鎖,新進的任務不對涌入無界隊列,導致任務隊列不斷增加。
第4步:到底是什么原因?qū)е碌乃梨i,我們再次回到jstack日志文件中提示的那行代碼做進一步分析。下面是我簡化過后的示例代碼:
/** * 執(zhí)行扣費任務 */ public Result<Integer> executeDeduct(ChargeInputDTO chargeInput) { ChargeTask chargeTask = new ChargeTask(chargeInput); bizThreadPool.execute(() -> chargeTaskBll.execute(chargeTask )); return Result.success(); } /* * 扣費任務的具體業(yè)務邏輯 */ public class ChargeTaskBll implements Runnable { public void execute(ChargeTask chargeTask) { // 第一步:參數(shù)校驗 verifyInputParam(chargeTask); // 第二步:執(zhí)行反作弊子任務 executeUserSpam(SpamHelper.userConfigs); // 第三步:執(zhí)行扣費 handlePay(chargeTask); // 其他步驟:點擊埋點等 ... } } /** * 執(zhí)行反作弊子任務 */ public void executeUserSpam(List<SpamUserConfigDO> configs) { if (CollectionUtils.isEmpty(configs)) { return; } try { CountDownLatch latch = new CountDownLatch(configs.size()); for (SpamUserConfigDO config : configs) { UserSpamTask task = new UserSpamTask(config,latch); bizThreadPool.execute(task); } latch.await(); } catch (Exception ex) { logger.error("", ex); } }
通過上述代碼,大家能否發(fā)現(xiàn)死鎖是怎么發(fā)生的呢?根本原因在于:一次扣費行為屬于父任務,同時它又包含了多次子任務:子任務用于并行執(zhí)行反作弊策略,而父任務和子任務使用的是同一個業(yè)務線程池。當線程池中全部都是執(zhí)行中的父任務時,并且所有父任務都存在子任務未執(zhí)行完,這樣就會發(fā)生死鎖。下面通過1張圖再來直觀地看下死鎖的情況:
假設核心線程數(shù)是2,目前正在執(zhí)行扣費父任務1和2。另外,反作弊子任務1和3都執(zhí)行完了,反作弊子任務2和4都積壓在任務隊列中等待被調(diào)度。因為反作弊子任務2和4沒執(zhí)行完,所以扣費父任務1和2都不可能執(zhí)行完成,這樣就發(fā)生了死鎖,核心線程永遠不可能釋放,從而造成任務隊列不斷增大,直到程序OOM crash。
死鎖原因清楚后,還有個疑問:上述代碼在線上運行很長時間了,為什么現(xiàn)在才暴露出問題呢?另外跟數(shù)據(jù)庫慢查詢到底有沒有直接關聯(lián)呢?
暫時我們還沒有復現(xiàn)證實,但是可以推斷出:上述代碼一定存在死鎖的概率,尤其在高并發(fā)或者任務處理變慢的情況下,概率會大大增加。數(shù)據(jù)庫慢查詢應該就是導致此次事故出現(xiàn)的導火索。
解決方案
弄清楚根本原因后,最簡單的解決方案就是:增加一個新的業(yè)務線程池,用來隔離父子任務,現(xiàn)有的線程池只用來處理扣費任務,新的線程池用來處理反作弊任務。這樣就可以徹底避免死鎖的情況了。
問題總結(jié)
回顧事故的解決過程以及扣費的技術方案,存在以下幾點待繼續(xù)優(yōu)化:
- 使用固定線程數(shù)的線程池存在OOM風險,在阿里巴巴Java開發(fā)手冊中也明確指出,而且用的詞是『不允許』使用Executors創(chuàng)建線程池。 而是通過ThreadPoolExecutor去創(chuàng)建,這樣讓寫的同學能更加明確線程池的運行規(guī)則和核心參數(shù)設置,規(guī)避資源耗盡的風險。
- 廣告的扣費場景是一個異步過程,通過線程池或者MQ來實現(xiàn)異步化處理都是可選的方案。另外,極個別的點擊請求丟失不扣費從業(yè)務上是允許的,但是大批量的請求丟棄不處理且沒有補償方案是不允許的。后續(xù)采用有界隊列后,拒絕策略可以考慮發(fā)送MQ做重試處理。
以上就是線程池運用不當引發(fā)的一次線上事故解決記錄分析的詳細內(nèi)容,更多關于線程池線上事故的資料請關注腳本之家其它相關文章!
相關文章
使用log4j2打印mybatis的sql執(zhí)行日志方式
這篇文章主要介紹了使用log4j2打印mybatis的sql執(zhí)行日志方式,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2023-09-09SpringCloud容器化服務發(fā)現(xiàn)及注冊實現(xiàn)方法解析
這篇文章主要介紹了SpringCloud容器化服務發(fā)現(xiàn)及注冊實現(xiàn)方法解析,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友可以參考下2020-08-08springboot + jpa實現(xiàn)刪除數(shù)據(jù)的操作代碼
這篇文章主要介紹了springboot + jpa實現(xiàn)刪除數(shù)據(jù)的操作代碼,本文通過實例代碼給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友參考下吧2024-05-05SpringBoot實戰(zhàn)項目之谷歌瀏覽器全屏效果實現(xiàn)
這篇文章主要介紹了通過 Java SpringBoot來實現(xiàn)谷歌瀏覽器的全屏效果,希望頁面展示時可以實現(xiàn)全屏效果以提高用戶體驗。感興趣的小伙伴跟著小編往下看吧2021-09-09mybatis新增到數(shù)據(jù)庫后返回當前ID問題
這篇文章主要介紹了mybatis新增到數(shù)據(jù)庫后返回當前ID問題,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-08-08半小時實現(xiàn)Java手擼網(wǎng)絡爬蟲框架(附完整源碼)
最近在做一個搜索相關的項目,需要爬取網(wǎng)絡上的一些鏈接存儲到索引庫中,自己寫了一個簡單的網(wǎng)絡爬蟲,感興趣的可以了解一下2021-06-06