1-背景
後臺BFF層服務爲了SEO,涉及大量對底層數據的聚合,如果按照過程化編程,串行執行請求數據再聚合會造成很高的延遲,因此我們往往大量使用多線程技術並行化多個查詢,來減少單個請求的響應時間。
多線程一定程度上也能達成通過並行化提升請求響應速度的需求,但是實際使用過程中存在一些問題:
- 配置調優複雜:不同場景、不同併發量、上下游不同壓力都需要爲線程池配置不同的線程參數,很容易因爲參數配置不佳,造成請求阻塞或高資源消耗;
- 資源開銷大:線程創建時消耗時間和CPU資源,而池化的線程又會浪費內存,爲突發流量配置大的線程上限,不僅浪費CPU資源又浪費內存;
- 動態性差:線程池參數固定,很難動態適應線上流量的突然變化。
因此,使用NIO同步非阻塞模型,用少量線程非處理大量請求,就很有意義:
- 無須配置調優:NIO同步非阻塞模型的少量線程就能處理海量的請求,無須配置調優——但是要注意不要在非阻塞鏈路上執行阻塞邏輯;
- 資源開銷可控:NIO同步非阻塞模型本身所需的線程數很少而且固定,運行時開銷可控——當然每個請求所佔用的堆內存和NIO直接內存不可避免,需全鏈路(服務內)非阻塞等手段優化;
- 動態伸縮性:NIO的理論上限遠高於其它方面的瓶頸,如達到資源(CPU、內存、網絡)上限直接擴容即可,或上下游吞吐量上限則優化上下游;
- 可測試性:線性增加壓測併發量,即可壓測出服務響應時間均值和吞吐量的臨界值,不存在線程池的臨界值、預熱等問題。
當然,不可否認相比Servlet技術棧,NIO技術棧的生態還有較大差距,編程便利程度和編程思維的切換,也是不小的門檻。因此,本人從實際場景出發,研究並輸出以下指導,以降低大家對NIO的使用難度。
2-快速上手
java項目通常是Spring MVC配合Spring Cloud OpenFeign使用,雖然Spring官方和OpenFeign都沒有提供同步非阻塞的完整微服務解決方案,但是推薦了一套第三方組件,使用或改造已有項目也像Spring Cloud OpenFeign一樣簡單
第一步:引入POM依賴
<dependency> <groupId>com.playtika.reactivefeign</groupId> <artifactId>feign-reactor-spring-cloud-starter</artifactId> <!-- Java8需要用3.2.11以下版本 --> <!-- Java11需要用3.3.0版本 --> <!-- Java17需要用4.x.x版本 --> <!-- 因示例使用了Java8,故選用3.2.11版本 --> <version>3.2.11</version> <!-- 由於源碼starter只是個聲明瞭依賴的pom,所以這裏也必須聲明依賴類型爲pom,否則會提示下載不到依賴包 --> <type>pom</type> </dependency>
第二步:Spring-boot註解聲明啓用ReactiveFeign
@SpringBootApplication ... @EnableReactiveFeignClients public class SpringWebApplication { public static void main(String[] args) { SpringApplication.run(SpringWebApplication.class, args); } }
第三步:新增異步接口聲明,或改造原Feign接口聲明
@ReactiveFeignClient(name = "mathServiceAsync") public interface FeignMathServiceAsync { @GetMapping("/add") Mono<Integer> add(@RequestParam int a, @RequestParam int b); @GetMapping("/minus") Mono<Integer> minus(@RequestParam int a, @RequestParam int b); @GetMapping("/multiply") Mono<Integer> multiply(@RequestParam int a, @RequestParam int b); @GetMapping("/divide") Mono<Integer> divide(@RequestParam int a, @RequestParam int b); @PostMapping("/jsonCalc") Mono<BinaryIntegerExpression> jsonCalc(@RequestBody BinaryIntegerExpression expression); }
第四步:在業務代碼中使用NIO發起並行調用並同步返回
public String callFeignAsync() { // 第一步:聲明調用鏈 Mono<Integer> m1 = feignMathServiceAsync.add(1, 2).cache(); Mono<Integer> m2 = feignMathServiceAsync.minus(3, 4).cache(); Mono<Integer> m3 = feignMathServiceAsync.multiply(5, 6).cache(); Mono<Integer> m4 = feignMathServiceAsync.divide(7, 8).cache(); // 第二步:提前觸發請求 Mono.when(m1, m2, m3, m4).subscribe(); // 第三步:阻塞並拿到結果進行業務處理。如果請求有什麼異常,在調用block()方法時可以捕獲到 try { return "" + m1.block() + m2.block() + m3.block() + m4.block(); } catch (Throwable e) { ... } }
補充說明:
- subscribe()和block()方法:同屬反應式編程的終結操作,只有調用了終結操作纔會真正發出請求,但subcribe()不阻塞調用者,block()則會阻塞調用者;
- Mono.when(m1, m2, m3, m4).subscribe():同時讓4個請求發起,也可以分別調用m1.subscribe();m2.subscribe();...;
- cache()方法:緩存前面的結果,因爲每對反應式鏈路調用一次終結操作,就會執行一次調用鏈,subscribe()和block()兩次終結操作一般情況下會觸發兩次請求,cache()方法則緩存了前面代碼的執行結果,cache()之前的代碼不再被多次執行。
反應式編程採用了回壓(Back Pressure)的概念,可以理解爲一步一步從後向前索要結果並完成當前操作,所有非終結操作,只是聲明處理代碼,而只有終結操作才真正觸發整個反應式鏈路的執行。
觀察效果
阿里雲ARMS鏈路跟蹤已經對常用的NIO的http客戶端提供了支持,如果接入的阿里雲,可以從調用鏈看出,代碼中的對外請求都並行地發起了,而且能串聯上下游服務的調用鏈:
阿里雲鏈路跟蹤對NIO框架的支持,比對線程池更好,NIO框架不用任何配置,而線程池需要一些配置才能支持調用鏈的關聯。
美中不足在於,對外請求的調用鏈前後各多出了一個無用的NIO方法棧,調用鏈最後每個請求也多出了一個block()調用對應的方法棧。
上手總結
到此,你已經掌握了在Spring MVC + Spring Cloud項目中使用NIO對外並行發起請求的基本方法,無須全棧改造、可以與原來的OpenFeign代碼共存,是不是非常簡單?
3-擴展閱讀
3.1 更多寫法
如果覺得前面即調subscribe()、又調block()的寫法比較醜陋或難以理解,可以藉助咱們異步庫中的變量包裝器,這樣用:
public String callFeignAsync2() { // 第一步:異步取值 Variable<Integer> v1 = new Variable<>(); Variable<Integer> v2 = new Variable<>(); Variable<Integer> v3 = new Variable<>(); Variable<Integer> v4 = new Variable<>(); Mono<Integer> m1 = feignMathServiceAsync.add(1, 2).doOnSuccess(v1::setValue); Mono<Integer> m2 = feignMathServiceAsync.minus(3, 4).doOnSuccess(v2::setValue); Mono<Integer> m3 = feignMathServiceAsync.multiply(5, 6).doOnSuccess(v3::setValue); Mono<Integer> m4 = feignMathServiceAsync.divide(7, 8).doOnSuccess(v4::setValue); // 第二步:發起並等待所有請求完成 try { Mono.when(m1, m2, m3, m4).block(); } catch (Throwable e) { // 捕獲並處理網絡錯誤 return ""; } // 第三步:接下來的代碼就與NIO完全無關了,該怎麼寫怎麼寫 return "" + v1.getValue() + v1.getValue() + v3.getValue() + v4.getValue(); }
以上代碼通過doOnSuccess()方法獲得了返回值,並且通過block()進行了線程同步,每個請求的反應鏈只有一個終結操作、只執行了一次。
這段代碼邏輯更清晰,更易理解,但是代碼行數反而更多,大家自行取捨。
3.2 錯誤示例
3.2.1 請求串行執行
對反應式編程不熟悉的同學,很容易將代碼寫成下面這樣,或者認爲下面的代碼會並行執行(包括我在內):
public String callFeignSync() { // 取值 Mono<Integer> m1 = feignMathServiceAsync.add(1, 2); Mono<Integer> m2 = feignMathServiceAsync.minus(3, 4); Mono<Integer> m3 = feignMathServiceAsync.multiply(5, 6); Mono<Integer> m4 = feignMathServiceAsync.divide(7, 8); // 用值 return "" + m1.block() + m2.block() + m3.block() + m4.block(); }
問題:請求會按block()的調用順序串行執行
解讀:只有終結操作block()被調用的時候,纔會讓整個反應鏈行動起來,纔會真正發起請求並等待返回值,上面的“請求”代碼只是聲明處理鏈路。
3.2.2 錯誤地觸發了兩次請求
理解了前例的問題之後,順勢就會想到,就是使用一個不阻塞的終結方法,提前觸發請求,再阻塞取值:
public String mistakeCallFeignAsync() { // 取值 Mono<Integer> m1 = feignMathServiceAsync.add(1, 2); Mono<Integer> m2 = feignMathServiceAsync.minus(3, 4); Mono<Integer> m3 = feignMathServiceAsync.multiply(5, 6); Mono<Integer> m4 = feignMathServiceAsync.divide(7, 8); // 提前發出請求 m1.subscribe(); m2.subscribe(); m3.subscribe(); m4.subscribe(); // 用值 return "" + m1.block() + m2.block() + m3.block() + m4.block(); }
問題:每個請求會被執行兩次,一共發出4*2=8次對外請求
解讀:終結操作每調用一次,就意味着反應鏈會執行一次,上面的代碼有兩次終結操作subscribe()和block(),因此會發出兩次請求,正確的做法,是像快速入門那樣使用cache()緩存結果。
3.3 直接使用Spring WebClient
如果想不通過Feign的方式調用外部服務,Spring提供了WebClient用於代替傳統阻塞式客戶端
第一步:添加依賴
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency>
第二步:創建全局共享的WebClient實例(可選)
@Configuration public class WebClientConfig { @Bean public WebClient createWebClient() { return WebClient.create("http://localhost:8081/api"); } }
第三步:使用WebClient並行發起多個請求
public String multiAsyncCallByWebClient() { Mono<String> m1 = webClient.get() .uri("/add?a=1&b=2") .retrieve() .bodyToMono(String.class) .cache(); Mono<String> m2 = webClient.get() .uri("/minus?a=3&b=4") .retrieve() .bodyToMono(String.class) .cache(); Mono<String> m3 = webClient.get() .uri("/multiply?a=5&b=6") .retrieve() .bodyToMono(String.class) .cache(); Mono<String> m4 = webClient.get() .uri("/divide?a=7&b=8") .retrieve() .bodyToMono(String.class) .cache(); m1.subscribe(); m2.subscribe(); m3.subscribe(); m4.subscribe(); return m1.block() + m2.block() + m3.block() + m4.block(); }
不難看出,使用WebClient發起請求也很簡單,只是相比Feign和ReactorFeign,http請求的細節需要自己處理。