什麼現象
先解釋下什麼是空窗,就是資料缺失導緻某塊或整頁出現空白的現象。
事情有點早了,剛接聚劃算,還沒來得及看邏輯,就被告知,壓測時頁面出現了空窗,像這樣:

原因是什麼
其實就是對應的接口逾時或者資料處理異常,導緻該塊兒資料沒有傳回。
我們的代碼是運作在阿拉丁容器裡的,阿拉丁本身是有兜底機制的,并且有兩層:
- 如果接口發生異常,阿拉丁會從tair裡取緩存的資料傳回給前端做兜底
- 如果阿拉丁也沒有兜住,前端接收到錯誤的code,會自動從cdn取對應接口的資料做兜底
這套機制還是非常優秀的,但為什麼還是出現了空窗了。
翻看代碼發現,是我們把對應的異常給吃掉了,沒有抛給阿拉丁容器,代碼是這樣的:
try {
executorService.invokeAll(callableHashSet);
} catch (Exception e) {
throw new RuntimeException(e);
}
初看,是不是以為把try catch拿掉就沒問題了,然而不是,我們看看java.util.concurrent.ExecutorService#invokeAll的實作,先看我們最常用的ThreadPoolExecutor,它的invokeAll方法在父類AbstractExecutorService裡實作:
這裡變量ignore的命名非常漂亮,想都不用想,它被忽略了,為什麼要看這個ExecutionException,是因為線程裡發生的異常都被包裝成了ExecutionException,我們跟着AbstractExecutorService##invokeAll看下,上圖有個newTaskFor,看下實作:
protected <T> RunnableFuture<T> newTaskFor(Callable<T> callable) {
return new FutureTask<T>(callable);
}
看看FutureTask#get方法:
public V get() throws InterruptedException, ExecutionException {
int s = state;
if (s <= COMPLETING)
s = awaitDone(false, 0L);
return report(s);
}
最終在report方法裡實作:
private V report(int s) throws ExecutionException {
Object x = outcome;
if (s == NORMAL)
return (V)x;
if (s >= CANCELLED)
throw new CancellationException();
throw new ExecutionException((Throwable)x);
}
可以看到,如果線程裡抛出了異常,都被包裝成了ExecutionException,而ThreadPoolExecutor#invokeAll方法裡忽略了這個異常,導緻我們根本捕捉不到異常。
上邊說的是ThreadPoolExecutor,我們再看另一個常用的ExecutorService的實作類ForkJoinPool:
看到了吧,方法命名就告訴你了,我不會抛異常給你,進去看看ForkJoinTask#quietlyJoin:
注釋說得很清楚,不抛異常。
怎麼解決
首先,根據上邊的分析,要慎用invokeAll,解決也很簡單,可以有以下幾種方式:
- 能讓主線程感覺到異常,并向外抛,就可以觸發阿拉丁的兜底
- 子產品内部做資料緩存,捕捉到異常以後取緩存資料做兜底
▐ 第一回合
因為對整體的邏輯沒摸透,不敢直接替換掉invokeAll,會影響整個聚劃算首頁,時間又比較急,就先縮小改動範圍,選取方法二:
- 在對應子產品内容做資料緩存,為了兼顧時效性(聚劃算商品有上團和下團時間,是以時效性很強),做了1分鐘的緩存和5分鐘的緩存,如果發生異常,按優先級取緩存,優先1分鐘的緩存。
- 為了減輕寫壓力,隻針對一定比例的請求寫緩存
效果
上線之後沒問題,然而第二次全鍊路壓測,半夜又收到消息說空窗了。
第一回合失敗。
▐ 第二回合
經過分析,可能有多個原因:
- 應該是壓測狀态下,下遊服務持續壓力大,導緻緩存資料過期,
- 寫入緩存的資料也沒有做好校驗,可能寫入不合法的資料
繼續做調整:
- 嚴格校驗寫入緩存的資料,保持緩存資料的合法性
- 既然是兜底資料,可以直接緩存在記憶體,這樣就不用關心寫比例,直接100%緩存合法資料,并且不設定失效時間,這樣保證兜底時總能取到最新的合法資料
- 把該元件的Callable從invokeAll裡拎出來,增加預案,可以觸發整頁兜底,作為最後的保命手段,如下:
後續壓測和日常沒再出現過空窗,就這個子產品來說,應該沒問題了。
這樣就好了嗎
其實不應該結束,上述方案都是在時間緊張的情況下做的臨時補救措施,代碼裡到處是特判邏輯,我們應該有更系統的設計方案:
- 子產品異常都外抛,觸發阿拉丁的兜底,但阿拉丁的兜底是接口級别的,我們一個接口裡邊通常包含多個子產品,如果因為次要子產品導緻使用者看到的主要子產品也是兜底的資料,使用者體驗不好
- 針對每一個子產品做獨立的兜底,但像上述方法一樣,一個子產品一個子產品來改,太累,也容易遺漏。我們應該有一個架構性設計,讓以後的開發隻需要關心業務邏輯,而不用關心這些非功能性問題,這點我準備在EasyWidget裡邊來實作,基礎設施已經具備,隻需要在模闆方法裡加幾行就能實作。
總結一下
這裡邊遇到的主要問題是沒有正确處理線程池的異常和兜底設計不完善導緻,兜底的設計上邊提到了思路,我們再看下處理子線程内部異常的常用方式:
▐ 通過原子變量
AtomicBoolean exception = new AtomicBoolean(false);
Callable<Void> qwbkt = () -> {
try {
qwbktSections.add(qwbktManager.query(context, null));
} catch (Throwable t) {
context.getLogger().error("qwbkt exception:", t);
exception.set(true);
}
return null;
};
//...
if (exception.get()) {
throw new RuntimeException("queryError");
}
▐ 以code形式傳回
Callable<String> task = new Callable<String>() {
@Override
public String call() throws Exception {
Result<String> result = new Result<>();
try {
//..
} catch (Exception e) {
result.setCode("500");
}
return result;
}
};
▐ 老老實實future#get
try {
String s = future.get();
} catch (InterruptedException e) {
//..
} catch (ExecutionException e) {
//todo: 這裡處理線程内部異常
}
再說說線程池的其它問題
▐ 線程池設定不合理
看到很多應用裡的線程池參數不合理,尤其是很多新同學,分不清前台應用和背景任務需要的線程數和拒絕政策怎麼設定。
很多同學從教程裡邊或者某些架構源碼裡邊看到線程池的線程數盡量跟機器核數保持一緻,就一直保持這個設定。
還有看到前台應用了設定了少量的線程,隊列長度是10000。這種情況在遇到突發流量的情況下很容易把自己拖垮,之是以一直沒觸發問題,一種原因可能是沒有遇到過大流量,另一種可能是被限流保護了,一旦限流沒有設定好,就可能遇到緻命問題。
這裡簡單說下自己的經驗:
- 搞清楚核心線程數、最大線程數、任務隊列的工作原理,核心線程用完了是先放任務隊列,隊列滿了才會繼續增加線程數至最大線程數
- 前台應用隊列長度一定不能太大,根據線程數、接口RT、用戶端所能接受的RT來計算隊列長度
- 厘清我們的應用是CPU密集型還是IO密集型,大多數情況我們的業務應用都是IO密集型的,這種情況下不必拘泥于線程數跟核數保持一緻
- 用Runtime.getRuntime().availableProcessors()設定線程數的時候,你以為取到的是虛拟機的線程數,但很可能取到的是實體機的線程數,要注意這個坑
- 前台應用的線程數必須通過壓測不斷調整,才能獲得合理的線程數,但一旦依賴接口的RT等情況發生變化,線程數就可能不再合理,是以合理的線程數很難保持
- 背景應用如果不關心響應的及時性,可以設定較大的隊列,但要關注機器記憶體,也要主要機器重新開機時的任務丢失問題
▐ 線程池的關閉
任務不能丢失的時候一定要在jvm關閉的時候通過鈎子關閉線程池。
Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
@Override
public void run()
{
threadPool.shutdown();
}
}));
上述方法隻在jvm正常關閉的時候有效,如果強殺或斷電等情況還是有問題,就要做更強有力的保障,如先發消息隊列,再處理。