RPC 核心,萬變不離其宗

yes的練級攻略發表於2020-12-24

微信搜 「yes的練級攻略」乾貨滿滿,不然來掐我,回覆【123】一份20W字的演算法刷題筆記等你來領。 個人文章彙總:https://github.com/yessimida/yes 歡迎 star !

Hola,我是 yes。

在瞭解 Dubbo 之前有必要先來剖析一波 RPC ,先搞清 RPC 原理再去深入瞭解 Dubbo 會起到事半功倍的效果。

理解核心原理很重要,市面上所有 RPC 框架都逃不過這些核心。

搞清原理之後再看 Dubbo 就會有我說的那個熟悉感和“認可感”。

其實 RPC 不僅僅用在我們平日微服務的呼叫中,在很多和網路通訊相關的場景都能用到 RPC。

比如訊息佇列客戶端和 Broker 之間的互動,還有和一些其他中介軟體的互動都會用到  RPC。

你可能會說沒啊?哪有 RPC 呼叫?

嘿嘿,你看這就是 RPC 的作用,讓你無感知地完成了遠端通訊。

其實在這篇我被噴的上“熱門”的文章中已經提過一次 RPC,也提到了 HTTP 和 RPC 的區別,不過那次的主角是 HTTP 。

這次我們們深入剖析一波 RPC,從根上來理解一波。

來,上車!

正文

RPC 全稱是 Remote Procedure Call ,即遠端過程呼叫,其對應的是我們的本地呼叫。

遠端其實指的就是需要網路通訊,可以理解為呼叫遠端機器上的方法。

那可能有人說:我用 HTTP 呼叫不就是遠端呼叫了,那不也叫 RPC 了?

不是的,RPC 的目的是:讓我們呼叫遠端方法像呼叫本地方法一樣無差別。

來看下程式碼就很清晰,比如本來沒有拆分服務都是本地呼叫的時候方法是這樣寫的:

    public String getSth(String str) {  
         return yesService.get(str);  
    }  

如果 yesSerivce 被拆分出去,此時需要遠端呼叫了,如果用 HTTP 方式,可能就是:

    public String getSth(String str) {  
        RequestParam param = new RequestParam();  
        ......  
        return HttpClient.get(url, param,.....);  
    }  

此時需要關心遠端服務的地址,還需要組裝請求等等,而如果採用 RPC 呼叫那就是:

   public String getSth(String str) {  
        // 看起來和之前呼叫沒差?哈哈沒唬你,  
        // 具體的實現已經搬到另一個服務上了,這裡只有介面。  
        // 看完下面就知道了。  
         return yesService.get(str);    
    }  

所以說  RPC 其實就是用來遮蔽遠端呼叫網路相關的細節,使得遠端呼叫和本地呼叫使用一致,讓開發的效率更高。

在瞭解了 RPC 的作用之後,我們來看看 RPC 呼叫需要經歷哪些步驟。

RPC 呼叫基本流程

按上面的例子來說,yesService 服務實現被移到了遠端服務上,本地沒有具體的實現只有一個介面。

那這時候我們需要呼叫 yesService.get(str) ,該怎麼辦呢?

我們所要做的就是把傳入的引數和呼叫的介面全限定名通過網路通訊告知到遠端服務那裡。

然後遠端服務接收到引數和介面全限定名就能選中具體的實現並進行呼叫。

業務處理完之後再通過網路返回結果,這就搞定了!

上面的操作這些就是由yesService.get(str) 觸發的。

不過我們知道 yesService 就是一個介面,沒有實現的,所以這些操作是怎麼來的?

是通過動態代理來的。

RPC 會給介面生成一個代理類,所以我們呼叫這個介面實際呼叫的是動態生成的代理類,由代理類來觸發遠端呼叫,這樣我們呼叫遠端介面就無感知了。

動態代理想必大家都比較熟悉,最常見的就是 Spring 的 AOP 了,涉及的有 JDK 動態代理和 cglib。

在 Dubbo 中用的是 Javassist,至於為什麼用這個其實樑飛大佬已經寫了部落格說明了。

他當時對比了 JDK 自帶的、ASM、CGLIB(基於ASM包裝)、Javassist。

經過測試最終選用了 Javassist。

樑飛:最終決定使用JAVAASSIST的位元組碼生成代理方式。雖然ASM稍快,但並沒有快一個數量級,而JAVAASSIST的位元組碼生成方式比ASM方便,JAVAASSIST只需用字串拼接出Java原始碼,便可生成相應位元組碼,而ASM需要手工寫位元組碼。

可以看到選擇一個框架的時候效能是一方面,易用性也很關鍵。

說回 RPC 。

現在我們知道動態代理遮蔽了 RPC 呼叫的細節,使得使用者無感知的呼叫遠端服務,那呼叫的細節有哪些呢?

序列化

像我們的請求引數都是物件,有時候是定義的  DTO ,有時候是 Map ,這些物件是無法直接在網路中傳輸的。

你可以理解為物件是“立體”的,而網路傳輸的資料是“扁平”的,最終需要轉化成“扁平”的二進位制資料在網路中傳輸。

你想想,各物件分配在記憶體不同位置,各種引用,這看起來是不是有種立體的感覺?

最終都是要變成一段01組成的數字傳輸給對方,這種就01組成的數字看起來是不是很“扁平”?

把物件轉化成二進位制資料的過程稱為序列化,把二進位制資料轉化成物件的過程稱為反序列化。

當然如何選擇序列化格式也很重要。

比如採用二進位制的序列化格式資料更加緊湊,採用 JSON 等文字型序列化格式可讀性更佳,排查問題比較方便。

還有很多序列化選擇,一般需要綜合考慮通用性、效能、可讀性和相容性。

具體本文就不分析了,之後再專門寫一篇分析各種序列化協議的。

RPC 協議

剛才也提到了只有二進位制資料才能在網路中傳輸,那一堆二進位制在底層看來是連起來的,它可不會管你哪些資料是哪個請求的。

但接收方得知道呀,不然就不能順利的把二進位制資料還原成對應的一個個請求了。

於是就需要定義一個協議,來約定一些規範,制定一些邊界使得二進位制資料可以被還原。

比如下面一串數字按照不同位數來識別得到的結果是不同的。

所以協議其實就定義了到底如何構造和解析這些二進位制資料。

我們的引數肯定比上面的複雜,因為引數值長度是不定的,而且協議常常伴隨著升級而擴充套件,畢竟有時候需要加一些新特性,那麼協議就得變了。

一般 RPC 協議都是採用協議頭+協議體的方式。

協議頭放一些後設資料,包括:魔法位、協議的版本、訊息的型別、序列化方式、整體長度、頭長度、擴充套件位等。

協議體就是放請求的資料了。

通過魔法位可以得知這是不是我們們約定的協議,比如魔法位固定叫 233 ,一看我們就知道這是 233 協議。

然後協議的版本是為了之後協議的升級。

從整體長度和頭長度我們就能知道這個請求到底有多少位,前面多少位是頭,剩下的都是協議體,這樣就能識別出來,擴充套件位就是留著日後擴充套件備用。

貼一下 Dubbo 協議:

可以看到有 Magic 位,請求  ID, 資料長度等等。

網路傳輸

組裝好資料就等著傳送了,這時候就涉及網路傳輸了。

網路通訊那就離不開網路 IO 模型了。

網路 IO 分為這四種模型,具體以後單獨寫文章分析,這篇就不展開了。

一般而言我們用的都是 IO 多路複用,因為大部分 RPC 呼叫場景都是高併發呼叫,IO 複用可以利用較少的執行緒 hold 住很多請求。

一般 RPC 框架會使用已經造好的輪子來作為底層通訊框架。

例如 Java 語言的都會用 Netty ,人家已經封裝的很好了,也做了很多優化,拿來即用,便捷高效。

小結

RPC 通訊的基礎流程已經講完了,看下圖:

響應返回就沒畫了,反正就是倒著來。

我再用一段話來總結一下:

服務呼叫方,面向介面程式設計,利用動態代理遮蔽底層呼叫細節將請求引數、介面等資料組合起來並通過序列化轉化為二進位制資料,再通過 RPC 協議的封裝利用網路傳輸到服務提供方。

服務提供方根據約定的協議解析出請求資料,然後反序列化得到引數,找到具體呼叫的介面,然後執行具體實現,再返回結果。

這裡面還有很多細節。

比如請求都是非同步的,所以每個請求會有唯一 ID,返回結果會帶上對應的 ID, 這樣呼叫方就能通過 ID 找到對應的請求塞入相應的結果。

有人會問為什麼要非同步,那是為了提高吞吐。

當然還有很多細節,會在之後剖析 Dubbo 的時候提到,結合實際中介軟體體會才會更深。

真正工業級別的 RPC

以上提到的只是 RPC 的基礎流程,這對於工業級別的使用是遠遠不夠的。

生產環境中的服務提供者都是叢集部署的,所以有多個提供者,而且還會隨著大促等流量情況動態增減機器。

因此需要註冊中心,作為服務的發現。

呼叫者可以通過註冊中心得知服務提供者們的 IP 地址等元資訊,進行呼叫。

呼叫者也能通過註冊中心得知服務提供者下線。

還需要有路由分組策略,呼叫者根據下發的路由資訊選擇對應的服務提供者,能實現分組呼叫、灰度釋出、流量隔離等功能。

還需要有負載均衡策略,一般經過路由過濾之後還是有多個服務提供者可以選擇,通過負載均衡策略來達到流量均衡。

當然還需要有異常重試,畢竟網路是不穩定的,而且有時候某個服務提供者也可能出點問題,所以一次呼叫出錯進行重試,較少業務的損耗。

還需要限流熔斷,限流是因為服務提供者不知道會接入多少呼叫者,也不清楚每個呼叫者的呼叫量,所以需要衡量一下自身服務的承受值來進行限流,防止服務崩潰。

而熔斷是為了防止下游服務故障導致自身服務呼叫超時阻塞堆積而崩潰,特別是呼叫鏈很長的那種,影響很大。

比如A=>B=>C=>D=>E,然後 E 出了故障,你看ABCD四個服務就傻等著,慢慢的資源就佔滿了就崩了,全崩。

大致就是以上提到的幾點,不過還能細化,比如負載均衡的各種策略、限流到底是限制總流量還是根據每個呼叫者指定限流量,還是上自適應限流等等。

這個在之後分析 Dubbo 的時候都會提到,等著哈。

最後

我之前面過一個同學,兩年經驗,簡歷寫著熟悉 Spring Cloud Alibaba 然後瞭解 Dubbo 。

我問他 RPC 的呼叫原理,他問我什麼是 RPC,沒聽過這個名詞。

這就太浮在表面了。

理解原理還是很重要的,像我上面提到的動態代理也不是一定是要的,像 C++ 就沒有動態代理, gRPC 框架用的是程式碼生成。

反正最終只要能遮蔽呼叫細節,不需要使用者關心即可,至於用什麼方式達到這個目的,影響不大。

還有上面提到面向介面,其實有時候就是沒介面,例如一些服務閘道器,暴露出 HTTP 呼叫的方式給呼叫者來呼叫後端 RPC 服務。

閘道器是要接入很多後端服務的,所以不可能依賴後端的介面,不然就不靈活了。

這裡就有個泛化呼叫的概念。

其實只要你理解了請求方無非就是告知服務提供方我要調哪個方法,引數都是哪些,你就能很容易的理解什麼叫泛化呼叫。

也就能理解其實不需要介面我們也能進行 RPC 呼叫。

具體泛化呼叫是什麼之後寫  Dubbo 會提到。

所以聽起來好像很高階的玩意,如果你理解了本質,其實也就這麼點東西。

萬變不離其宗。

歡迎關注我的公眾號【yes的練級攻略】,更多硬核文章等你來讀。

更多文章可看我的文章彙總:https://github.com/yessimida/yes 歡迎 star !


我是 yes,從一點點到億點點,歡迎在看、轉發、留言,我們下篇見。

相關文章