1. Reactor 對比
1.1 Reactor 執行緒模型
Reactor 執行緒模型就是透過 單個執行緒 使用 Java NIO 包中的 Selector 的 select()方法,進行監聽。當獲取到事件(如 accept、read 等)後,就會分配(dispatch)事件進行相應的事件處理(handle)。
如果要給 Reactor 執行緒模型 下一個更明確的定義,應該是:
Reactor執行緒模式 = Reactor(I/O多路複用)+ 執行緒池
Netty、Redis 使用了此模型,主要是解決 C10K 問題
C10K 問題:伺服器如何支援 10K 個併發連線
1.2 Spring Reactor
Reactor 是 JVM 完全非阻塞的響應式程式設計基礎,響應式程式設計是一種關注資料流和變化傳播的非同步程式設計正規化。這意味著可以透過所採用的程式語言輕鬆地表達靜態(例如陣列)或動態(例如事件發射器)資料流。
Mono<List<String>> cartInfoMono = Mono.just( "songjiyang" )
.map( UserService::findUserByName )
.map( UserService::findUserShoppingCart );
String user = UserService.findUserByName( "songjiyang" );
List<String> userShoppingCart = UserService.findUserShoppingCart( user );
1.3 區別和聯絡
聯絡:
- 兩者都是使用非同步的手段來提高系統的效能
區別:
- Reactor 模型主要非同步的處理新連線、連線和讀寫,而 Spring Reactor 在更高的程式碼級別提供了非同步框架
或者反過來說,新連線、連線和讀寫等事件觸發了 Netty Reactor 的某些管道處理器流程,某些事件觸發了 Spring Reactor 的執行流程,這也是 Reactor(反應器)名稱的由來
2. Java 中的非同步
上面我們一直在講非同步,非同步其實是針對呼叫者的,也就是呼叫者呼叫完方法之後就可以做的別的事情了,Java 中實現非同步就兩種方式:
回撥- 多執行緒
2.1 回撥
回撥其實就是把當前的事情完成之後,後面需要做的事當成函式傳進行,等完成之後呼叫就行
public static void main( String[] args ){
doA( ( next ) -> {
log.info( "doB" );
next.run();
}, () -> log.info( "doC" ) );
}
public static void doA( Consumer<Runnable> next, Runnable nextNext ){
log.info( "doA" );
next.accept( nextNext );
}
// output
15:06:52.818 [main] INFO concurrent.CompleteTest - doA
15:06:52.820 [main] INFO concurrent.CompleteTest - doB
15:06:52.820 [main] INFO concurrent.CompleteTest - doC
回撥是在一個執行緒中來完成的,很容易理解,但問題是回撥太多程式碼就變的很複雜,有回撥地域的問題
回撥只是一種非同步的程式設計方式,本身實現非同步其實還是需要多執行緒,例如單獨起一個監聽執行緒來執行回撥函式,例如 EventListener
如果執行的任務不考慮執行緒安全問題的話,可以使用 CompletableFuture 來解決,會更加易於閱讀
CompletableFuture
.runAsync( ()-> log.info("doA") )
.thenRunAsync( ()-> log.info("doB") )
.thenRunAsync( ()->log.info("doC") )
.get();
// output
15:08:04.407 [ForkJoinPool.commonPool-worker-1] INFO concurrent.CompleteTest - doA
15:08:04.410 [ForkJoinPool.commonPool-worker-1] INFO concurrent.CompleteTest - doB
15:08:04.410 [ForkJoinPool.commonPool-worker-1] INFO concurrent.CompleteTest - doC
CompletableFuture 的 thenRunAsync 也是基於回撥,每個任務 Class 會有一個 next, 多個任務組成一個回撥鏈
Mono.just("")
.doOnNext( (x)-> log.info("doA") )
.doOnNext( (x)-> log.info("doB") )
.doOnNext( (x)-> log.info("doC") )
.block();
15:12:56.160 [main] INFO concurrent.CompleteTest - doA
15:12:56.160 [main] INFO concurrent.CompleteTest - doB
15:12:56.161 [main] INFO concurrent.CompleteTest - doC
2.2 多執行緒
多執行緒的方式,大家應該都很熟悉
- Thread
- ExecutorService 執行緒池
- CompletionService 帶結果佇列的執行緒池
- CompletableFuture 用於任務編排
- Runable、Callable、Future、CompletableFuture
3. Spring Reactor
從上面可以看到一些使用 Reactor 的程式碼中,都可以在原生 JDK 中找到替換,那我們為什麼還需要它呢?
- 可組合和可讀性
- 豐富的操作
- 訂閱之前什麼都不會發生
- 背壓
下面是 Java9 中 Flow 類的類圖,SpringReactor 也是使用這四個類,在 Java9 中已經成了規範
3.1 Publisher
- Mono,提供 0 到 1 個 Item
- Flux,提供 0 到 N 個 Item
釋出者提供 n 個 Item, 經過一些 operator(資料處理操作),完成或者異常中止
核心方法:
- subscribe
3.1.1 建立
Mono<String> noData = Mono.empty();
Mono<String> data = Mono.just("foo");
Flux<Integer> numbersFromFiveToSeven = Flux.range(5, 3);
Mono.fromSupplier( ()->1 );
Mono.fromFuture( CompletableFuture.runAsync( ()-> {} ) );
Flux.create((sink)->{
for( int i = 0; i < 5; i++ ){
sink.next( i ) ;
}
sink.complete();
});
3.1.2 處理
下面這些都稱為 operator,可以很靈活處理其中的 Item
- 轉化 map、flatMap、
- 消費 doOnNext、doNextError、doOnCancel
- 過濾 filter、distinct、take
- 錯誤處理 onErrorReturn、onErrorComplete、onErrorResume、doFinally
- 時間相關 timeout、interval、delay
- 分隔 window、buffer
- 轉同步 block、toStream
3.1.3 訂閱
訂閱然後消費釋出者的內容
subscribe();
subscribe(Consumer<? super T> consumer);
訂閱之後的返回值是Disposable****,可以使用這個物件來取消訂閱,會告訴釋出者停止生產物件,但不保證會立即終止
- 當然可以給 subscribe 傳遞引數,自定義 complete 或者 error 時需要做的時
- 同時可以使用 BaseSubscriber 類來實現訂閱,可以控制消費的數量
3.2 Subscriber
消費者一般不用手動建立,透過 subscribe 傳進 Consumer 函式後,會自動生成一個 LambdaSubscriber,核心方法:
- onSubscribe
- onNext
- onError
- onComplete
3.3 Processor
既是釋出者,又是訂閱者
3.4 Subscription
訂閱,消費者呼叫 subscribe 方法之後可以在 onSubscribe 回撥中獲取,可以請求下一個 Item 或者取消訂閱
- request
- cancel
3.5 Thread 和 Scheduler
沒有指定的情況下:
- 當前的 operator 使用上一個 operator 的執行緒,最先的 operator 使用呼叫 subscribe 的執行緒來執行
Reactor 中使用 Scheduler 來執行流程,類似 ExecutorService
- subscribeOn 可以指定訂閱時使用的執行緒,這樣可以不阻塞的訂閱
- publishOn 指定釋出時使用的執行緒
4. Spring Reactor 最佳化案例
流程中可以最佳化的點:
- 準備資料可以非同步,等需要用的時候在去阻塞獲取,相當於一個 Future
- 召回可以完成之後就去等正排資料,新的問題,如何去重?本來拿一次正排資料,現在拿 N 個召回次資料,請求量是不是會變大,耗時是不是也會增加
- 過濾的準備資料也可以非同步,也就是說某個過濾策略的資料準備好了,就可以去執行過濾了,而且還存在很多不需要依賴資料的過濾策略也需要等
- 一般粗排只需要 1000 條資料,過濾時已經拿夠了 1000 條就可以跳過了
我們上面所說的非同步,其實就是說流程中某些節點是在同時執行的,不必等一個節點完成後再執行另外一個,這其實一個統籌學的問題
4.1 解決方法對比
問題 | Java 原生 | Reactor |
---|---|---|
準備資料非同步 | Future,缺點:1. 需要呼叫方處理異常 2. 不能編排後續流程,eg: 拿完企業資訊後繼續拿企業治理資訊,Future 需要 get 阻塞 | Mono, 使用 onErrorResume 處理異常,使用 map 編排後續流程 |
召回完成拿正排 | 需要一個阻塞佇列,召回把結果往裡面 push,另外一個執行緒從佇列裡面拿同時去取正排資料,需要自己維護 map 來去重,需要迴圈等待到達批次後去取正排 | Flux |
過濾準備資料非同步 | 需要阻塞佇列 | Flux |
粗排取 1000 條 | 非同步執行過濾,把過濾結果放到一個容器中,粗排節點不斷檢視這個容器的結果是否夠 1000 條,夠了就可以執行粗排了 | Flux |
for (StrategyConfig filterConfig : filterConfigList) {
doStrategyFilter(filterChainContext, recommendContext, recRequest, filterConfig, allFilters, partitionContext, partitionTrace);
}
readyStrategyFlux.publishOn(ExecutorServiceHolder.scheduler).doOnNext((readyStrategyName) -> {
try {
List<StrategyConfig> strategyConfigs = strategyNameToConfigs.get(readyStrategyName);
for (StrategyConfig strategyConfig : strategyConfigs) {
doStrategyFilter(filterChainContext, recommendContext, recRequest, strategyConfig, allFilters, partitionContext, partitionTrace);
}
} catch (Exception e) {
LOGGER.error("doOnNext filter error", e);
}
}).blockLast();
這裡的 blockLast 又回到了同步世界,可以很好的和已有的程式碼相容
下面是 20240629 到 20240702 某個場景最佳化過濾階段的耗時對比
pv | qps | tp99 | avg | |
---|---|---|---|---|
實驗組 | 4051865 | 46.90 | 369.00 | 230.88 |
對照組 | 4054074 | 46.92 | 397.00 | 251.55 |
業務指標對比
無明顯波動
5. 總結
Spring Reactor 是一個響應式程式設計框架,非常適合類似 MXN 這樣的流程編排系統,也是 Java 中非同步程式設計的一種補充,但也會有一些其他的問題,例如潛在的執行緒安全問題,已有框架的衝突 ThreadLocal 等
參考
【1】深入 Netty 邏輯架構,從 Reactor 執行緒模型開始(一)-阿里雲開發者社群
【2】Reactor 3 Reference Guide
【3】C10k 問題簡述-CSDN 部落格