做者:李林鋒
連接:https://www.infoq.cn/article/q3iPeYQv-uF5YsISq62c
複製代碼
1. 異步的一些常見誤區數據庫
1.1. 常見的理解誤區apache
在將近 10 年的平臺中間件研發歷程中,咱們的平臺和業務經歷了從 C++ 到 Java,從同步的 BIO 到非阻塞的 NIO,以及純異步的事件驅動 I/O(AIO)。服務器也從 Web 容器逐步遷移到了內部更輕量、更高性能的微容器。服務之間的 RPC 調用從最初的同步阻塞式調用逐步升級到了全棧異步非阻塞調用。編程
每次的技術演進都會涉及到大量底層平臺技術以及上層編程模型的切換,在實際工做中,我發現不少同窗對通訊框架的異步和 RPC 調用的異步理解有誤,比較典型的錯誤理解包括:緩存
1.我使用的是 Tomcat8,由於 Tomcat8 支持 NIO,因此我基於 Tomcat 開發的 HTTP 調用都是異步的。性能優化
2.由於咱們的 RPC 框架底層使用的是 Netty、Vert.X 等異步框架,因此咱們的 RPC 調用天生就是異步的。bash
3.由於咱們底層的通訊框架不支持異步,因此 RPC 調用也沒法異步化。服務器
1.2. 混淆 Tomcat NIO 與 HTTP 服務的異步化網絡
1.2.1. Tomcat 的 BIO 和 NIO數據結構
在 Tomcat6.X 版本對 NIO 提供比較完善的支持以前,做爲 Web 服務器,Tomcat 以 BIO 的方式接收並處理客戶端的 HTTP 請求,當併發訪問量比較大時,就容易發生擁塞等性能問題,它的工做原理示意以下所示:多線程
圖 1 採用 BIO 作 HTTP 服務器的 Web 容器
傳統同步阻塞通訊(BIO)面臨的主要問題以下:
1.性能問題:一鏈接一線程模型致使服務端的併發接入數和系統吞吐量受到極大限制。
2.可靠性問題:因爲 I/O 操做採用同步阻塞模式,當網絡擁塞或者通訊對端處理緩慢會致使 I/O 線程被掛住,阻塞時間沒法預測。
3.可維護性問題:I/O 線程數沒法有效控制、資源沒法有效共享(多線程併發問題),系統可維護性差。
從上圖咱們能夠看出,每當有一個新的客戶端接入,服務端就須要建立一個新的線程(或者重用線程池中的可用線程),每一個客戶端鏈路對應一個線程。當客戶端處理緩慢或者網絡有擁塞時,服務端的鏈路線程就會被同步阻塞,也就是說全部的 I/O 操做均可能被掛住,這會致使線程利用率很是低,同時隨着客戶端接入數的不斷增長,服務端的 I/O 線程不斷膨脹,直到沒法建立新的線程。
同步阻塞 I/O 致使的問題沒法在業務層規避,必須改變 I/O 模型,才能從根本上解決這個問題。
Tomcat 6.X 提供了對 NIO 的支持,經過指定 Connector 的 protocol=「org.apache.coyote.http11.Http11NioProtocol」,就能夠開啓 NIO 模式,採用 NIO 以後,利用 Selector 的輪詢以及 I/O 操做的非阻塞特性,能夠實現使用更少的 I/O 線程處理更多的客戶端鏈接,提高吞吐量和主機的資源利用率。Tomcat 8.X 以後提供了對 NIO2.0 的支持,默認也開啓了 NIO 通訊模式。
1.2.2. Tomcat NIO 與 Servlet 異步
事實上,Tomcat 支持 NIO,與 Tomcat 的 HTTP 服務是不是異步的,沒有必然關係,這個能夠從兩個層面理解:
1.HTTP 消息的讀寫:即使採用了 NIO,HTTP 請求和響應的消息處理仍然多是同步阻塞的,這與協議棧的具體策略有關係。從 Tomcat 官方文檔能夠看到,Tomcat 6.X 版本即使採用 Http11NioProtocol,HTTP 請求消息和響應消息的讀寫仍然是 Blocking 的。
2.HTTP 請求和響應的生命週期管理:本質上就是 Servlet 是否支持異步,若是 Servlet 是 3.X 以前的版本,則 HTTP 協議的處理仍然是同步的,這就意味着 Tomcat 的 Connector 線程須要同時處理 HTTP 請求消息、執行 Servlet Filter、以及業務邏輯,而後將業務構造的 HTTP 響應消息發送給客戶端,整個 HTTP 消息的生命週期都採用了同步處理方式。
Tomcat 與 Servlet 的版本配套關係以下所示:
Servlet**** 規範版本Tomcat**** 版本JDK**** 版本4.09.0.X8+3.18.0.X7+3.07.0.X6+2.56.0.X5+2.45.5.X1.4+2.34.1.X1.3+
表 1 Tomcat 與 Servlet 的版本配套關係
1.2.3. Tomcat NIO 與 HTTP 服務調用
以 Tomcat 6.X 版本爲例,Tomcat HTTP 協議消息和後續的業務邏輯處理以下所示(Tomcat HTTP 協議處理很是複雜,爲了便於理解,圖示作了簡化):
圖 2 Tomcat 6.X 的 HTTP 消息接入和處理原理
從上圖能夠看出,HTTP 請求消息的讀取、Servlet Filter 的執行、業務 Servlet 的邏輯處理,以及 HTTP 響應都是由 Tomcat 的 NIO 線程(Processor,實際更復雜些,這裏作了簡化處理)作處理,即 HTTP 消息的處理週期中都是串行同步執行的,儘管 Tomcat 使用 NIO 作接入,HTTP 服務端的處理仍然是同步的。它的弊端很明顯,若是 Servlet 中的業務邏輯處理比較複雜,則會致使 Tomcat 的 NIO 線程被阻塞,沒法讀取其它 HTTP 客戶端發送的 HTTP 請求消息,致使客戶端讀響應超時。
可能有讀者會有疑問,途中標識處,爲何不能建立一個業務線程池,由業務線程池異步處理業務邏輯,處理完成以後再填充 HttpServletResponse,發送響應。實際上在 Servlet 支持異步以前是沒法實現的,緣由是每一個響應對象只有在 Servlet 的 service 方法或 Filter 的 doFilter 方法範圍內有效,該方法一旦調用完成,Tomcat 就認爲本次 HTTP 消息處理完成,它會回收 HttpServletRequest 和 HttpServletResponse 對象再利用,若是業務異步化以後再處理 HttpServletResponse,拿到的實際就不是以前請求消息對應的響應,會發生各類非預期問題,所以,業務邏輯必須在 service 方法結束前執行,沒法作異步化處理。
若是使用的是支持 Servlet3.0+ 版本的 Tomcat,經過開啓異步處理模式,就能解決同步調用面臨的各類問題,在後續章節中會有詳細介紹。
1.2.4. 總結
經過以上分析咱們能夠看出,除了將 Tomcat 的 Connector 配置成 NIO 模式以外,還須要 Tomcat 配套的 Servlet 版本支持異步化(3.0+),同時還須要在業務 Servlet 的代碼中開啓異步模式,HTTP 服務端纔可以實現真正的異步化:I/O 異步以及業務邏輯處理的異步化。
1.3. 混淆 RPC 異步與 I/O 異步
1.3.1. Java 的各類 I/O 模型
不少人喜歡將 JDK 1.4 提供的 NIO 框架稱爲異步非阻塞 I/O,可是,若是嚴格按照 UNIX 網絡編程模型和 JDK 的實現進行區分,實際上它只能被稱爲非阻塞 I/O,不能叫異步非阻塞 I/O。在早期的 JDK 1.4 和 1.5 update10 版本以前,JDK 的 Selector 基於 select/poll 模型實現,它是基於 I/O 複用技術的非阻塞 I/O,不是異步 I/O。在 JDK 1.5 update10 和 Linux core2.6 以上版本,Sun 優化了 Selctor 的實現,它在底層使用 epoll 替換了 select/poll,上層的 API 並無變化,能夠認爲是 JDK NIO 的一次性能優化,可是它仍舊沒有改變 I/O 的模型。相關優化的官方說明以下圖所示:
圖 3 JDK1.5_update10 支持 epoll
由 JDK1.7 提供的 NIO 2.0 新增了異步的套接字通道,它是真正的異步 I/O,在異步 I/O 操做的時候能夠傳遞信號變量,當操做完成以後會回調相關的方法,異步 I/O 也被稱爲 AIO。NIO 類庫支持非阻塞讀和寫操做,相比於以前的同步阻塞讀和寫,它是異步的,所以不少人仍然習慣於稱 NIO 爲異步非阻塞 I/O,在此不須要太咬文嚼字。
不一樣的 I/O 模型因爲線程模型、API 等差異很大,因此用法的差別也很是大。各類 I/O 模型的優缺點對好比下:
同步阻塞 I/O(BIO)非阻塞 I/O(NIO)異步 I/O(AIO)客戶端個數:I/O 線程1:1
表 2 Java 各類 I/O 模型優缺點對比
1.3.2. RPC 工做原理
RPC 的全稱是 Remote Procedure Call,它是一種進程間通訊方式。容許像調用本地服務同樣調用遠程服務,它的具體實現方式能夠不一樣,例如 Spring 的 HTTP Invoker,Facebook 的 Thrift 二進制私有協議通訊。
RPC 框架的目標就是讓遠程過程(服務)調用更加簡單、透明,RPC 框架負責屏蔽底層的傳輸方式(TCP 或者 UDP)、序列化方式(XML/Json/ 二進制)和通訊細節。框架使用者只須要了解誰在什麼位置提供了什麼樣的遠程服務接口便可,開發者不須要關心底層通訊細節和調用過程。
RPC 框架的調用原理圖以下所示:
圖 4 RPC 框架原理圖
RPC 框架實現的幾個核心技術點總結以下:
1.遠程服務提供者須要以某種形式提供服務調用相關的信息,包括但不限於服務接口定義、數據結構,或者中間態的服務定義文件,例如 Thrift 的 IDL 文件,WS-RPC 的 WSDL 文件定義,甚至也能夠是服務端的接口說明文檔;服務調用者須要經過必定的途徑獲取遠程服務調用相關信息,例如服務端接口定義 Jar 包導入,獲取服務端 IDL 文件等。
2.遠程代理對象:服務調用者調用的服務實際是遠程服務的本地代理,對於 Java 語言,它的實現就是 JDK 的動態代理,經過動態代理的攔截機制,將本地調用封裝成遠程服務調用。
3.通訊:RPC 框架與具體的協議無關,例如 Spring 的遠程調用支持 HTTP Invoke、RMI Invoke,MessagePack 使用的是私有的二進制壓縮協議。
4.序列化:遠程通訊,須要將對象轉換成二進制碼流進行網絡傳輸,不一樣的序列化框架,支持的數據類型、數據包大小、異常類型以及性能等都不一樣。不一樣的 RPC 框架應用場景不一樣,所以技術選擇也會存在很大差別。一些作的比較好的 RPC 框架,能夠支持多種序列化方式,有的甚至支持用戶自定義序列化框架(Hadoop Avro)。
1.3.3. RPC 異步與 I/O 的異步
RPC 異步與 I/O 的異步沒有必然關係,固然,在大多數場景下,RPC 框架底層會使用異步 I/O,實現全棧異步。
RPC 框架異步調度模型以下所示:
圖 5 異步 RPC 調用原理
異步 RPC 調用的關鍵點有 2 個:
1.不能阻塞調用方線程:接口調用一般會返回 Future 或者 Promise 對象,表明異步操做的一個回調對象,當異步操做完成以後,由 I/O 線程回調業務註冊的 Listener,繼續執行業務邏輯。
2.請求和響應的上下文關聯:除了 HTTP/1.X 協議,大部分二進制協議的 TCP 鏈路都是多路複用的,請求和響應消息的發送和接收順序是無序的。因此,異步 RPC 調用須要緩存請求和響應的上下文關聯關係,以及響應須要使用到的消息上下文。
正如上圖所示,當 RPC 調用請求消息發送到 I/O 線程的消息隊列以後,業務線程就能夠返回,至於 I/O 線程採用同步仍是異步的方式讀寫消息,與 RPC 調用的同步和異步沒必然的關聯關係,固然,採用異步 I/O, 總體性能和可靠性會更好一些,因此如今大部分的 RPC 框架底層採用的都是異步 / 非阻塞 I/O。以 Netty 爲例,不管 RPC 調用是同步仍是異步,只要調用消息發送接口,Netty 都會將發送請求封裝成 Task,加入到 I/O 線程的消息隊列中統一處理,相關代碼以下所示:
異步回調的一些實現策略:
1.Future/Promise:比較經常使用的有 JDK8 以前的 Future,經過添加 Listener 來作異步回調,JDK8 以後一般使用 CompletableFuture,它支持各類複雜的異步處理策略,例如自定義線程池、多個異步操做的編排、有返回值和無返回值異步、多個異步操做的級聯操做等。
2.線程池 +RxJava: 最經典的實現就是 Netflix 開源的 Hystrix 框架,使用 HystrixCommand(建立線程池)作一層異步封裝,將同步調用封裝成異步調用,利用 RxJava API,經過訂閱的方式對結果作異步處理,它的工做原理以下所示:
圖 6 利用 Hystix 作異步化封裝
1.3.4. 總結
經過以上分析能夠得出以下結論:
1.RPC 異步指的是業務線程發起 RPC 調用以後,不用同步等待服務端返回應答,而是當即返回,當接收到響應以後,回調執行業務的後續邏輯。
2.I/O 的異步是通訊層的具體實現策略,使用異步 I/O 會帶來性能和可靠性提高,可是與 RPC 調用是同步仍是異步沒必然關係。
2. RPC 同步與異步調用
不少 RPC 框架同時支持同步和異步調用,下面對同步和異步 RPC 調用的工做原理以及優缺點進行分析。
2.1. 同步 RPC 調用
2.1.1. 同步 RPC 調用流行的緣由
在傳統的單體架構中,以 Spring + Struts + MyBatis + Tomcat 爲例,業務邏輯一般由各類 Controller(Spring Bean)來實現,它的邏輯架構以下所示:
圖 7 基於 MVC 的傳統單體架構
在單體架構中,本地方法調用都是同步方式,並且定義形式每每都是以下形式(請求參數 + 方法返回值):
String sayHello(String hello);
切換到 RPC 框架以後,不少都支持經過 XML 引用或者代碼註解的方式引用遠端的 RPC 服務,能夠像使用本地接口同樣調用遠程的服務,這種開發模式與傳統單體應用開發模式類似,編程簡單,學習和切換成本低,調試也比較方便,所以,同步 RPC 調用成爲大部分項目的首選。
以 XML 方式導入遠端服務提供者的 API 接口示例以下:
複製代碼
<xxx:reference id="echoService" interface="edu.neu.EchoService" />
<bean class="edu.neu.xxxAction" init-method="start">
<property name="echoService" ref="echoService" />
</bean>
複製代碼
導入以後業務就能夠直接在代碼中調用 echoService 接口,與傳統單體應用調用本地 Spring Bean 同樣,無需感知遠端服務接口的具體部署位置信息。
2.1.2. 同步 RPC 調用工做原理
同步 RPC 調用是最經常使用的一種服務調用方式,它的工做原理以下:客戶端發起遠程 RPC 調用請求,用戶線程完成消息序列化以後,將消息投遞到通訊框架,而後同步阻塞,等待通訊線程發送請求並接收到應答以後,喚醒同步等待的用戶線程,用戶線程獲取到應答以後返回。它的工做原理圖以下所示:
它的工做原理圖以下所示:
圖 8 同步 RPC 調用
主要流程以下:
1.消費者調用服務端發佈的接口,接口調用由 RPC 框架包裝成動態代理,發起遠程 RPC 調用。
2.消費者線程調用通訊框架的消息發送接口以後,直接或者間接調用 wait() 方法,同步阻塞等待應答。
3.通訊框架的 I/O 線程經過網絡將請求消息發送給服務端。
4.服務端返回應答消息給消費者,由通訊框架負責應答消息的反序列化。
5.I/O 線程獲取到應答消息以後,根據消息上下文找到以前同步阻塞的業務線程,notify() 阻塞的業務線程,返回應答給消費者,完成 RPC 調用。
2.1.3. 同步 RPC 調用面臨的挑戰
同步 RPC 調用的主要缺點以下:
1.線程利用率低:線程資源是系統中很是重要的資源,在一個進程中線程總數是有限制的,提高線程使用率就可以有效提高系統的吞吐量,在同步 RPC 調用中,若是服務端沒有返回響應,客戶端業務線程就會一直阻塞,沒法處理其它業務消息。
2.糾結的超時時間:RPC 調用的超時時間配置是個比較棘手的問題。若是配置的過大,一旦服務端返回響應慢,就容易把客戶端掛死。若是配置的太小,則超時失敗率會增長。即使參考測試環境的平均和最大時延來設置,因爲生產環境數據、硬件等與測試環境的差別,也很難一次設置的比較合理。另外,考慮到客戶端流量的變化、服務端依賴的數據庫、緩存、第三方系統等的性能波動,這都會致使服務調用時延發生變化,所以,依靠超時時間來保障系統的可靠性,難度很大。
3.雪崩效應:在一個同步調用鏈中,只要下游某個服務返回響應慢,會致使故障沿着調用鏈向上遊蔓延,最終把整個系統都拖垮,引發雪崩,示例以下:
圖 9 同步 RPC 調用級聯故障
2.2. 異步 RPC 調用
2.2.1. 異步 RPC 調用工做原理
JDK 原生的 Future 主要用於異步操做,它表明了異步操做的執行結果,用戶能夠經過調用它的 get 方法獲取結果。若是當前操做沒有執行完,get 操做將阻塞調用線程。在實際項目中,每每會擴展 JDK 的 Future,提供 Future-Listener 機制,它支持主動獲取和被動異步回調通知兩種模式,適用於不一樣的業務場景。
基於 JDK 的 Future-Listener 機制,能夠實現異步 RPC 調用,它的工做原理以下所示:
圖 10 異步 RPC 調用原理圖
異步 RPC 調用的工做流程以下:
1.消費者調用 RPC 服務端發佈的接口,接口調用由 RPC 框架包裝成動態代理,發起遠程 RPC 調用。
2.通訊框架異步發送請求消息,若是沒有發生 I/O 異常,返回。
3.請求消息發送成功後,I/O 線程構造 Future 對象,設置到 RPC 上下文中。
4.用戶線程經過 RPC 上下文獲取 Future 對象。
5.構造 Listener 對象,將其添加到 Future 中,用於服務端應答異步回調通知。
6.用戶線程返回,不阻塞等待應答。
7.服務端返回應答消息,通訊框架負責反序列化等。
8.I/O 線程將應答設置到 Future 對象的操做結果中。
9.Future 對象掃描註冊的監聽器列表,循環調用監聽器的 operationComplete 方法,將結果通知給監聽器,監聽器獲取到結果以後,繼續後續業務邏輯的執行,異步 RPC 調用結束。
2.2.2. 異步 RPC 調用編程模型的優化
Java8 的 CompletableFuture 提供了很是豐富的異步功能,它能夠幫助用戶簡化異步編程的複雜性,經過 Lambda 表達式能夠方便的編寫異步回調邏輯,除了普通的異步回調接口,它還提供了多個異步操做結果轉換以及與或等條件表達式的編排能力,方便對多個異步操做結果進行邏輯編排。
CompletableFuture 提供了大約 20 類比較實用的異步 API,接口定義示例以下:
圖 11 CompletableFuture 異步 API 定義
利用 JDK 的 CompletableFuture 與 Netty 的 NIO,能夠很是方便的實現異步 RPC 調用,設計思路以下所示:
圖 12 異步 RPC 調用設計原理
異步 RPC 調用的工做流程以下:
1.消費者經過 RPC 框架調用服務端。
2.Netty 異步發送 HTTP 請求消息,若是沒有發生 I/O 異常就正常返回。
3.HTTP 請求消息發送成功後,I/O 線程構造 CompletableFuture 對象,設置到 RPC 上下文中。
4.用戶線程經過 RPC 上下文獲取 CompletableFuture 對象。
5.不阻塞用戶線程,當即返回 CompletableFuture 對象。
6.經過 CompletableFuture 編寫 Function 函數,在 Lambda 表達式中實現異步回調邏輯。
7.服務端返回 HTTP 響應,Netty 負責反序列化工做。
8.Netty I/O 線程經過調用 CompletableFuture 的 complete 方法將應答設置到 CompletableFuture 對象的操做結果中。
9.CompletableFuture 經過 whenCompleteAsync 等接口異步執行業務回調邏輯,實現 RPC 調用的異步化。
2.2.3. 異步 RPC 調用的優點
異步 RPC 調用相比於同步調用有兩個優勢:
1.化串行爲並行,提高 RPC 調用效率,減小業務線程阻塞時間。
2.化同步爲異步,避免業務線程阻塞。
假如一次閱讀首頁訪問須要調用多個服務接口,採用同步調用方式,它的調用流程以下所示:
圖 13 同步調用多個服務場景
因爲每次 RPC 調用都是同步阻塞,三次調用總耗時爲 T = T1 + T2 + T3。下面看下采用異步 RPC 調用以後的優化效果:
圖 14 異步多服務調用場景
採用異步 RPC 調用模式,最後調用三個異步操做結果 Future 的 get 方法同步等待應答,它的總執行時間 T = Max(T1, T2,T3),相比於同步 RPC 調用,性能提高效果很是明顯。
2.3. 總結
2.3.1. 異步 RPC 調用性能未必會更高
一般在實驗室環境中測試,因爲網絡時延小、模擬業務又一般比較簡單,因此異步 RPC 調用並不必定性能更高,但在生產環境中,異步 RPC 調用每每性能更高、可靠性也更好。主要緣由是網絡環境相對惡劣、真實的 RPC 調用耗時更多等,這種惡劣的運行環境正好能夠發揮異步 RPC 調用的優點。
2.3.2. 最佳實踐
服務框架支持多種 RPC 調用方式,在實際項目中如何選擇呢?建議從如下幾個角度進行考慮:
1.下降業務 E2E 時延:業務調用鏈是否太長、某些服務是否不太可靠,須要對服務調用流程進行梳理,看是否能夠經過異步並行 RPC 調用來提高調用效率,下降 RPC 調用時延。
2.可靠性角度:某些業務調用鏈上的關鍵服務不太可靠,一旦出故障會致使大量線程資源被掛住,能夠考慮使用異步 RPC 調用防止故障擴散。
3.傳統的 RPC 調用:服務調用比較簡單,對時延要求不高的場景,則能夠考慮同步 RPC 調用,下降編程複雜度,以及調試難度,提高開發效率。