1. 現象
某天伺服器監控上發現大量介面報錯,檢視伺服器日誌,並且分析程式碼後,發現最直接的問題現象有:
- Controller 的主執行緒中,RequestContextHolder.getRequestAttributes() 返回的值,會突然在某個時刻返回的是 null,從而導致API的邏輯報錯。
- elk日誌中,spring cloud sleuth 框架裡面的 traceId,會在介面處理鏈路的某個環節突然丟失了,只有新建的spanId。
- elk日誌中,最大量的報錯日誌資訊,都是在於非同步請求一個第三方服務 timeout,但是請求這個第三方服務是透過執行緒池非同步執行的。
前面1、2 兩個問題暫時無思路,因為透過日誌來看,問題出現的環節無規律,也摸不著頭腦。但第3個問題則是很明確,那個第三方服務不可用了。但問題3畢竟是非同步執行緒執行的,原則上來講不應該影響主執行緒,雖說暫時看不到和第1、2問題的關聯點,可經驗上來看應該脫不了干係。
於是我們將對那個第三方服務的請求熔斷了。結果是慢慢的,伺服器錯誤逐漸減少,直到最終恢復正常。
現在基本可以肯定,問題1、2 是受問題3影響的。接下來就需要查清問題發生的原委。
2. 排查
進一步分析問題1、2,就能明顯看出來,這和執行緒有關。無論 RequestContextHolder
還是 MDC
,都是基於 ThreadLocal 實現的,上述亂象表面,應該是本地執行緒 ThreadLocal 被動了。可到底是 ThreadLocal 的值被動了?還是當前執行緒被切換了呢?目前只能從問題3著手。
分析問題3的現象,當時第三方服務不可用,當呼叫服務不是直接被拒絕,而且在請求達到最大 timeout 限制後報錯。雖說整個過程是基於執行緒池非同步執行,可這類請求 timeout 的現象,對執行緒池帶來的結果是災難性的。
因為執行緒池中分配的執行緒在處理 timeout 請求時,也只能保持等待,等到超時時間後才回收執行緒。因為是非同步執行的,tomcat 執行緒池的處理效率並不會受影響,不斷的有API請求湧入儘量,並給業務執行緒池分配呼叫第三方服務的任務。而業務執行緒池下執行任務的每個執行緒因為 timeout 請求阻塞,會導致短時間內併發執行緒數量迅速飆升。
我們再著重搜一下出問題時,執行緒池的告警日誌,果然,當時執行緒池已經打滿,開始大量執行拒絕策略。
再看看我們業務執行緒池的配置。這個執行緒池的配置在我前面幾篇文章中出現過,之前以為是比較標準的,但這次發現有很大的坑。
@Bean("customExecutor")
public Executor getAsyncExecutor() {
final RejectedExecutionHandler rejectedHandler = new ThreadPoolExecutor.CallerRunsPolicy() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
log.warn("LOG:執行緒池容量不夠,考慮增加執行緒數量,但更推薦將執行緒消耗數量大的程式使用單獨的執行緒池");
super.rejectedExecution(r, e);
}
};
ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
threadPoolTaskExecutor.setCorePoolSize(10);
threadPoolTaskExecutor.setMaxPoolSize(50);
threadPoolTaskExecutor.setQueueCapacity(2000);
threadPoolTaskExecutor.setRejectedExecutionHandler(rejectedHandler);
threadPoolTaskExecutor.setThreadNamePrefix("custom exec-");
threadPoolTaskExecutor.setTaskDecorator(runnable -> {
try {
Optional<RequestAttributes> requestAttributesOptional = ofNullable(RequestContextHolder.getRequestAttributes());
Optional<Map<String, String>> contextMapOptional = ofNullable(MDC.getCopyOfContextMap());
return () -> {
try {
requestAttributesOptional.ifPresent(RequestContextHolder::setRequestAttributes);
contextMapOptional.ifPresent(MDC::setContextMap);
runnable.run();
} finally {
MDC.clear();
RequestContextHolder.resetRequestAttributes();
}
};
} catch (Exception e) {
return runnable;
}
});
return threadPoolTaskExecutor;
}
3. 結論
我們著重關注執行緒池的兩個引數配置:
- 執行緒池拒絕策略: 拒絕策略是
CallerRunsPolicy
,即:當執行緒池滿了之後,再有執行緒進來,將由對應請求的主執行緒來執行。 - 任務裝飾: 當執行緒池在分配子執行緒時,會先讓當前主執行緒將自身ThreadLocal 值複製給子執行緒。並在子執行緒執行完任務後,子執行緒清除 ThreadLocal 的值。
這些引數各自的邏輯都沒有問題,可結合到一起就出問題了。試想一下:
- API 主執行緒原本攜帶 ThreadLocal 資訊。此時需要基於業務執行緒池非同步執行任務。
- 但是由於業務執行緒池滿了,根據CallerRunsPolicy拒絕策略,只能由主執行緒自己來充當執行任務的“子執行緒”。
- 再根據執行緒任務裝飾的邏輯,在執行完業務執行緒池中的任務後,執行緒中的“子執行緒”(即:主執行緒)會被清空 ThreadLocal 資訊。
- 主執行緒在執行完上述任務後,就丟棄了原本攜帶的 ThreadLocal 資訊,從而出現之前的第1和第2個問題。
所以說這樣的執行緒池配置就是個大坑,只要執行緒池滿了,後續的執行緒執行了拒絕策略。就會導致主執行緒 ThreadLocal 資訊丟失。
問題總算找著了。
4. 解決
4.1. TaskDecorator 邏輯最佳化
回顧前面,發生的問題在於當執行拒絕策略時,TaskDecorator 的處理邏輯中,將主執行緒當子執行緒處理,做了 ThreadLocal 值當清除操作,最終導致主執行緒的 ThreadLocal 值丟失。那麼首先可以針對該現象解決的是,在 TaskDecorator 邏輯對當前執行緒做出區分,如果發現當前執行緒是主執行緒,則不進行 ThreadLocal 的賦值和清除。如下:
@Bean("customExecutor")
public Executor getCustomExecutor() {
final RejectedExecutionHandler rejectedHandler = new ThreadPoolExecutor.CallerRunsPolicy() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
log.warn("LOG:執行緒池容量不夠,考慮增加執行緒數量,但更推薦將執行緒消耗數量大的程式使用單獨的執行緒池");
super.rejectedExecution(r, e);
}
};
ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
threadPoolTaskExecutor.setCorePoolSize(2);
threadPoolTaskExecutor.setMaxPoolSize(2);
threadPoolTaskExecutor.setQueueCapacity(0);
threadPoolTaskExecutor.setRejectedExecutionHandler(rejectedHandler);
threadPoolTaskExecutor.setThreadNamePrefix("custom-exec-");
threadPoolTaskExecutor.setTaskDecorator(runnable -> {
try {
Thread mainThread = Thread.currentThread();
Optional<RequestAttributes> requestAttributesOptional = ofNullable(RequestContextHolder.getRequestAttributes());
Optional<Map<String, String>> contextMapOptional = ofNullable(MDC.getCopyOfContextMap());
return () -> {
Thread subThread = Thread.currentThread();
if (!subThread.equals(mainThread)) {
try {
requestAttributesOptional.ifPresent(RequestContextHolder::setRequestAttributes);
contextMapOptional.ifPresent(MDC::setContextMap);
runnable.run();
} finally {
MDC.clear();
RequestContextHolder.resetRequestAttributes();
}
} else {
runnable.run();
}
};
} catch (Exception e) {
return runnable;
}
});
return threadPoolTaskExecutor;
}
4.2. TransmittableThreadLocal
TransmittableThreadLocal (簡稱TTL)是阿里推出的工具庫,專門解決執行緒複用情況下變數傳遞問題。
這個工具能解決上述問題的關鍵在於下面的比較:
- TaskDecorator:(1)父執行緒給子執行緒賦值;(2)子執行緒執行完成後清空 TreadLocal值。
- TTL:(1)先給子執行緒做備份backup;(2)父執行緒給子執行緒賦值;(3)子執行緒執行完成後清空 TreadLocal值;(4)利用之前的backup,給子執行緒restore還原回剛來的樣子。
如果是 TTL 的設計,就算按照拒絕策略,由主執行緒來執行緒池中執行非同步,也因為backup還原回原來的值,並不會有任何丟值的現象。
具體的使用方法和文件,請參考 TTL github,這裡就不多介紹。
但是,TTL 這個工具的框架封裝上,沒 TaskDecorator 那麼靈活,它純粹為了業務使用的 TreadLocal 變數服務的。如果你希望和上面一樣,針對 RequestContextHolder、MDC也能實現,就比較難了。但如果你只是自己定義 ThreadLocal 值,希望子執行緒依然能使用,不妨使用它,再專門定義一個 TtlExecutors 類方法封裝後的執行緒池。
5. 其他知識點
5.1. 執行緒池拒絕策略
拒絕策略提供頂級介面 RejectedExecutionHandler ,其中方法 rejectedExecution 即定製具體的拒絕策略的執行邏輯。
jdk預設提供了四種拒絕策略:
- AbortPolicy:丟擲異常,中止任務。丟擲拒絕執行 RejectedExecutionException 異常資訊。執行緒池預設的拒絕策略。必須處理好丟擲的異常,否則會打斷當前的執行流程,影響後續的任務執行
- CallerRunsPolicy:使用呼叫執行緒執行任務。當觸發拒絕策略,只要執行緒池沒有關閉的話,則使用呼叫執行緒直接執行任務。一般併發比較小,效能要求不高,不允許失敗。但是,由於呼叫者自己執行任務,如果任務提交速度過快,可能導致程式阻塞,效能效率上必然的損失較大
- DiscardPolicy:直接丟棄,其他啥都沒有
- DiscardOldestPolicy:丟棄佇列最老任務,新增新任務。當觸發拒絕策略,只要執行緒池沒有關閉的話,丟棄阻塞佇列 workQueue 中最老的一個任務,並將新任務加入