關於 Spring-WebFlux 的一些想法
本文是本人在知乎提問 spring webflux現在看來是否成功? 下的回答,其他回答也很精彩,如果感興趣可以查看
現在基於 spring web 的同步微服務有一個非常大的缺陷就是:相對於基於 spring-webflux 的異步微服務,基於 spring-web 的同步微服務沒有很好的處理客戶端有請求超時配置的情況。當客戶端請求超時時,客戶端會直接返回超時異常,但是調用的服務端任務,在基於 spring-web 的同步微服務並沒有被取消,基於 spring-webflux 的異步微服務是會被取消的。目前,還沒有很好的辦法在同步環境中可以取消這些已經超時的任務。
Spring-weflux 目前最大的問題,在於很多框架,包括 JDK 本身,有很多基於 Thread 的 Context,例如 Thread local 這種。還有就是 Java Log 框架的 MDC 的實現,一般都是基於 ThreadLocal 的 Map.還有就是像 redisson 的分佈式鎖,它讓每個線程生成唯一id並和線程綁定,解鎖的時候會檢查。 但是這種設計,與 Spring-Webflux 的 Context 很難兼容。可以看看 Spring cloud sleuth 在 Spring-Webflux 中加入鏈路信息上下文,並保持,有多麻煩,而且,還有不少的 bug 和漏掉的點,參考:
- Spring Cloud Gateway 沒有鏈路信息,我 TM 人傻了(上)
- Spring Cloud Gateway 沒有鏈路信息,我 TM 人傻了(中)
- Spring Cloud Gateway 沒有鏈路信息,我 TM 人傻了(下)
還有一點比較麻煩,就是和現有的各種阻塞鎖的設計,不兼容,因為響應式編程需要非阻塞。這需要重構成隊列排序消費來解決並發競爭,工作量也很大。
然後就是官方數據庫 IO 庫,不是 NIO 這個問題。不論是Java自帶的Future框架,還是 Spring WebFlux,還是 Vert.x,他們都是一種非阻塞的基於Ractor模型的框架(後兩個框架都是利用netty實現)。在阻塞編程模式里,任何一個請求,都需要一個線程去處理,如果io阻塞了,那麼這個線程也會阻塞在那。但是在非阻塞編程裏面,基於響應式的編程,線程不會被阻塞,還可以處理其他請求。舉一個簡單例子:假設只有一個線程池,請求來的時候,線程池處理,需要讀取數據庫 IO,這個 IO 是 NIO 非阻塞 IO,那麼就將請求數據寫入數據庫連接,直接返回。之後數據庫返回數據,這個鏈接的 Selector 會有 Read 事件準備就緒,這時候,再通過這個線程池去讀取數據處理(相當於回調),這時候用的線程和之前不一定是同一個線程。這樣的話,線程就不用等待數據庫返回,而是直接處理其他請求。這樣情況下,即使某個業務 SQL 的執行時間長,也不會影響其他業務的執行。但是,這一切的基礎,是 IO 必須是非阻塞 IO,也就是 NIO(或者 AIO)。官方JDBC沒有 NIO,只有 BIO 實現。這樣無法讓線程將請求寫入鏈接之後直接返回,必須等待響應。但是也就解決方案,就是通過其他線程池,專門處理數據庫請求並等待返回進行回調,也就是業務線程池 A 將數據庫 BIO 請求交給線程池B處理,讀取完數據之後,再交給 A 執行剩下的業務邏輯。這樣A也不用阻塞,可以處理其他請求。但是,這樣還是有因為某個業務 SQL 的執行時間長,導致B所有線程被阻塞住隊列也滿了從而A的請求也被阻塞的情況,這是不完美的實現。真正完美的,需要 JDBC 實現 NIO。當然,也可以使用其他異步響應式的三方庫,但是非官方的,兼容性以及是否更新及時,還有使用限制什麼的也很麻煩。
最後,提一下 Java 本身的 Project Loom,我簡單研究過他的實現原理:
簡單總結即:在虛擬線程中運行的 Java 同步網絡 API 會將底層原生 Socket 切換到非阻塞模式。當 Java 代碼啟用一個 I/O 請求並且這個請求沒有立即完成(原生 socket 返回 EAGAIN – 代表”未就緒”/”會阻塞”)的時候,這個底層 socket 會被註冊到一個 JVM 內部事件通知機制(Poller),並且虛擬線程會被 parked。當底層 I/O 操作就緒的時候(有相關事件會到達 Poller),虛擬線程會 unparked 並且底層的 Socket 操作會被重試處理。同步 Java 網絡 API 已經被重新實現,相關的 JEP 包括 JEP 353 和 JEP 373. 在虛擬線程中運行時,不能立即完成的 I/O 操作將導致虛擬線程被 parked 。當 I/O 就緒時,虛擬線程將被 unparked。這個實現相對於當前的異步非阻塞 I/O 實現代碼來看,更加簡單易用,隱藏了很多業務不關心的實現細節。
Project Loom 解決了主要的網絡 IO 阻塞問題,並且基本不用改現有代碼就能實現纖程,用阻塞的代碼風格實現非阻塞的代碼(而且和現在的基於 Thread 的上下文框架兼容)。是目前的 Java 架構師 Goetz 最看重的特性之一,目前 Java 17 的很多新特性其實就是為這個 Project Loom 的發佈鋪路,可以看看 Nicolai 和 Goetz 大神的這個視頻,從 19:17 秒開始:
Brian Goetz: “I think Project Loom is going to kill Reactive Programming”(哈哈,有點過於樂觀帶節奏了,不過值得觀望)
不過,雖然期望中是僅需要下面這種代碼就可以把現有的線程和線程池替換成虛擬線程:
Thread thread = Thread.ofVirtual().name("duke").unstarted(runnable);
ThreadFactory factory = Thread.ofVirtual().factory();
ExecutorService b = Executors.newVirtualThreadPool();
但是還有很多問題需要解決:
- ThreadLocal 相關的類,由於虛擬線程會無限制的生成,ThreadLocal 的生成也需要重新設計:首先是很多 JDK 中的框架基於 ThreadLocal 的 Probe 實現,例如 ThreadLocalRandom 的初始 Seed。第二是 ThreadLocal 的使用可能會導致 GC 壓力增大,因為虛擬線程可以無限制的生成。
- 依然阻塞實際線程的地方:在同步鎖的阻塞還是會阻塞實際的線程,還有文件 IO 等。
- 修改以上帶來的 bug 以及安全問題,由於這些修改動了 JDK 的一些框架的根本,沒有經過實際線上應用之前,僅憑單元測試和壓測可能很難發現一些細節問題。
不過,現在的 Java 已經在為 Project loom 鋪路了,例如:
- Java 13 中的 Reimplement the Legacy Socket API 以及 Java 15 中的 Reimplement the Legacy DatagramSocket API 也是為了優化 Project Loom 對於 網絡 IO 的兼容
- Java 18 中的 JEP 416: Reimplement Core Reflection with Method Handles 使用句柄重構反射,減少 Loom 虛擬線程對於 native 棧幀的調用(因為虛擬線程會非常大量,如果每個都訪問 native 線程棧則性能有嚴重問題)
- Java 18 中的 JEP 418: Internet-Address Resolution SPI 為了解決 DNS 解析時阻塞虛擬線程的實際負載線程的問題
- 其他還有 JEP draft: Scope Locals 為了歸一化區域本地變量(例如 ThreadLocal),這樣一部分目標也是為了 Loom 的實現
微信搜索「我的編程喵」關注公眾號,每日一刷,輕鬆提升技術,斬獲各種offer: