【故障現(xiàn)場】多線程性能優(yōu)化最大的坑,99%人都不自知
1. 問題&分析
當(dāng)我們在處理慢接口問題時(shí),經(jīng)常會使用多線程技術(shù),將能夠并行處理的任務(wù)拆分到不同的線程中處理,等任務(wù)處理完成后,再收集各線程的處理結(jié)果,進(jìn)行后續(xù)的處理。整體思路如下圖所示:
圖片
這樣可以將并行部分的總耗時(shí)從 sum 降為 max,從而大幅降低接口的響應(yīng)時(shí)間。
1.1. 案例
訂單詳情頁耗時(shí)嚴(yán)重,p99 將近3秒,已經(jīng)驗(yàn)證影響用戶體驗(yàn),本次迭代小艾專門對該接口進(jìn)行優(yōu)化。迭代剛上線,該接口的響應(yīng)時(shí)間大幅降低,p99 降低到 800 毫秒以內(nèi),大家紛紛向小艾發(fā)來祝賀。但好景不長,隨著流量的增加,接口響應(yīng)時(shí)間也在逐漸變長,p99 超過 5 秒,最后系統(tǒng)拋出大量的 RejectedExecutionException 異常,這個(gè)接口不可用。最終,QA伙伴火速進(jìn)行回滾操作,系統(tǒng)恢復(fù)正常。
系統(tǒng)恢復(fù)后,小艾仔細(xì)查看系統(tǒng)監(jiān)控,CPU使用率并不高,內(nèi)存也處于正常水位,接口性能居然比優(yōu)化前還差,真心不知道哪里出了問題。
優(yōu)化前代碼:
public RestResult<OrderDetailVO> getOrderDetail(@PathVariable Long orderId){
Stopwatch stopwatch = Stopwatch.createStarted();
OrderService.Order order = this.orderService.getById(orderId);
if (order == null){
return RestResult.success(null);
}
OrderDetailVO orderDetail = new OrderDetailVO();
orderDetail.setUser(userService.getById(order.getUserId()));
orderDetail.setAddress(addressService.getById(order.getUserAddressId()));
orderDetail.setCoupon(couponService.getById(order.getCouponId()));
orderDetail.setProduct(productService.getById(order.getProductId()));
log.info("串行 Cost {} ms", stopwatch.stop().elapsed(TimeUnit.MILLISECONDS));
return RestResult.success(orderDetail);
}優(yōu)化前耗時(shí):
圖片
優(yōu)化后代碼:
public RestResult<OrderDetailVO> getOrderDetailNew(@PathVariable Long orderId){
Stopwatch stopwatch = Stopwatch.createStarted();
OrderService.Order order = this.orderService.getById(orderId);
if (order == null){
return RestResult.success(null);
}
Future<UserService.User> userFuture = this.executorService.submit(() -> userService.getById(order.getUserId()));
Future<AddressService.Address> addressFuture = this.executorService.submit(() -> addressService.getById(order.getUserAddressId()));
Future<CouponService.Coupon> couponFuture = this.executorService.submit(() -> couponService.getById(order.getCouponId()));
Future<ProductService.Product> productFuture = this.executorService.submit(() -> productService.getById(order.getProductId()));
OrderDetailVO orderDetail = new OrderDetailVO();
orderDetail.setUser(getFutureValue(userFuture));
orderDetail.setProduct(getFutureValue(productFuture));
orderDetail.setAddress(getFutureValue(addressFuture));
orderDetail.setCoupon(getFutureValue(couponFuture));
log.info("并行 Cost {} ms", stopwatch.stop().elapsed(TimeUnit.MILLISECONDS));
return RestResult.success(orderDetail);
}優(yōu)化后耗時(shí):
圖片
可見采用并行優(yōu)化后,接口的響應(yīng)時(shí)間從 4 秒 將至 1 秒,效果還是非常明顯的。
但,繼續(xù)加大請求量,系統(tǒng)便出現(xiàn)問題,如下圖所示:
圖片

在流量逐漸增加的過程中,從日志中可以得到以下信息:
初期耗時(shí)穩(wěn)定,基本在 1 秒左右
接口耗時(shí)逐漸增大,甚至遠(yuǎn)超串行處理的耗時(shí)(大于 4 秒)
有些請求直接拋出 RejectedExecutionException 異常
1.2. 問題分析
從代碼中并未發(fā)現(xiàn)任何問題,設(shè)計(jì)思路也非常清晰,其核心問題在線程池使用上,項(xiàng)目線程池配置如下:
int coreSize = Runtime.getRuntime().availableProcessors();
executorService = new ThreadPoolExecutor(coreSize, coreSize * 5,
5L, TimeUnit.MINUTES,
new LinkedBlockingQueue<Runnable>(1024)
);核心配置為:
- 核心線程數(shù)為 cpu 核數(shù)
- 最大線程數(shù)為 cpu 核數(shù)的 5 倍
- 空閑線程存活時(shí)間為 5 分鐘
- 任務(wù)隊(duì)列為 LinkedBlockingQueue 大小為 1024
在這個(gè)配置下,我們推演下以上的三個(gè)現(xiàn)象。
1.2.1. 線程資源充足
如下圖所示:
圖片
整體流程如下:
- 主線程向線程池提交 Task
- 由于線程處于空閑狀態(tài),立即接受并處理問題
- 線程池線程處理完任務(wù),將最終的處理結(jié)果寫回到 Future
- 主線程等待所有任務(wù)執(zhí)行完成,獲取所有執(zhí)行結(jié)果,然后執(zhí)行后續(xù)流程
這正是想要的執(zhí)行結(jié)果,任務(wù)被并行執(zhí)行,大幅降低接口耗時(shí)。
1.2.2. 任務(wù)進(jìn)入等待隊(duì)列
隨著流量的增加,所有的核心線程都處于忙碌狀態(tài),此時(shí)新任務(wù)將進(jìn)入等待隊(duì)列,具體如下:
圖片
整體流入如下:
- 主線程向線程池提交任務(wù)
- 由于沒有核心線程可用,任務(wù)被放置到任務(wù)隊(duì)列
- 主線程進(jìn)入等待狀態(tài),等待時(shí)間包括兩部分:
任務(wù)在隊(duì)列中等待線程調(diào)度時(shí)間
任務(wù)分配到線程后,任務(wù)實(shí)際執(zhí)行時(shí)間
- 如果前面等待的任務(wù)非常多,那等待時(shí)間將變的非常長
主線程等待時(shí)間 = 隊(duì)列等待時(shí)間 + 任務(wù)執(zhí)行時(shí)間。當(dāng)任務(wù)隊(duì)列非常長時(shí),整體時(shí)間將遠(yuǎn)超串行執(zhí)行時(shí)間。
1.2.3. 資源耗盡觸發(fā)拒絕策略
流量繼續(xù)增加,線程池的任務(wù)隊(duì)列已滿并且線程數(shù)量也達(dá)到上限,此時(shí)會觸發(fā)拒絕策略,具體如下:
圖片
線程池默認(rèn)拒絕策略為:AbortPolicy,直接拋出 RejectedExecutionException,從而觸發(fā)接口異常。
還有更可怕的情況,就是部分提交,也就是主線程已經(jīng)成功提交幾個(gè)任務(wù),如下圖所示:
圖片
核心流程如下:
- 主線程已經(jīng)成功提交兩個(gè)任務(wù)
- 在提交第三個(gè)任務(wù)時(shí),由于資源不夠觸發(fā)拒絕策略,拋出異常導(dǎo)致主線程提前結(jié)束
- 已經(jīng)成功提交的任務(wù)仍舊會被線程執(zhí)行,由于主線程已經(jīng)退出,執(zhí)行結(jié)果沒有任何意義,從而白白浪費(fèi)系統(tǒng)資源
2. 解決方案
前面已經(jīng)分析的很清楚,問題的本質(zhì)就是線程池資源分配不合理,核心參數(shù)設(shè)置錯(cuò)誤:
- 隊(duì)列設(shè)置錯(cuò)誤。在該場景下,需要充分利用線程資源,將任務(wù)放入隊(duì)列會增加任務(wù)在隊(duì)列的等待時(shí)間,隊(duì)列長度越大對系統(tǒng)的傷害越大;
- 拒絕策略設(shè)置錯(cuò)誤。直接拋出異常會中斷主流程,導(dǎo)致部分無效任務(wù)(無意義任務(wù))提交,白白浪費(fèi)系統(tǒng)資源;
除線程池參數(shù)問題外,還有個(gè)小問題:主線程完成任務(wù)提交后處于等待狀態(tài),未執(zhí)行任何有意義的操作,存在資源浪費(fèi)。
2.1. 線程池改進(jìn)方案
改進(jìn)線程池如下所示:
int coreSize = Runtime.getRuntime().availableProcessors();
executorService = new ThreadPoolExecutor(coreSize, coreSize * 5,
5L, TimeUnit.MINUTES,
new SynchronousQueue<>(),
new ThreadPoolExecutor.CallerRunsPolicy()
);線程池配置如下:
- 核心線程數(shù)不變,仍舊是 cpu 數(shù);
- 最大線程數(shù)不變,仍舊是 cpu 數(shù)的5倍;
- 空閑線程存活時(shí)間不變,仍舊是 5 分鐘;
- 使用 SynchronousQueue 替代 LinkedBlockingQueue(1024)。SynchronousQueue 是一個(gè)特殊的隊(duì)列,其最大容量是1。也就是說,任何一次插入操作都必須等待一個(gè)相應(yīng)的刪除操作,反之亦然。如果沒有相應(yīng)的操作正在進(jìn)行,則該線程將被阻塞;
- 指定拒絕策略為 CallerRunsPolicy。當(dāng)線程池資源不夠時(shí),由主線程來執(zhí)行任務(wù);
在這個(gè)配置下,及時(shí)線程池中的所有資源全部耗盡,也只會降級到串行執(zhí)行,不會讓系統(tǒng)變的更糟糕。
新配置下,系統(tǒng)表現(xiàn)如下:
圖片
在最差的情況下也僅僅與串行執(zhí)行耗時(shí)一致。
總體來說就一句話:線程池有資源可用,那就為主線程分擔(dān)部分壓力;如果沒有資源可用,那就由主線程獨(dú)自完成。
2.1. 充分利用主線程
上面提到一個(gè)小問題,在資源充足情況下,所有任務(wù)均有線程池線程完成,主線程一致處于等待狀態(tài),存在一定的資源浪費(fèi)。
如下圖所示:
圖片
3 個(gè)任務(wù)耗費(fèi) 4 個(gè)線程資源:
- 線程池3個(gè)線程負(fù)責(zé)執(zhí)行任務(wù)
- 主線程等待執(zhí)行結(jié)果,一直處于阻塞狀態(tài)
為了充分利用線程資源,可以讓主線程負(fù)責(zé)執(zhí)行任意一個(gè)任務(wù)。如下圖所示:
圖片
主線程不在盲目等待,也負(fù)責(zé)一個(gè)任務(wù)的執(zhí)行,這樣 3 個(gè)任務(wù)只需 3 個(gè)線程即可。
代碼上也非常簡單,具體如下:
public RestResult<OrderDetailVO> getOrderDetailNew(@PathVariable Long orderId){
Stopwatch stopwatch = Stopwatch.createStarted();
OrderService.Order order = this.orderService.getById(orderId);
if (order == null){
return RestResult.success(null);
}
Future<UserService.User> userFuture = this.executorService.submit(() -> userService.getById(order.getUserId()));
Future<AddressService.Address> addressFuture = this.executorService.submit(() -> addressService.getById(order.getUserAddressId()));
Future<CouponService.Coupon> couponFuture = this.executorService.submit(() -> couponService.getById(order.getCouponId()));
// Future<ProductService.Product> productFuture = this.executorService.submit(() -> productService.getById(order.getProductId()));
OrderDetailVO orderDetail = new OrderDetailVO();
// 由主線程負(fù)責(zé)運(yùn)行
orderDetail.setProduct(productService.getById(order.getProductId()));
orderDetail.setUser(getFutureValue(userFuture));
orderDetail.setAddress(getFutureValue(addressFuture));
orderDetail.setCoupon(getFutureValue(couponFuture));
log.info("并行 Cost {} ms", stopwatch.stop().elapsed(TimeUnit.MILLISECONDS));
return RestResult.success(orderDetail);
}主線程執(zhí)行不同的任務(wù),會對接口的響應(yīng)時(shí)間產(chǎn)生影響嗎?
不會,并行執(zhí)行整體耗時(shí)為 max(任務(wù)耗時(shí)),主線程必須獲取全部結(jié)果才能運(yùn)行,所以必須等待這么長時(shí)間。
- 如果主線程運(yùn)行的任務(wù)不是最耗時(shí)任務(wù),則需要等待最耗時(shí)任務(wù)執(zhí)行完成才能執(zhí)行后續(xù)邏輯;
- 如果主線程運(yùn)行的是最耗時(shí)任務(wù),則其他線程已經(jīng)執(zhí)行完成并提前釋放資源;
3. 示例&源碼
代碼倉庫:https://gitee.com/litao851025/learnFromBug
代碼地址:https://gitee.com/litao851025/learnFromBug/tree/master/src/main/java/com/geekhalo/demo/thread/paralleltask































