天天看點

深入淺出 RPC - 深入篇

《深入篇》我們主要圍繞 rpc 的功能目标和實作考量去展開,一個基本的 rpc 架構應該提供什麼功能,滿足什麼要求以及如何去實作它?

rpc 功能目标

rpc 的主要功能目标是讓建構分布式計算(應用)更容易,在提供強大的遠端調用能力時不損失本地調用的語義簡潔性。為實作該目标,rpc 架構需提供一種透明調用機制讓使用者不必顯式的區分本地調用和遠端調用,在前文《淺出篇》中給出了一種實作結構,基于 stub 的結構來實作。下面我們将具體細化 stub 結構的實作。

rpc 調用分類

rpc 調用分以下兩種:

1. 同步調用

   客戶方等待調用執行完成并傳回結果。

2. 異步調用

   客戶方調用後不用等待執行結果傳回,但依然可以通過回調通知等方式擷取傳回結果。

   若客戶方不關心調用傳回結果,則變成單向異步調用,單向調用不用傳回結果。

異步和同步的區分在于是否等待服務端執行完成并傳回結果。

《淺出篇》給出了一個比較粗粒度的 rpc 實作概念結構,這裡我們進一步細化它應該由哪些元件構成,如下圖所示。

深入淺出 RPC - 深入篇

rpc 服務方通過 rpcserver 去導出(export)遠端接口方法,而客戶方通過 rpcclient 去引入(import)遠端接口方法。客戶方像調用本地方法一樣去調用遠端接口方法,rpc 架構提供接口的代理實作,實際的調用将委托給代理rpcproxy 。代理封裝調用資訊并将調用轉交給rpcinvoker 去實際執行。在用戶端的rpcinvoker 通過連接配接器rpcconnector 去維持與服務端的通道rpcchannel,并使用rpcprotocol 執行協定編碼(encode)并将編碼後的請求消息通過通道發送給服務方。

rpc 服務端接收器 rpcacceptor 接收用戶端的調用請求,同樣使用rpcprotocol 執行協定解碼(decode)。解碼後的調用資訊傳遞給rpcprocessor 去控制處理調用過程,最後再委托調用給rpcinvoker 去實際執行并傳回調用結果

上面我們進一步拆解了 rpc 實作結構的各個元件組成部分,下面我們詳細說明下每個元件的職責劃分。

rpc 實作分析

在進一步拆解了元件并劃分了職責之後,這裡以在 java 平台實作該 rpc 架構概念模型為例,詳細分析下實作中需要考慮的因素。

導出遠端接口

導出遠端接口的意思是指隻有導出的接口可以供遠端調用,而未導出的接口則不能。在 java 中導出接口的代碼片段可能如下:

我們可以導出整個接口,也可以更細粒度一點隻導出接口中的某些方法,如:

java 中還有一種比較特殊的調用就是多态,也就是一個接口可能有多個實作,那麼遠端調用時到底調用哪個?這個本地調用的語義是通過 jvm 提供的引用多态性隐式實作的,那麼對于 rpc 來說跨程序的調用就沒法隐式實作了。如果前面demoservice 接口有 2 個實作,那麼在導出接口時就需要特殊标記不同的實作,如:

上面 demo2 是另一個實作,我們标記為 "demo2" 來導出,那麼遠端調用時也需要傳遞該标記才能調用到正确的實作類,這樣就解決了多态調用的語義。

導入遠端接口與用戶端代理

導入相對于導出遠端接口,用戶端代碼為了能夠發起調用必須要獲得遠端接口的方法或過程定義。目前,大部分跨語言平台 rpc 架構采用根據 idl 定義通過 code generator 去生成 stub 代碼,這種方式下實際導入的過程就是通過代碼生成器在編譯期完成的。我所使用過的一些跨語言平台 rpc 架構如 corbar、webservice、ice、thrift 均是此類方式。

代碼生成的方式對跨語言平台 rpc 架構而言是必然的選擇,而對于同一語言平台的 rpc 則可以通過共享接口定義來實作。在 java 中導入接口的代碼片段可能如下:

在 java 中 'import' 是關鍵字,是以代碼片段中我們用 refer 來表達導入接口的意思。這裡的導入方式本質也是一種代碼生成技術,隻不過是在運作時生成,比靜态編譯期的代碼生成看起來更簡潔些。java 裡至少提供了兩種技術來提供動态代碼生成,一種是 jdk 動态代理,另外一種是位元組碼生成。動态代理相比位元組碼生成使用起來更友善,但動态代理方式在性能上是要遜色于直接的位元組碼生成的,而位元組碼生成在代碼可讀性上要差很多。兩者權衡起來,個人認為犧牲一些性能來獲得代碼可讀性和可維護性顯得更重要。

協定編解碼

用戶端代理在發起調用前需要對調用資訊進行編碼,這就要考慮需要編碼些什麼資訊并以什麼格式傳輸到服務端才能讓服務端完成調用。出于效率考慮,編碼的資訊越少越好(傳輸資料少),編碼的規則越簡單越好(執行效率高)。我們先看下需要編碼些什麼資訊:

除了以上這些必須的調用資訊,我們可能還需要一些元資訊以友善程式編解碼以及未來可能的擴充。這樣我們的編碼消息裡面就分成了兩部分,一部分是元資訊、另一部分是調用的必要資訊。如果設計一種 rpc 協定消息的話,元資訊我們把它放在協定消息頭中,而必要資訊放在協定消息體中。下面給出一種概念上的 rpc 協定消息設計格式:

深入淺出 RPC - 深入篇

格式确定後編解碼就簡單了,由于頭長度一定是以我們比較關心的就是消息體的序列化方式。序列化我們關心三個方面:

1. 序列化和反序列化的效率,越快越好。

2. 序列化後的位元組長度,越小越好。

3. 序列化和反序列化的相容性,接口參數對象若增加了字段,是否相容。

上面這三點有時是魚與熊掌不可兼得,這裡面涉及到具體的序列化庫實作細節,就不在本文進一步展開分析了。

傳輸服務

協定編碼之後,自然就是需要将編碼後的 rpc 請求消息傳輸到服務方,服務方執行後傳回結果消息或确認消息給客戶方。rpc 的應用場景實質是一種可靠的請求應答消息流,和 http 類似。是以選擇長連接配接方式的 tcp 協定會更高效,與 http 不同的是在協定層面我們定義了每個消息的唯一 id,是以可以更容易的複用連接配接。

既然使用長連接配接,那麼第一個問題是到底 client 和 server 之間需要多少根連接配接?實際上單連接配接和多連接配接在使用上沒有差別,對于資料傳輸量較小的應用類型,單連接配接基本足夠。單連接配接和多連接配接最大的差別在于,每根連接配接都有自己私有的發送和接收緩沖區,是以大資料量傳輸時分散在不同的連接配接緩沖區會得到更好的吞吐效率。是以,如果你的資料傳輸量不足以讓單連接配接的緩沖區一直處于飽和狀态的話,那麼使用多連接配接并不會産生任何明顯的提升,反而會增加連接配接管理的開銷。

連接配接是由 client 端發起建立并維持。如果 client 和 server 之間是直連的,那麼連接配接一般不會中斷(當然實體鍊路故障除外)。如果 client 和 server 連接配接經過一些負載中轉裝置,有可能連接配接一段時間不活躍時會被這些中間裝置中斷。為了保持連接配接有必要定時為每個連接配接發送心跳資料以維持連接配接不中斷。心跳消息是 rpc 架構庫使用的内部消息,在前文協定頭結構中也有一個專門的心跳位,就是用來标記心跳消息的,它對業務應用透明。

執行調用

client stub 所做的事情僅僅是編碼消息并傳輸給服務方,而真正調用過程發生在服務方。server stub 從前文的結構拆解中我們細分了 rpcprocessor 和 rpcinvoker 兩個元件,一個負責控制調用過程,一個負責真正調用。這裡我們還是以 java 中實作這兩個元件為例來分析下它們到底需要做什麼?

java 中實作代碼的動态接口調用目前一般通過反射調用。除了原生的 jdk 自帶的反射,一些第三方庫也提供了性能更優的反射調用,是以 rpcinvoker 就是封裝了反射調用的實作細節。

調用過程的控制需要考慮哪些因素,rpcprocessor 需要提供什麼樣地調用控制服務呢?下面提出幾點以啟發思考:

rpc 異常處理

無論 rpc 怎樣努力把遠端調用僞裝的像本地調用,但它們依然有很大的不同點,而且有一些異常情況是在本地調用時絕對不會碰到的。在說異常處理之前,我們先比較下本地調用和 rpc 調用的一些差異:

1. 本地調用一定會執行,而遠端調用則不一定,調用消息可能因為網絡原因并未發送到服務方。

2. 本地調用隻會抛出接口聲明的異常,而遠端調用還會跑出 rpc 架構運作時的其他異常。

3. 本地調用和遠端調用的性能可能差距很大,這取決于 rpc 固有消耗所占的比重。

正是這些差別決定了使用 rpc 時需要更多考量。當調用遠端接口抛出異常時,異常可能是一個業務異常,也可能是 rpc 架構抛出的運作時異常(如:網絡中斷等)。業務異常表明服務方已經執行了調用,可能因為某些原因導緻未能正常執行,而 rpc 運作時異常則有可能服務方根本沒有執行,對調用方而言的異常處理政策自然需要區分。

由于 rpc 固有的消耗相對本地調用高出幾個數量級,本地調用的固有消耗是納秒級,而 rpc 的固有消耗是在毫秒級。那麼對于過于輕量的計算任務就并不合适導出遠端接口由獨立的程序提供服務,隻有花在計算任務上時間遠遠高于 rpc 的固有消耗才值得導出為遠端接口提供服務。

總結

至此我們提出了一個 rpc 實作的概念架構,并詳細分析了需要考慮的一些實作細節。無論 rpc 的概念是如何優雅,但是“草叢中依然有幾條蛇隐藏着”,隻有深刻了解了 rpc 的本質,才能更好地應用。

java進階架構 ∣幹貨|學習

深入淺出 RPC - 深入篇
深入淺出 RPC - 深入篇

長按,識别二維碼,加關注

溫馨提示