事故案例03 - Qserver RPC調用大量失敗
一、事故背景
Queryserver是內部的核心服務,負責處理數據查詢請求并支持分布式緩存功能。為優化緩存一致性,新增了分布式鎖邏輯:在查詢請求命中緩存時需先獲取分布式鎖(基于Tair實現),若未獲取成功則等待1秒后重試。此功能上線后,在特定異常場景下(如SQL執行失敗)觸發了線程池資源耗盡,最終導致RPC請求被拒絕,引發服務故障。
二、事故影響
(一)業務影響
- 線上部分數據查詢失敗,直接影響依賴Queryserver的業務功能(如Mustang、malldatacentor服務)。
- 因故障快速修復(總影響時長19分鐘),未造成大規模客戶投訴,但存在潛在業務中斷風險。
- 某日線上出現RPC失敗率突增,導致部分業務數據無法展示。
(二)技術影響
- 告警顯示上游服務調用Queryserver的RPC失敗率超過50%,Queryserver的RPC線程池持續滿載,觸發RejectedException(服務端線程池拒絕請求),服務可用性下降。
- 失敗請求均勻分布,無明顯業務流量突增或數據庫負載異常,即Doris引擎負載無顯著波動,故障范圍集中在Queryserver自身邏輯。
三、根本原因
(一)直接原因
分布式鎖邏輯在高并發場景下導致RPC線程池耗盡,RPC框架主動拒絕請求(RejectedException)。
(二)深層原因
1. 邏輯設計缺陷
- 查詢失敗時未緩存結果,后續相同請求持續觸發分布式鎖競爭,強制串行執行,占用線程資源。
- 分布式鎖重試間隔過長(1秒),導致線程長時間阻塞,加速線程池耗盡。
2. 異常處理不足
未對失敗查詢的請求進行熔斷或限流,異常流量持續沖擊線程池。
3. 測試覆蓋不全
未模擬“高并發異常SQL”場景,導致邏輯缺陷未在測試階段暴露。
四、處理流程與數據變化
(一)處理流程
- 16:16:收到raptor持續告警,確認Mustang/malldatacentor服務異常。
- 16:17:通過鏈路追蹤定位到Queryserver RPC失敗率異常。
- 16:28:嘗試重啟服務,但未解決問題。
- 16:32:深入分析代碼邏輯,發現分布式鎖機制導致線程池滿載,臨時關閉緩存功能或調整鎖等待時間。
- 16:35:告警恢復,服務可用性回升。
(二)關鍵數據變化
指標 | 故障前 | 故障中 | 恢復后 |
---|---|---|---|
Queryserver QPS | 正常 | 驟降(拒絕請求) | 正常 |
RPC失敗率 | <1% | >50% | <1% |
線程池使用率 | 60% | 100% | 60% |
Tair鎖競爭頻率 | 低 | 高頻(持續重試) | 低 |
五、過程復現
(一)原因定位
queryserver添加了分布式鎖的功能,如果開啟了緩存的情況下,每個查詢需要去拿到分布式鎖之后,才能被執行,否則會等待1s時間再去嘗試拿到分布式鎖。代碼如下:
while (true) {if (!cacheInfo.isUpdateCache()) {//1 從緩存中獲取結果CachedResult cachedResult = cacheService.getResult(sqlMappingKey);if (cachedResult!= null) {
// logInfo.setHitCache(true);detailLog.setUseCache(cacheInfo.isFetchFromCache());return ResponseContext.success(cachedResult.getData()).hitCache().withAttach(detailLog);}}//加鎖if (!tairService.setNx((LOCK_PREFIX + sqlMappingKey).getBytes(), ("" + id).getBytes(), 20)) {Thread.sleep(1000);continue;}//2 從引擎中查詢List result = queryEngineBackend.syncQuery(queryInfo);//3 對結果進行緩存if (result!= null &&!result.isEmpty()) {//這里最終判斷傳入的緩存過期時間是否合法,如果不合法就傳入默認的1800s.最長時間不超過60天cacheService.cacheResult(sqlMappingKey, result, ((cacheInfo.getExpireMs() > 0) && (cacheInfo.getExpireMs() < MAX_EXPIRE_SEC))? cacheInfo.getExpireMs() : DEFAULT_EXPIRE_SEC);}//解鎖if (("" + id).equals(new String(tairService.getValue((LOCK_PREFIX + sqlMappingKey).getBytes()))))tairService.delete((LOCK_PREFIX + sqlMappingKey).getBytes());return ResponseContext.success(result).withRequestId(id).withAttach(detailLog);
}
并且如果查詢失敗的話,不會緩存SQL結果,后續的相同請求均會提交到引擎執行,而后續的請求如果全是有問題的SQL,則會占用請求線程,并且將查詢變為串行執行。
(二)復現過程
通過監控發現,RPC線程池使用率在故障期間持續100%,隊列堆積后觸發拒絕策略。
復現實驗:構造高并發異常SQL請求,線程池在10秒內被打滿,完全復現線上問題。
- 在Mustang上配置一個帶有緩存功能的SQL,然后SQL故意寫成執行會報錯的SQL。
- 在st環境使用工具并發請求,查看queryserver報錯信息。
實驗結果如下:
- queryserver大量出現RPC請求被拒絕,出現很多
com.dianping.pigeon.remoting.provider.exception.ProcessTimeoutException
異常。 - 上游調用出現失敗率較高的情況。
六、總結與改進方案
(一)深層次原因分析
1. 架構設計缺陷
- 資源競爭模型不合理:
- 分布式鎖重試機制采用同步阻塞(Sleep),違背高并發服務的異步化設計原則。
- 未隔離線程池:鎖競爭、SQL查詢、緩存操作共享同一線程池,異常SQL影響全局請求。
- 緩存策略漏洞:
- 僅緩存成功結果,未對失敗請求做短期標記(如“異常狀態緩存”),導致無效查詢重復執行。
2. 運維與監控盲區
- 線程池狀態未監控:僅關注QPS和錯誤率,未實時跟蹤線程池使用率、鎖競爭耗時等指標。
- 告警閾值不合理:RPC失敗率告警觸發閾值過高(>30%),未能提前預警。
3. 測試與流程問題
- 異常場景覆蓋不足:壓力測試僅覆蓋正常SQL,未模擬高并發異常SQL場景。
- 代碼評審遺漏:分布式鎖邏輯的Sleep操作在評審中未被質疑,團隊對阻塞風險認知不足。
(二)技術解決方案(面試重點)
1. 邏輯優化
- 鎖重試異步化:
- 將
Thread.sleep(1000)
改為異步等待(如基于CompletableFuture
的調度),釋放線程資源。 - 代碼示例:
- 將
if (!tairService.setNx(...)) { return CompletableFuture.delayedExecutor(100, TimeUnit.MILLISECONDS) .submit(() -> retryLockAndQuery());
}
- 失敗結果緩存:
- 對異常SQL結果緩存5秒,避免重復執行,代碼修改:
if (result == null) { cacheService.markError(sqlMappingKey, 5); // 標記異常狀態
}
2. 架構增強
- 線程池隔離:
- 為鎖競爭、SQL查詢、緩存操作分配獨立線程池,避免相互影響。
- 使用Netty或Vert.x實現異步RPC框架,提升吞吐量。
3. 熔斷與限流
- 集成Hystrix,對異常SQL請求熔斷(10秒內錯誤率>50%則直接拒絕)。
- 為單SQL設置QPS限流(如每秒100次),防止資源耗盡。
4. 監控與運維改進
- 新增監控指標:
- 線程池使用率、鎖競爭耗時、Tair操作成功率。
- 通過Prometheus + Grafana實時展示,閾值觸發企業微信告警。
- 混沌測試常態化:
- 定期注入線程池滿載、分布式鎖失效等故障,驗證服務自愈能力。
七、后續計劃
- 灰度發布改進后的分布式鎖邏輯,驗證異常場景下的線程池穩定性。
- 完善自動化測試平臺的異常注入能力(如模擬SQL失敗、線程池滿載)。
- 建立服務健康度評分體系,將線程池狀態、鎖競爭等指標納入服務可用性評估。