RPC協議


什么是 RPC?

初步印象

  RPC的語義是遠程過程調用,在一般的印象中,就是將一個服務調用封裝在一個本地方法中,讓調用者像使用本地方法一樣調用服務。而具體的實現是通過調用方和服務方各自的stub基於TCP長連接進行數據交互達成。

  上面的解釋似雲里霧里,僅僅了解到這種程度是遠遠不夠的,還需要更進一步,以相對底層抽象的視角來理解RPC。

三個特點

  廣義上來講,所有本應用程序外的調用都可以歸類為RPC,不管是分布式服務,第三方服務的HTTP接口,還是讀寫Redis的一次請求。從抽象的角度來講,它們都一樣是RPC,由於不在本地執行,都有三個特點:

    •   需要事先約定調用的語義(接口語法)
    •   需要網絡傳輸
    •   需要約定網絡傳輸中的內容格式

  以一次Redis調用為例,執行redis.set("rpc", 1)這個調用,其中:

    •   set及其參數("rpc", 1),就是對調用語義的約定,由redis的API給出
    •   RedisServer會監聽一個服務端口,通過TCP傳輸內容,用異步事件驅動實現高並發
    •   底層庫會約定數據如何進行編解碼,如何標識命令和參數,如何表示結果,如何表示數據的結尾等等

  這三個特點都是因為調用不在本地而不得不衍生出來的問題,也因此決定了RPC的形態。所有的RPC解決方案都是在解決這三個問題,不斷地在提出更加優良的解決方案,試圖達到更好的性能,更低的使用成本。 本文也將圍繞這三個特點來展開內容。

  常規的RPC一般都是基於一個大的內部服務,進行分布式拆分,由於其語義上以本地方法的作為入口,那么天然的就更傾向於具備高性能、支持復雜參數和返回值、跨語言等特性。下圖是RPC調用的過程示意圖:

協議約定

  Stub會負責封裝命令和參數,並以特定的數據格式進行打包。其中命令、參數和返回值的需要客戶端和服務端的Stub事先進行協商,雙方都需要維護一份完全一樣的方法及參數列表。

  更進一步需要知道對方如何進行壓縮打包,如何壓縮結構體,如何壓縮Class等等,並嚴格按照標准進行解壓縮,中途有任何一絲的差錯都會的導致調用失敗。

  所以一般情況下可能會對數據進行一定的校驗,同時要協商方法、參數等錯誤時如何返回。 這是一個比較繁雜的過程,混合了調用語法內容解壓縮兩部分內容,可被理解為協議約定問題。

網絡傳輸

  搞定了協議約定問題后,接下來就是要通過Runtime進行內容傳輸了,這又是一大難題,一般是需要通過Socket編程來實現,一般使用TCP或UDP來進行傳輸。

  如果是UDP可以用數據報來區分每一次請求和回復;但如果是字節流的TCP,就需要用特殊的方式來標示請求或回復的末尾,用來區分不同的請求。

  同時當對調用性能有要求時,可能會使用Socket的異步編程模型,消除等待中的消耗,這會引入事件機制,通過狀態機來解析處理或回復請求。當出現超時、丟包等情況時還進行做重試、重傳、報錯等等。

  拆解到協議約定和網絡傳輸時,就會發現實現RPC調用是一件非常復雜的事情,自己實現千難萬難,接下來就了解一番已有的,針對協議約定和網絡傳輸的解決方案。

ONC RPC

  ONC RPC是相對早期的RPC解決方案,通過外部數據表示法來約定數據的壓縮方式:

  被傳輸的所有內容都需要按上面的約定進行壓縮,這樣接收方就能順利地按照同樣的協議進行解壓縮。雖然解決了壓縮問題,但無法識別哪些是函數、哪些是類、哪些是參數等,因此需要自定義一份文件,規定什么樣的數據是函數、類等。然后客戶端和服務器按按此規則划分參數等

  對於命令和參數列表的約定,會創建一份公共的協議文件,里面會定義被調用的方法名,參數列表,對象的列表等等。然后用特定工具將文件進行解析生成Stub程序,客戶端和服務端都同時將Stub程序放在代碼中。比如對方法名進行編號,將GetUserName(userId)這個方法編號為1,在調用時就將1傳輸給服務端,服務端通過協議文件就知道調用的是方法,這樣節省了大量的空間。

 

  傳輸則通過對應的類庫實現,通過Socket編程實現的非常復雜的解決方案,包含了超時、失敗、異常處理、狀態轉換等等功能。

  這種早期的方案,在每一次代碼更新時都需要重新生成Stub程序,調用方和服務方都需要及時更新對應的文件。給某一個方法增加一個默認參數,都需要全部使用者同步升級,從迭代或多版本的場景看來,這是一場噩夢。

RESTfull HTTP JSON

  RESTfull是一種資源狀態轉換的架構風格,也可以用來實現RPC, 互聯網對HTTP超廣泛的支持,使得這相當簡單,也是大多數情況下的首選。

  通過HTTP協議來進行內容傳輸,Header用來約定編碼、body大小等,彼此以\r\n來分割,Header和body之間通過兩個連續的\r\n來間隔,能很容易地區分不同的請求。

  通過Url和對應參數來標示要調用的方法和參數。在body中用JSON對內容進行編碼,極易跨語言,不需要約定特定的復雜編碼格式和Stub文件。在版本兼容性上非常友好,擴展也很容易。

  眾多的優點使得這種方案廣受歡迎。不過也有其無法避開的弱點:

    •   HTTP的header和Json的數據冗余和低壓縮率使得傳輸性能差
    •   JSON難以表達復雜的參數類型,如結構體等

gRPC HTTP2.0 Protobuf

  gRPC是一款RPC框架,在性能和版本兼容上做了提升和讓步:

    •   Protobuf進行數據編碼,提高數據壓縮率
    •   使用HTTP2.0彌補了HTTP1.1的不足
    •   同樣在調用方和服務方使用協議約定文件,提供參數可選,為版本兼容留下緩沖空間

  protobuf是一款用C++開發的跨語言、二進制編碼的數據序列化協議,以超高的壓縮率著稱。它和早期的RPC方案一樣,需要雙方維護一個協議約束文件,以.proto結尾,使用proto命令對文件進行解析,會生成對應的Stub程序,客戶端和服務端都需要保存這份Stub程序用來進行編解碼。對於這種協議文件導致的升級困難問題,protobuf 3 中定義的字段默認都是可選的(可以不傳),在接口升級時,部分客戶端不需要升級自己的Stub程序。

// ***.proto文件
syntax = "proto3";
package id_rpc;
message BusinessType {// 定義參數
  string name = 1; //參數字段
}

message UniqueId {// 定義返回值
  uint64 id = 1;
  string business_type = 2;
}

service UniqueIdService {// 定義服務,可以調用 MakeUniqueId 方法
  rpc MakeUniqueId(BusinessType) returns (UniqueId){}
}

  對於JSON等文本形式的序列化協議來說,protobuf能有幾十倍空間和性能提升, 比如傳輸123,文本類的需要3個字節(ascii 31 32 33)來傳輸,而二進制類只需要一個字節(01111011)就可以表示。

  同時protobuf會維護.proto文件,這樣在解析文件生成Stub程序時,可以對方法名等進行編號,傳輸時只傳編號,而不用傳方法的名字,這又可以節省大量字節,還有其他更多的精巧壓縮方法,比如TLV,詳情可以參考proto encoding

  解決了數據體積的問題后,gRPC使用HTTP2來改善傳輸性能。 HTTP2是在HTTP1.1的基礎上做了大量的改進,HTTP1.1雖然引入了KeepAlive復用TCP連接,但仍然有很多問題:

    •   使用KeepAlive的請求是串行執行(非pipeline時),pipeline時有隊首阻塞問題
    •   每次都需要發送不必要的Header
    •   不能雙向通信

  簡單補充一下pipeline,HTTP1.1中允許多個請求復用連接,同時可以一口氣將請求全部發出去,不用一個返回后再發送第二個,提升並發性。而服務端需要將請求的結果,按照pipeline中發送的順序進行順序返回,如果靠前的請求阻塞了,那么靠后請求返回就會被動等待。

  HTTP2解決了這些問題,引入了新的機制:

    •   在兩端建立Header索引表,每次只發送索引,減小header體積
    •   建立虛擬通道,將數據拆分成多個流,每個流有自己的ID和優先級,並且流可以雙向傳輸,每個流可以進一步拆成多個幀。可以將多個請求切成不同的流發送,每個流可以獨立返回,避開1.1的串行或隊首阻塞問題。

  同時,基於HTTP2的數據流機制,gRPC客戶端和服務端可以實現批量操作優化,客戶端可以攢一些請求,一口氣發給服務端,服務端也可以批量返回結果,借此實現流式rpc。

RabbitMQ

r  pc作為一種極常見的服務形態,以異步和解耦著稱的mq也自然不會放過這個場景,rabbitmq就為rpc調用提供了很好的支持。

  一般和rabbitmq的交互場景是發布或消費消息,是一個單向的過程,而rpc卻是一種同步的雙向交互過程,在使用上有些差異。要理解rabbitmq如何實現rpc,還是可以從上面三個抽象的特點出發,萬變不離其宗。

如何協商調用語義

  mq中的消息是從exchange分發到queue中,消費端在特定的queue中獲取消息,rpc的請求依然要走這條路徑: 方法調用->exchange->queue->方法執行

  創建一個direct類型的exchange,讓每個rpc方法對應一個queue,這個exchange通過routing_key分發到對應的queue中, 讓特定的消費者來實際執行rpc方法。這樣rpc方法的語義就通過queue來約定,而方法的參數,可以放入消息中。

如何將結果傳遞回客戶端

  方法調用->exchange->queue->方法執行, 這條路是單行道,方法執行端執行完rpc方法后不能按照原路將結果返回給客戶端。要實現結果回傳,就得再開辟一條結果回傳端->exchange->queue->結果等待端路徑,一條用來發送rpc請求,另一條用來回傳rpc結果,方法調用者和方法執行者都會扮演生產者和消費者。

  rabbitmq中有回調隊列(Callback queue)來實現調用結果回傳,同時有關聯ID(Correlation Id)來唯一標識每一份調用結果

  rpc調用方在發送請求時,會在數據中帶上回調隊列信息(routing_key)和關聯ID,rpc執行方在執行完方法后,就將關聯ID摻入執行結果中,並將結果通過exchange發往回調隊列(通過routing_key)。 rpc調用方在發送請求后,緊接着在設置的回調隊列中等結果就行。 整個過程(兩條路徑)共用同一個exchange。

  調用參數和調用結果的打包可以用JSON,protobuf等等,協商一致即可。完整示例代碼

  使用mq實現rpc,有其獨有的優勢,rpc執行端可以輕松地橫向擴展,rpc調用方也不用考慮負載均衡,沿襲了mq解耦的優點。不過對於調用超時,執行端崩潰等等情況得做額外處理。調用方在等待結果時需要設置超時間,高性能的rpc調用還需要調用方能異步高效地通過關聯ID將請求結果儲存起來,等待調用者獲取。Spring框架的實現方案就是用一個HashMap將結果保存起來,等待調用者以關聯ID作為key來取結果。

服務發現

  上面介紹了常見的rpc實現方案,而RPC作為分布式服務的一種形態,本身還有服務發現和負載均衡的問題需要解決(mq除外)。服務注冊和發現作為分布式系統的基礎設施之一,有很多的解決方案,例如: zookeeper、ETCD、envoy等等。服務啟動后向公開的注冊中心進行服務注冊,服務調用方在調用前,向注冊中心查詢對應服務的地址,這又是一項工程,不過一般的請求規模也不需要這套東西,更高端的方案,得期待ServiceMesh的進一步發展了。

總結

  RPC從抽象的角度來看,具有需要約定調用語法需要約定編碼格式需要網絡傳輸這三大特點,進一步可以歸納為協議約定問題網絡傳輸問題,本文的主要內容都圍繞這兩大問題,並介紹常見的解決方案,借此對建立RPC更深的理解。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM