Spring項目中使用NIO並行調用http接口指南

1-背景

後臺BFF層服務爲了SEO,涉及大量對底層數據的聚合,如果按照過程化編程,串行執行請求數據再聚合會造成很高的延遲,因此我們往往大量使用多線程技術並行化多個查詢,來減少單個請求的響應時間。

多線程一定程度上也能達成通過並行化提升請求響應速度的需求,但是實際使用過程中存在一些問題:

  1. 配置調優複雜:不同場景、不同併發量、上下游不同壓力都需要爲線程池配置不同的線程參數,很容易因爲參數配置不佳,造成請求阻塞或高資源消耗;
  2. 資源開銷大:線程創建時消耗時間和CPU資源,而池化的線程又會浪費內存,爲突發流量配置大的線程上限,不僅浪費CPU資源又浪費內存;
  3. 動態性差:線程池參數固定,很難動態適應線上流量的突然變化。

因此,使用NIO同步非阻塞模型,用少量線程非處理大量請求,就很有意義:

  1. 無須配置調優:NIO同步非阻塞模型的少量線程就能處理海量的請求,無須配置調優——但是要注意不要在非阻塞鏈路上執行阻塞邏輯;
  2. 資源開銷可控:NIO同步非阻塞模型本身所需的線程數很少而且固定,運行時開銷可控——當然每個請求所佔用的堆內存和NIO直接內存不可避免,需全鏈路(服務內)非阻塞等手段優化;
  3. 動態伸縮性:NIO的理論上限遠高於其它方面的瓶頸,如達到資源(CPU、內存、網絡)上限直接擴容即可,或上下游吞吐量上限則優化上下游;
  4. 可測試性:線性增加壓測併發量,即可壓測出服務響應時間均值和吞吐量的臨界值,不存在線程池的臨界值、預熱等問題。

當然,不可否認相比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請求的細節需要自己處理。

4-參考文檔

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章