RPC最全總結
近幾年的專案中,服務化和微服務化漸漸成為中大型分散式系統架構的主流方式,而 RPC 在其中扮演著關鍵的作用。在平時的日常開發中我們都在隱式或顯式的使用 RPC,一些剛入行的程式設計師會感覺 RPC 比較神祕,而一些有多年使用 RPC 經驗的程式設計師雖然使用經驗豐富,但有些對其原理也不甚了了。缺乏對原理層面的理解,往往也會造成開發中的一些誤用。
本文分上下兩篇《淺出篇》和《深入篇》,其目標就是想嘗試深入淺出的分析下 RPC 本質,我總是這麼認為理解了本質才能更好的應用。
RPC 是什麼?
RPC 的全稱是 Remote Procedure Call 是一種程序間通訊方式。它允許程式呼叫另一個地址空間(通常是共享網路的另一臺機器上)的過程或函式,而不用程式設計師顯式編碼這個遠端呼叫的細節。即程式設計師無論是呼叫本地的還是遠端的,本質上編寫的呼叫程式碼基本相同。
RPC 起源
1. 簡單:RPC 概念的語義十分清晰和簡單,這樣建立分散式計算就更容易。
2. 高效:過程呼叫看起來十分簡單而且高效。
3. 通用:在單機計算中過程往往是不同演算法部分間最重要的通訊機制。
通俗一點說,就是一般程式設計師對於本地的過程呼叫很熟悉,那麼我們把 RPC 作成和本地呼叫完全類似,那麼就更容易被接受,使用起來毫無障礙。Nelson 的論文發表於 30 年前,其觀點今天看來確實高瞻遠矚,今天我們使用的 RPC 框架基本就是按這個目標來實現的。
RPC 結構
Nelson 的論文中指出實現 RPC 的程式包括 5 個部分:
1. User
2. User-stub
3. RPCRuntime
4. Server-stub
5. Server
這 5 個部分的關係如下圖所示
這裡 user 就是 client 端,當 user 想發起一個遠端呼叫時,它實際是通過本地呼叫 user-stub。user-stub 負責將呼叫的介面、方法和引數通過約定的協議規範進行編碼並通過本地的 RPCRuntime 例項傳輸到遠端的例項。遠端 RPCRuntime 例項收到請求後交給 server-stub 進行解碼後發起本地端呼叫,呼叫結果再返回給 user 端。
RPC 實現
Nelson 論文中給出的這個實現結構也成為後來大家參考的標準範本。大約 10 年前,我最早接觸分散式計算時使用的 CORBAR 實現結構基本與此類似。CORBAR 為了解決異構平臺的 RPC,使用了 IDL(Interface Definition Language)來定義遠端介面,並將其對映到特定的平臺語言中。後來大部分的跨語言平臺 RPC 基本都採用了此類方式,比如我們熟悉的 Web Service(SOAP),近年開源的 Thrift 等。他們大部分都通過 IDL 定義,並提供工具來對映生成不同語言平臺的 user-stub 和 server-stub,並通過框架庫來提供 RPCRuntime 的支援。不過貌似每個不同的 RPC 框架都定義了各自不同的 IDL 格式,導致程式設計師的學習成本進一步上升(苦逼啊),Web Service 嘗試建立業界標準,無賴標準規範複雜而效率偏低,否則 Thrift 等更高效的 RPC 框架就沒必要出現了。
IDL 是為了跨平臺語言實現 RPC 不得已的選擇,要解決更廣泛的問題自然導致了更復雜的方案。而對於同一平臺內的 RPC 而言顯然沒必要搞箇中間語言出來,例如 java 原生的 RMI,這樣對於 java 程式設計師而言顯得更直接簡單,降低使用的學習成本。目前市面上提供的 RPC 框架已經可算是五花八門,百家爭鳴了。需要根據實際使用場景謹慎選型,需要考慮的選型因素我覺得至少包括下面幾點:
1. 效能指標
2. 是否需要跨語言平臺
3. 內網開放還是公網開放
4. 開源 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 實現結構的各個元件組成部分,下面我們詳細說明下每個元件的職責劃分。
- 1. RpcServer
- 負責匯出(export)遠端介面
- 2. RpcClient
- 負責匯入(import)遠端介面的代理實現
- 3. RpcProxy
- 遠端介面的代理實現
- 4. RpcInvoker
- 客戶方實現:負責編碼呼叫資訊和傳送呼叫請求到服務方並等待呼叫結果返回
- 服務方實現:負責呼叫服務端介面的具體實現並返回呼叫結果
- 5. RpcProtocol
- 負責協議編/解碼
- 6. RpcConnector
- 負責維持客戶方和服務方的連線通道和傳送資料到服務方
- 7. RpcAcceptor
- 負責接收客戶方請求並返回請求結果
- 8. RpcProcessor
- 負責在服務方控制呼叫過程,包括管理呼叫執行緒池、超時時間等
- 9. RpcChannel
- 資料傳輸通道
RPC 實現分析
在進一步拆解了元件並劃分了職責之後,這裡以在 java 平臺實現該 RPC 框架概念模型為例,詳細分析下實現中需要考慮的因素。
匯出遠端介面
匯出遠端介面的意思是指只有匯出的介面可以供遠端呼叫,而未匯出的介面則不能。在 java 中匯出介面的程式碼片段可能如下:
- DemoService demo = new ...;
- RpcServer server = new ...;
- server.export(DemoService.class, demo, options);
我們可以匯出整個介面,也可以更細粒度一點只匯出介面中的某些方法,如:
- // 只匯出 DemoService 中籤名為 hi(String s) 的方法
- server.export(DemoService.class, demo, "hi", new Class<?>[] { String.class }, options);
java 中還有一種比較特殊的呼叫就是多型,也就是一個介面可能有多個實現,那麼遠端呼叫時到底呼叫哪個?這個本地呼叫的語義是通過 jvm 提供的引用多型性隱式實現的,那麼對於 RPC 來說跨程序的呼叫就沒法隱式實現了。如果前面DemoService
介面有 2 個實現,那麼在匯出介面時就需要特殊標記不同的實現,如:
- DemoService demo = new ...;
- DemoService demo2 = new ...;
- RpcServer server = new ...;
- server.export(DemoService.class, demo, options);
- server.export("demo2", DemoService.class, demo2, options);
上面 demo2 是另一個實現,我們標記為 "demo2" 來匯出,那麼遠端呼叫時也需要傳遞該標記才能呼叫到正確的實現類,這樣就解決了多型呼叫的語義。
匯入遠端介面與客戶端代理
匯入相對於匯出遠端介面,客戶端程式碼為了能夠發起呼叫必須要獲得遠端介面的方法或過程定義。目前,大部分跨語言平臺 RPC 框架採用根據 IDL 定義通過 code generator 去生成 stub 程式碼,這種方式下實際匯入的過程就是通過程式碼生成器在編譯期完成的。我所使用過的一些跨語言平臺 RPC 框架如 CORBAR、WebService、ICE、Thrift 均是此類方式。
程式碼生成的方式對跨語言平臺 RPC 框架而言是必然的選擇,而對於同一語言平臺的 RPC 則可以通過共享介面定義來實現。在 java 中匯入介面的程式碼片段可能如下:
- RpcClient client = new ...;
- DemoService demo = client.refer(DemoService.class);
- demo.hi("how are you?");
在 java 中 'import' 是關鍵字,所以程式碼片段中我們用 refer 來表達匯入介面的意思。這裡的匯入方式本質也是一種程式碼生成技術,只不過是在執行時生成,比靜態編譯期的程式碼生成看起來更簡潔些。java 裡至少提供了兩種技術來提供動態程式碼生成,一種是 jdk 動態代理,另外一種是位元組碼生成。動態代理相比位元組碼生成使用起來更方便,但動態代理方式在效能上是要遜色於直接的位元組碼生成的,而位元組碼生成在程式碼可讀性上要差很多。兩者權衡起來,個人認為犧牲一些效能來獲得程式碼可讀性和可維護性顯得更重要。
協議編解碼
客戶端代理在發起呼叫前需要對呼叫資訊進行編碼,這就要考慮需要編碼些什麼資訊並以什麼格式傳輸到服務端才能讓服務端完成呼叫。出於效率考慮,編碼的資訊越少越好(傳輸資料少),編碼的規則越簡單越好(執行效率高)。我們先看下需要編碼些什麼資訊:
- -- 呼叫編碼 --
- 1. 介面方法
- 包括介面名、方法名
- 2. 方法引數
- 包括引數型別、引數值
- 3. 呼叫屬性
- 包括呼叫屬性資訊,例如呼叫附件隱式引數、呼叫超時時間等
- -- 返回編碼 --
- 1. 返回結果
- 介面方法中定義的返回值
- 2. 返回碼
- 異常返回碼
- 3. 返回異常資訊
- 呼叫異常資訊
除了以上這些必須的呼叫資訊,我們可能還需要一些元資訊以方便程式編解碼以及未來可能的擴充套件。這樣我們的編碼訊息裡面就分成了兩部分,一部分是元資訊、另一部分是呼叫的必要資訊。如果設計一種 RPC 協議訊息的話,元資訊我們把它放在協議訊息頭中,而必要資訊放在協議訊息體中。下面給出一種概念上的 RPC 協議訊息設計格式:
- -- 訊息頭 --
- magic : 協議魔數,為解碼設計
- header size: 協議頭長度,為擴充套件設計
- version : 協議版本,為相容設計
- st : 訊息體序列化型別
- hb : 心跳訊息標記,為長連線傳輸層心跳設計
- ow : 單向訊息標記,
- rp : 響應訊息標記,不置位預設是請求訊息
- status code: 響應訊息狀態碼
- reserved : 為位元組對齊保留
- message id : 訊息 id
- body size : 訊息體長度
- -- 訊息體 --
- 採用序列化編碼,常見有以下格式
- xml : 如 webservie soap
- json : 如 JSON-RPC
- binary: 如 thrift; hession; kryo 等
格式確定後編解碼就簡單了,由於頭長度一定所以我們比較關心的就是訊息體的序列化方式。序列化我們關心三個方面:
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
需要提供什麼樣地呼叫控制服務呢?下面提出幾點以啟發思考:
- 1. 效率提升
- 每個請求應該儘快被執行,因此我們不能每請求來再建立執行緒去執行,需要提供執行緒池服務。
- 2. 資源隔離
- 當我們匯出多個遠端介面時,如何避免單一介面呼叫佔據所有執行緒資源,而引發其他介面執行阻塞。
- 3. 超時控制
- 當某個介面執行緩慢,而 client 端已經超時放棄等待後,server 端的執行緒繼續執行此時顯得毫無意義。
RPC 異常處理
無論 RPC 怎樣努力把遠端呼叫偽裝的像本地呼叫,但它們依然有很大的不同點,而且有一些異常情況是在本地呼叫時絕對不會碰到的。在說異常處理之前,我們先比較下本地呼叫和 RPC 呼叫的一些差異:
1. 本地呼叫一定會執行,而遠端呼叫則不一定,呼叫訊息可能因為網路原因並未傳送到服務方。
2. 本地呼叫只會丟擲介面宣告的異常,而遠端呼叫還會跑出 RPC 框架執行時的其他異常。
3. 本地呼叫和遠端呼叫的效能可能差距很大,這取決於 RPC 固有消耗所佔的比重。
正是這些區別決定了使用 RPC 時需要更多考量。當呼叫遠端介面丟擲異常時,異常可能是一個業務異常,也可能是 RPC 框架丟擲的執行時異常(如:網路中斷等)。業務異常表明服務方已經執行了呼叫,可能因為某些原因導致未能正常執行,而 RPC 執行時異常則有可能服務方根本沒有執行,對呼叫方而言的異常處理策略自然需要區分。
由於 RPC 固有的消耗相對本地呼叫高出幾個數量級,本地呼叫的固有消耗是納秒級,而 RPC 的固有消耗是在毫秒級。那麼對於過於輕量的計算任務就並不合適匯出遠端介面由獨立的程序提供服務,只有花在計算任務上時間遠遠高於 RPC 的固有消耗才值得匯出為遠端介面提供服務。
總結
至此我們提出了一個 RPC 實現的概念框架,並詳細分析了需要考慮的一些實現細節。無論 RPC 的概念是如何優雅,但是“草叢中依然有幾條蛇隱藏著”,只有深刻理解了 RPC 的本質,才能更好地應用。