摘自: http://blog.csdn.net/mindfloating/article/details/39473807
近幾年的項目中,服務化和微服務化漸漸成為中大型分布式系統架構的主流方式,而 RPC 在其中扮演着關鍵的作用。在平時的日常開發中我們都在隐式或顯式的使用 RPC,一些剛入行的程式員會感覺 RPC 比較神秘,而一些有多年使用 RPC 經驗的程式員雖然使用經驗豐富,但有些對其原理也不甚了了。缺乏對原理層面的了解,往往也會造成開發中的一些誤用。
本文分上下兩篇《淺出篇》和《深入篇》,其目标就是想嘗試深入淺出的分析下 RPC 本質,我總是這麼認為了解了本質才能更好的應用。
RPC 是什麼?
RPC 的全稱是 Remote Procedure Call 是一種程序間通信方式。它允許程式調用另一個位址空間(通常是共享網絡的另一台機器上)的過程或函數,而不用程式員顯式編碼這個遠端調用的細節。即程式員無論是調用本地的還是遠端的,本質上編寫的調用代碼基本相同。
RPC 起源
RPC 這個概念術語在上世紀 80 年代由 Bruce Jay Nelson 提出。這裡我們追溯下當初開發 RPC 的原動機是什麼?在 Nelson 的論文 "Implementing Remote Procedure Calls" 中他提到了幾點:
- 簡單:RPC 概念的語義十厘清晰和簡單,這樣建立分布式計算就更容易。
- 高效:過程調用看起來十分簡單而且高效。
-
通用:在單機計算中過程往往是不同算法部分間最重要的通信機制。
通俗一點說,就是一般程式員對于本地的過程調用很熟悉,那麼我們把 RPC 作成和本地調用完全類似,那麼就更容易被接受,使用起來毫無障礙。Nelson 的論文發表于 30 年前,其觀點今天看來确實高瞻遠矚,今天我們使用的 RPC 架構基本就是按這個目标來實作的。
RPC 結構
Nelson 的論文中指出實作 RPC 的程式包括 5 個部分:
- User
- User-stub
- RPCRuntime
- Server-stub
-
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 架構已經可算是五花八門,百家争鳴了。需要根據實際使用場景謹慎選型,需要考慮的選型因素我覺得至少包括下面幾點:
- 性能名額
- 是否需要跨語言平台
- 内網開放還是公網開放
-
開源 RPC 架構本身的品質、社群活躍度
總結
《淺出篇》大概就到這裡結束了,《深入篇》會具體深入講解一個 RPC 架構需要實作哪裡基本功能,達到什麼目标,并以在 java 平台上去具體實作一個 RPC 架構為例,分析其需要考慮的實作因素。
摘自: http://blog.csdn.net/mindfloating/article/details/39474123#comments
《深入篇》我們主要圍繞 RPC 的功能目标和實作考量去展開,一個基本的 RPC 架構應該提供什麼功能,滿足什麼要求以及如何去實作它?
RPC 功能目标
RPC 的主要功能目标是讓建構分布式計算(應用)更容易,在提供強大的遠端調用能力時不損失本地調用的語義簡潔性。為實作該目标,RPC 架構需提供一種透明調用機制讓使用者不必顯式的區分本地調用和遠端調用,在前文《淺出篇》中給出了一種實作結構,基于 stub 的結構來實作。下面我們将具體細化 stub 結構的實作。
RPC 調用分類
RPC 調用分以下兩種:
[plain] view plain copy 在CODE上檢視代碼片派生到我的代碼片
-
同步調用
客戶方等待調用執行完成并傳回結果。
-
異步調用
客戶方調用後不用等待執行結果傳回,但依然可以通過回調通知等方式擷取傳回結果。
若客戶方不關心調用傳回結果,則變成單向異步調用,單向調用不用傳回結果。
異步和同步的區分在于是否等待服務端執行完成并傳回結果。
RPC 結構拆解
《淺出篇》給出了一個比較粗粒度的 RPC 實作概念結構,這裡我們進一步細化它應該由哪些元件構成,如下圖所示。
RPC 服務方通過 RpcServer 去導出(export)遠端接口方法,而客戶方通過 RpcClient 去引入(import)遠端接口方法。客戶方像調用本地方法一樣去調用遠端接口方法,RPC 架構提供接口的代理實作,實際的調用将委托給代理RpcProxy 。代理封裝調用資訊并将調用轉交給RpcInvoker 去實際執行。在用戶端的RpcInvoker 通過連接配接器RpcConnector 去維持與服務端的通道RpcChannel,并使用RpcProtocol 執行協定編碼(encode)并将編碼後的請求消息通過通道發送給服務方。
RPC 服務端接收器 RpcAcceptor 接收用戶端的調用請求,同樣使用RpcProtocol 執行協定解碼(decode)。解碼後的調用資訊傳遞給RpcProcessor 去控制處理調用過程,最後再委托調用給RpcInvoker 去實際執行并傳回調用結果。
RPC 元件職責
上面我們進一步拆解了 RPC 實作結構的各個元件組成部分,下面我們詳細說明下每個元件的職責劃分。
[plain] view plain copy 在CODE上檢視代碼片派生到我的代碼片
-
RpcServer
負責導出(export)遠端接口
-
RpcClient
負責導入(import)遠端接口的代理實作
-
RpcProxy
遠端接口的代理實作
-
RpcInvoker
客戶方實作:負責編碼調用資訊和發送調用請求到服務方并等待調用結果傳回
服務方實作:負責調用服務端接口的具體實作并傳回調用結果
-
RpcProtocol
負責協定編/解碼
-
RpcConnector
負責維持客戶方和服務方的連接配接通道和發送資料到服務方
-
RpcAcceptor
負責接收客戶方請求并傳回請求結果
-
RpcProcessor
負責在服務方控制調用過程,包括管理調用線程池、逾時時間等
-
RpcChannel
資料傳輸通道
RPC 實作分析
在進一步拆解了元件并劃分了職責之後,這裡以在 java 平台實作該 RPC 架構概念模型為例,詳細分析下實作中需要考慮的因素。
導出遠端接口
導出遠端接口的意思是指隻有導出的接口可以供遠端調用,而未導出的接口則不能。在 java 中導出接口的代碼片段可能如下:
[java] view plain copy 在CODE上檢視代碼片派生到我的代碼片
DemoService demo = new ...;
RpcServer server = new ...;
server.export(DemoService.class, demo, options);
我們可以導出整個接口,也可以更細粒度一點隻導出接口中的某些方法,如:
[java] view plain copy 在CODE上檢視代碼片派生到我的代碼片
// 隻導出 DemoService 中簽名為 hi(String s) 的方法
server.export(DemoService.class, demo, "hi", new Class<?>[] { String.class }, options);
java 中還有一種比較特殊的調用就是多态,也就是一個接口可能有多個實作,那麼遠端調用時到底調用哪個?這個本地調用的語義是通過 jvm 提供的引用多态性隐式實作的,那麼對于 RPC 來說跨程序的調用就沒法隐式實作了。如果前面DemoService 接口有 2 個實作,那麼在導出接口時就需要特殊标記不同的實作,如:
[java] view plain copy 在CODE上檢視代碼片派生到我的代碼片
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 中導入接口的代碼片段可能如下:
[java] view plain copy 在CODE上檢視代碼片派生到我的代碼片
RpcClient client = new ...;
DemoService demo = client.refer(DemoService.class);
demo.hi("how are you?");
在 java 中 'import' 是關鍵字,是以代碼片段中我們用 refer 來表達導入接口的意思。這裡的導入方式本質也是一種代碼生成技術,隻不過是在運作時生成,比靜态編譯期的代碼生成看起來更簡潔些。java 裡至少提供了兩種技術來提供動态代碼生成,一種是 jdk 動态代理,另外一種是位元組碼生成。動态代理相比位元組碼生成使用起來更友善,但動态代理方式在性能上是要遜色于直接的位元組碼生成的,而位元組碼生成在代碼可讀性上要差很多。兩者權衡起來,個人認為犧牲一些性能來獲得代碼可讀性和可維護性顯得更重要。
協定編解碼
用戶端代理在發起調用前需要對調用資訊進行編碼,這就要考慮需要編碼些什麼資訊并以什麼格式傳輸到服務端才能讓服務端完成調用。出于效率考慮,編碼的資訊越少越好(傳輸資料少),編碼的規則越簡單越好(執行效率高)。我們先看下需要編碼些什麼資訊:
[plain] view plain copy 在CODE上檢視代碼片派生到我的代碼片
-- 調用編碼 --
-
接口方法
包括接口名、方法名
-
方法參數
包括參數類型、參數值
-
調用屬性
包括調用屬性資訊,例如調用附件隐式參數、調用逾時時間等
-- 傳回編碼 --
-
傳回結果
接口方法中定義的傳回值
-
傳回碼
異常傳回碼
-
傳回異常資訊
調用異常資訊
除了以上這些必須的調用資訊,我們可能還需要一些元資訊以友善程式編解碼以及未來可能的擴充。這樣我們的編碼消息裡面就分成了兩部分,一部分是元資訊、另一部分是調用的必要資訊。如果設計一種 RPC 協定消息的話,元資訊我們把它放在協定消息頭中,而必要資訊放在協定消息體中。下面給出一種概念上的 RPC 協定消息設計格式:
[plain] view plain copy 在CODE上檢視代碼片派生到我的代碼片
-- 消息頭 --
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 等
格式确定後編解碼就簡單了,由于頭長度一定是以我們比較關心的就是消息體的序列化方式。序列化我們關心三個方面:
- 序列化和反序列化的效率,越快越好。
- 序列化後的位元組長度,越小越好。
- 序列化和反序列化的相容性,接口參數對象若增加了字段,是否相容。
上面這三點有時是魚與熊掌不可兼得,這裡面涉及到具體的序列化庫實作細節,就不在本文進一步展開分析了。
傳輸服務
協定編碼之後,自然就是需要将編碼後的 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 需要提供什麼樣地調用控制服務呢?下面提出幾點以啟發思考:
[plain] view plain copy 在CODE上檢視代碼片派生到我的代碼片
-
效率提升
每個請求應該盡快被執行,是以我們不能每請求來再建立線程去執行,需要提供線程池服務。
-
資源隔離
當我們導出多個遠端接口時,如何避免單一接口調用占據所有線程資源,而引發其他接口執行阻塞。
-
逾時控制
當某個接口執行緩慢,而 client 端已經逾時放棄等待後,server 端的線程繼續執行此時顯得毫無意義。
RPC 異常處理
無論 RPC 怎樣努力把遠端調用僞裝的像本地調用,但它們依然有很大的不同點,而且有一些異常情況是在本地調用時絕對不會碰到的。在說異常處理之前,我們先比較下本地調用和 RPC 調用的一些差異:
- 本地調用一定會執行,而遠端調用則不一定,調用消息可能因為網絡原因并未發送到服務方。
- 本地調用隻會抛出接口聲明的異常,而遠端調用還會跑出 RPC 架構運作時的其他異常。
- 本地調用和遠端調用的性能可能差距很大,這取決于 RPC 固有消耗所占的比重。
正是這些差別決定了使用 RPC 時需要更多考量。當調用遠端接口抛出異常時,異常可能是一個業務異常,也可能是 RPC 架構抛出的運作時異常(如:網絡中斷等)。業務異常表明服務方已經執行了調用,可能因為某些原因導緻未能正常執行,而 RPC 運作時異常則有可能服務方根本沒有執行,對調用方而言的異常處理政策自然需要區分。
由于 RPC 固有的消耗相對本地調用高出幾個數量級,本地調用的固有消耗是納秒級,而 RPC 的固有消耗是在毫秒級。那麼對于過于輕量的計算任務就并不合适導出遠端接口由獨立的程序提供服務,隻有花在計算任務上時間遠遠高于 RPC 的固有消耗才值得導出為遠端接口提供服務。
總結
至此我們提出了一個 RPC 實作的概念架構,并詳細分析了需要考慮的一些實作細節。無論 RPC 的概念是如何優雅,但是“草叢中依然有幾條蛇隐藏着”,隻有深刻了解了 RPC 的本質,才能更好地應用。