最近線上碰到一點小問題,分析其原因發現是出在對 RPC 使用上的一些細節掌握不夠清晰導致。很多時候我們做業務開發會把 RPC 當作黑盒機制來使用,但若不對黑盒的工作原理有個基本掌握,也容易犯一些誤用的微妙錯誤。
雖然曾經已經寫過一篇《RPC 的概念模型與實現解析》 從概念模型和實現細節上講述了 RPC 的原理,這一篇就從使用上的一些注意點來捋一捋吧。
同步
RPC 的呼叫通常為了方便使用,會被偽裝成普通方法呼叫的形式。但實際二者之間存在巨大的差異,程式內的方法呼叫的時間量級是 ns(納秒),而程式間的 RPC 方法呼叫時間量級通常是 ms(毫秒),它們之間差著 10 的六次方呢。RPC 的冰山底部透檢視如下:
但在目前流行的微服務架構模式下,跨服務的同步呼叫隱藏著巨大的風險。一般微服務化架構下,通常一個業務的呼叫會跨 N(N 一般大於 2) 個服務程式,整個呼叫鏈路上的同步呼叫等待的瓶頸會由最慢(或脆弱)的服務決定,A-B-C 像這樣一個鏈路,A 同步呼叫 B 並等待返回,B 同步呼叫 C 並等待返回,以此類推,就像一組齒輪鏈,級級傳動,這很容易產生雪崩效應。若 C 服務掛住了,會導致前面的服務全部都因為等待超時而佔用大量不必要的執行緒資源。
因此,微服務架構下,內部主服務鏈之間的 RPC 呼叫需要非同步化,服務之間的呼叫請求和等待結果相互之間解耦,如下是一個服務鏈路呼叫的示意圖:
外部使用者通過服務閘道器(API Gateway)發起呼叫並等待結果,隨後閘道器派發呼叫請求給後續服務,其主呼叫鏈路為 A-B-C,其內部為非同步呼叫,鏈路上不等待,最後由 C 返回結果給服務閘道器。其中 B 又依賴兩個子服務,S1 和 S2,B 需要 S1 和 S2 的返回結果才能發起 C 呼叫,因此在支線上 B 針對 S1 和 S2 呼叫就需要是同步的。
非同步
RPC 的同步呼叫確保請求送達對方並收到對方響應,若沒有收到響應,框架則丟擲 Timeout 異常。這種情況下呼叫方是無法確定呼叫是成功還是失敗的,需要根據業務場景(是否可重入,冪等)選擇重試和補償策略。
而 RPC 的非同步呼叫意味著 RPC 框架不阻塞呼叫方執行緒,呼叫方不需要立刻拿到返回結果,甚至呼叫方根本就不關心返回結果。RPC 的非同步互動場景示意圖如下:
在上面的示意圖中,對於是否需要返回值的非同步請求,其中的細微差異在於是否返回一個 Future 物件給呼叫方,以便未來(Future)呼叫方可以再通過它來獲取返回值。正是因為這種 Future 機制的存在,所以針對前面(圖2)中 S1 和 S2 的呼叫就可以採用一種非同步並行的呼叫機制來提升並行性和效能,如下圖所示:
這樣呼叫 S1 和 S2 的總時間就由最慢的一個服務響應時間來決定了。(上圖中其實呼叫 S1 和 S2 不可能做到同時,有細微的時間差異,但相對跨程式的呼叫本身來說這種差異基本忽略不計。)
執行緒
RPC 的執行緒模型一般如下所示:
其中,RPC 的網路層通常採用非阻塞型 I/O 模型,放在 Java 的實現語境下就是 NIO 了。而 RPC 框架通常共享一個 I/O 執行緒池,處理所有連線上的 I/O 事件派發。通常業務事件會派發到內部的一個固定大小(可配置)的業務執行執行緒池,再由業務執行執行緒呼叫應用實現層的程式碼。
但有些 RPC 框架在實現客戶端的 I/O 執行緒模型時,也採用了針對每個不同的服務端一個獨立的 I/O 執行緒池,這樣就變成了下面這個圖所示:
這帶來了一個潛在的問題,在一個客戶端需要連線大量服務端時(這在基於 RPC 實現的服務框架中很常見),客戶端的 I/O 執行緒池數就和需連線的服務數相等。在現在的微服務部署模式下,一般一個服務部署在一個 Docker 容器中,同一個服務會有很多個(幾十上百個)程式共同組成叢集提供服務,這樣就導致客戶端 I/O 執行緒數可能會很多。
而在 Docker 環境下 Java 的 Runtime.availableProcessors() 獲取的 CPU 數量實際是物理機的,而不是 Docker 隔離的核數。另外,像 Netty 這樣的網路框架經常預設是基於 CPU 核數來啟動預設的 I/O 執行緒數的,所以導致針對每個服務的客戶端會啟動 CPU 核數個 I/O 執行緒再乘上服務例項數,這個執行緒數量也是頗為客觀,出現單程式好幾千固化的執行緒,執行緒排程和切換的成本頗高,另外服務的水平擴充套件性也有一定的受限。這也是需要注意的另一點。
...
在曾經那篇《RPC 的概念模型與實現解析》 的的結尾,我曾寫到:
無論 RPC 的概念是如何優雅,但是“草叢中依然有幾條蛇隱藏著”,只有深刻理解了 RPC 的本質,才能更好地應用。
所以這一篇大概就是抓出了幾條隱藏著的蛇吧。
寫點文字,畫點畫兒,記錄成長瞬間。
微信公眾號「瞬息之間」,既然遇見,不如一起成長。