原文地址:http://www.infoq.com/cn/articles/get-to-know-rpc?utm_source=infoq&utm_medium=popular_widget&utm_campaign=popular_content_list&utm_content=homepagehtml
RPC(Remote Procedure Call),即遠程過程調用,是一個分佈式系統間通訊的必備技術,本文體系性地介紹了 RPC 包含的核心概念和技術,但願讀者讀完文章,一提到 RPC,腦中不是零碎的知識,而是具體的一個腦圖般的體系。本文並不會深刻到每個主題剖析,只作提綱挈領的介紹。react
RPC 最核心要解決的問題就是在分佈式系統間,如何執行另一個地址空間上的函數、方法,就彷彿在本地調用同樣,我的總結的 RPC 最核心的概念和技術包括以下,如圖所示:git
(點擊放大圖像)github
下面依次展開每一個部分。web
TCP 協議是 RPC 的 基石,通常來講通訊是創建在 TCP 協議之上的,並且 RPC 每每須要可靠的通訊,所以不採用 UDP。算法
這裏重申下 TCP 的關鍵詞:面向鏈接的,全雙工,可靠傳輸(按序、不重、不丟、容錯),流量控制(滑動窗口)。apache
另外,要理解 RPC 中的嵌套 header+body,協議棧每一層都包含了下一層協議的所有數據,只不過包了一個頭而已,以下圖所示的 TCP segment 包含了應用層的數據,套了一個頭而已。編程
(點擊放大圖像)設計模式
那麼 RPC 傳輸的 message 也就是 TCP body 中的數據,這個 message 也一樣能夠包含 header+body。body 也常常叫作 payload。安全
TCP 就是可靠地把數據在不一樣的地址空間上搬運,例如在傳統的阻塞 I/O 模型中,當有數據過來的時候,操做系統內核把數據從 I/O 中讀出來存放在 kernal space,而後內核就通知 user space 能夠拷貝走數據,用以騰出空間,讓 TCP 滑動窗口向前移動,接收更多的數據。
TCP 協議棧存在端口的概念,端口是進程獲取數據的渠道。
作一個高性能 /scalable 的 RPC,須要可以知足:
CPU 和 I/O 之間自然存在着差別,網絡傳輸的延時不可控,最簡單的模型下,若是有線程或者進程在調用 I/O,I/O 沒響應時,CPU 只能選擇掛起,線程或者進程也被 I/O 阻塞住。
而 CPU 資源寶貴,要讓 CPU 在該忙碌的時候儘可能忙碌起來,而不須要頻繁地掛起、喚醒作切換,同時不少寶貴的線程和進程佔用系統資源也在作無用功。
Socket I/O 能夠看作是兩者之間的橋樑,如何更好地協調兩者,去知足前面說的兩點要求,有一些模式(pattern)是能夠應用的。
RPC 框架可選擇的 I/O 模型嚴格意義上有 5 種,這裏不討論基於 信號驅動 的 I/O(Signal Driven I/O)。這幾種模型在《UNIX 網絡編程》中就有提到了,它們分別是:
這裏不細說每種 I/O 模型。這裏舉一個形象的例子,讀者就能夠領會這四種 I/O 的區別,就用 銀行辦業務 這個生活的場景描述。
下圖是使用 傳統的阻塞 I/O 模型。一個櫃員服務全部客戶,可見當客戶填寫單據的時候也就是發生網絡 I/O 的時候,櫃員,也就是寶貴的線程或者進程就會被阻塞,白白浪費了 CPU 資源,沒法服務後面的請求。
下圖是上一個的進化版,若是一個櫃員不夠,那麼就 併發處理,對應採用線程池或者多進程方案,一個客戶對應一個櫃員,這明顯加大了併發度,在併發不高的狀況下性可以用,可是仍然存在櫃員被 I/O 阻塞的可能。
下圖是 I/O 多路複用,存在一個大堂經理,至關於代理,它來負責全部的客戶,只有當客戶寫好單據後,才把客戶分配一個櫃員處理,能夠想象櫃員不用阻塞在 I/O 讀寫上,這樣櫃員效率會很是高,這也就是 I/O 多路複用的精髓。
下圖是 異步 I/O,徹底不存在大堂經理,銀行有一個自然的「高級的分配機器」,櫃員註冊本身負責的業務類型,例如 I/O 可讀,那麼由這個「高級的機器」負責 I/O 讀,當可讀時候,經過 回調機制,把客戶已經填寫完畢的單據主動交給櫃員,回調其函數完成操做。
重點說下高性能,而且工業界廣泛使用的方案,也就是後兩種。
基於內核,創建在 epoll 或者 kqueue 上實現,I/O 多路複用最大的優點是用戶能夠在一個線程內同時處理多個 Socket 的 I/O 請求。用戶能夠訂閱事件,包括文件描述符或者 I/O 可讀、可寫、可鏈接事件等。
經過一個線程監聽所有的 TCP 鏈接,有任何事件發生就通知用戶態處理便可,這麼作的目的就是 假設 I/O 是慢的,CPU 是快的,那麼要讓用戶態儘量的忙碌起來去,也就是最大化 CPU 利用率,避免傳統的 I/O 阻塞。
這裏重點說下同步 I/O 和異步 I/O,理論上前三種模型都叫作同步 I/O,同步是指用戶線程發起 I/O 請求後須要等待或者輪詢內核 I/O 完成後再繼續,而異步是指用戶線程發起 I/O 請求直接退出,當內核 I/O 操做完成後會通知用戶線程來調用其回調函數。
進程 / 線程模型每每和 I/O 模型有聯繫,當 Socket I/O 能夠很高效的工做時候,真正的業務邏輯如何利用 CPU 更快地處理請求,也是有 pattern 可尋的。這裏主要說 Scalable I/O 通常是如何作的,它的 I/O 須要經歷 5 個環節:
Read -> Decode -> Compute -> Encode -> Send
使用傳統的阻塞 I/O + 線程池的方案(Multitasks)會遇 C10k問題。
https://en.wikipedia.org/wiki/C10k_problem
可是業界有不少實現都是這個方式,好比 Java web 容器 Tomcat/Jetty 的默認配置就採用這個方案,能夠工做得很好。
可是從 I/O 模型能夠看出 I/O Blocking is killer to performance,它會讓工做線程卡在 I/O 上,而一個系統內部可以使用的線程數量是有限的(本文暫時不談協程、纖程的概念),因此纔有了 I/O 多路複用和異步 I/O。
I/O 多路複用每每對應 Reactor 模式,異步 I/O 每每對應 Proactor。
Reactor 通常使用 epoll+ 事件驅動 的經典模式,經過 分治 的手段,把耗時的網絡鏈接、安全認證、編碼等工做交給專門的線程池或者進程去完成,而後再去調用真正的核心業務邏輯層,這在 *nix 系統中被普遍使用。
著名的 Redis、Nginx、Node.js 的 Socket I/O 都用的這個,而 Java 的 NIO 框架 Netty 也是,Spark 2.0 RPC 所依賴的一樣採用了 Reactor 模式。
Proactor 在 *nix 中沒有很好的實現,可是在 Windows 上大放異彩(例如 IOCP 模型)。
關於 Reactor 能夠參考 Doug Lea 的 PPT
http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf
以及 這篇 paper
http://www.dre.vanderbilt.edu/~schmidt/PDF/reactor-siemens.pdf
關於 Proactor 能夠參考 這篇 paper
http://www.cs.wustl.edu/~schmidt/PDF/proactor.pdf
說個具體的例子,Thrift 做爲一個融合了 序列化 +RPC 的框架,提供了不少種 Server 的構建選項,從名稱中就能夠看出他們使用哪一種 I/O 和線程模型。
(點擊放大圖像)
當 I/O 完成後,數據能夠由程序處理,那麼如何識別這些二進制的數據,是下一步要作的。序列化和反序列化,是作對象到二進制數據的轉換,程序是能夠理解對象的,對象通常含有 schema 或者結構,基於這些語義來作特定的業務邏輯處理。
考察一個序列化框架通常會關注如下幾點:
序列化方式很是多,常見的有 Protocol Buffers, Avro,Thrift,XML,JSON,MessagePack,Kyro,Hessian,Protostuff,Java Native Serialize,FST。
下面詳細展開 Protocol Buffers(簡稱 PB),看看爲何做爲工業界用得最多的高性能序列化類庫,好在哪裏。
首先去官網查看它的 Encoding format
https://developers.google.com/protocol-buffers/docs/encoding
緊湊高效 是 PB 的特色,使用字段的序號做爲標識,而不是包名類名(Java 的 Native Serialization 序列化後數據大就在於什麼都一股腦放進去),使用 varint 和 zigzag 對整型作特殊處理。
PB 能夠跨各類語言,可是前提是使用 IDL 編寫描述文件,而後 codegen 工具生成各類語言的代碼。
舉個例子,有個 Person 對象,包含內容以下圖所示,通過 PB 序列化後只有 33 個字節,能夠對比 XML、JSON 或者 Java 的 Native Serialization 都會大很是多,並且序列化、反序列化的速度也不會很好。記住這個數據,後面 demo 的時候會有用。
(點擊放大圖像)
圖片來源
https://www.slideshare.net/SergeyPodolsky/google-protocol-buffers-56085699
再舉個例子,使用 Thrift 作一樣的序列化,採用 Binary Protocol 和 Compact Protocol 的大小是不同的,可是 Compact Protocol 和 PB 雖然序列化的編碼不同,可是一樣是很是高效的。
(點擊放大圖像)
圖片來源
https://www.slideshare.net/SergeyPodolsky/google-protocol-buffers-56085699
這裏給一個 Uber 作的序列化框架比較
https://eng.uber.com/trip-data-squeeze/
能夠看出 Protocol Buffers 和 Thrift 都是名列前茅的,可是這些 benchmark 看看就好,知道個大概,不必細究,由於樣本數據、測試環境、版本等均可能會影響結果。
Socket 範疇裏討論的包叫作 Frame、Packet、Segment 都沒錯,可是通常把這些分別映射爲數據鏈路層、IP 層和 TCP 層的數據包,應用層的暫時沒有,因此下文沒必要計較包怎麼翻譯。
協議結構,英文叫作 wire protocol 或者 wire format。TCP 只是 binary stream 通道,是 binary 數據的可靠搬用工,它不懂 RPC 裏面包裝的是什麼。而在一個通道上傳輸 message,勢必涉及 message 的識別。
舉個例子,正以下圖中的例子,ABC+DEF+GHI 分 3 個 message,也就是分 3 個 Frame 發送出去,而接收端分四次收到 4 個 Frame。
Socket I/O 的工做完成得很好,可靠地傳輸過去,這是 TCP 協議保證的,可是接收到的是 4 個 Frame,不是本來發送的 3 個 message 對應的 3 個 Frame。
這種狀況叫作發生了 TCP 粘包和半包 現象,AB、H、I 的狀況叫作半包,CDEFG 的狀況叫作粘包。雖然順序是對的,可是分組徹底和以前對應不上。
這時候應用層如何作語義級別的 message 識別是個問題,只有作好了協議的結構,才能把一整個數據片斷作序列化或者反序列化處理。
通常採用的方式有三種:
方式 1:分隔符。
方式 2:換行符。好比 memcache 由客戶端發送的命令使用的是文本行\r\n 作爲 mesage 的分隔符,組織成一個有意義的 message。
圖片來源
https://www.kancloud.cn/kancloud/essential-netty-in-action/52643
圖中的說明:
方式 3:固定長度。RPC 常常採用這種方式,使用 header+payload 的方式。
好比 HTTP 協議,創建在 TCP 之上最普遍使用的 RPC,HTTP 頭中確定有一個 body length 告知應用層如何去讀懂一個 message,作 HTTP 包的識別。
在 HTTP/2 協議中,詳細見 Hypertext Transfer Protocol Version 2 (HTTP/2)
https://tools.ietf.org/html/rfc7540
雖然精簡了不少,加入了流的概念,可是 header+payload 的方式是絕對不能變的。
圖片來源
https://tools.ietf.org/html/rfc7540
下面展現的是做者自研的一個 RPC 框架,能夠在 github 上找到這個工程
neoremind/navi-pbrpc:
https://github.com/neoremind/navi-pbrpc
能夠看出它的協議棧 header+payload 方式的,header 固定 36 個字節長度,最後 4 個字節是 body length,也就是 payload length,可使用大尾端或者小尾端編碼。
RPC 框架不光要處理 Network I/O、序列化、協議棧。還有不少不肯定性問題要處理,這裏的不肯定性就是由 網絡的不可靠 帶來的麻煩。
例如如何保持長鏈接心跳?網絡閃斷怎麼辦?重連、重傳?鏈接超時?這些都很是的細碎和麻煩,因此說開發好一個穩定的 RPC 類庫是一個很是系統和細心的工程。
可是好在工業界有一羣人就致力於提供平臺似的解決方案,例如 Java 中的 Netty,它是一個強大的異步、事件驅動的網絡 I/O 庫,使用 I/O 多路複用的模型,作好了上述的麻煩處理。
它是面向對象設計模式的集大成者,使用方只須要會使用 Netty 的各類類,進行擴展、組合、插拔,就能夠完成一個高性能、可靠的 RPC 框架。
著名的 gRPC Java 版本、Twitter 的 Finagle 框架、阿里巴巴的 Dubbo、新浪微博的 Motan、Spark 2.0 RPC 的網絡層(能夠參考 kraps-rpc:https://github.com/neoremind/kraps-rpc)都採用了這個類庫。
RPC 是須要讓上層寫業務邏輯來實現功能的,如何優雅地啓停一個 server,注入 endpoint,客戶端怎麼連,重試調用,超時控制,同步異步調用,SDK 是否須要交換等等,都決定了基於 RPC 構建服務,甚至 SOA 的工程效率與生產力高低。這裏不作展開,看各類 RPC 的文檔就知道他們的易用性如何了。
上述列出來的都是如今互聯網企業經常使用的解決方案,暫時不考慮傳統的 SOAP,XML-RPC 等。這些是有網絡資料的,實際上不少公司內部都會針對本身的業務場景,以及和公司內的平臺相融合(好比監控平臺等),自研一套框架,可是異曲同工,都逃不掉剛剛上面所列舉的 RPC 的要考慮的各個部分。
爲了使讀者更好地理解上面所述的各個章節,下面作一個簡單例子分析。使用 neoremind/navi-pbrpc:https://github.com/neoremind/navi-pbrpc 來作 demo,使用 Java 語言來開發。
假設要開發一個服務端和客戶端,服務端提供一個請求響應接口,請求是 user_id,響應是一個 user 的數據結構對象。
首先定義一個 IDL,使用 PB 來作 Schema 聲明,IDL 描述以下,第一個 Request 是請求,第二個 Person 是響應的對象結構。
而後使用 codegen 生成對應的代碼,例如生成了 PersonPB.Request 和 PersonPB.Person 兩個 class。
server 端須要開發請求響應接口,API 是 PersonPB.Person doSmth(PersonPB.Request req),實現以下,包含一個 Interface 和一個實現 class。
server 返回的是一個 Person 對象,裏面的內容主要就是上面講到的 PB 例子裏面的。
啓動 server。在 8098 端口開啓服務,客戶端須要靠 id=100 這個標識來路由到這個服務。
至此,服務端開發完畢,能夠看出使用一個完善的 RPC 框架,只須要定義好 Schema 和業務邏輯就能夠發佈一個 RPC,而 I/O model、線程模型、序列化 / 反序列化、協議結構均由框架服務。
navi-pbrpc 底層使用 Netty,在 Linux 下會使用 epoll 作 I/O 多路複用,線程模型默認採用 Reactor 模式,序列化和反序列化使用 PB,協議結構見上文部分介紹的,是一個標準的 header+payload 結構。
下面開發一個 client,調用剛剛開發的 RPC。
client 端代碼實現以下。首先構造 PbrpcClient,而後構造 PersonPB.Request,也就是請求,設置好 user_id,構造 PbrpcMsg 做爲 TCP 層傳輸的數據 payload,這就是協議結構中的 body 部分。
經過 asyncTransport 進行通訊,返回一個 Future 句柄,經過 Future.get 阻塞獲取結果而且打印。
至此,能夠看出做爲一個 RPC client 易用性是很簡單的,同時可靠性,例如重試等會由 navi-pbrpc 框架負責完成,用戶只須要聚焦到真正的業務邏輯便可。
下面繼續深刻到 binary stream 級別觀察,使用嗅探工具來看看 TCP 包。通常使用 wireshark 或者 tcpdump。
客戶端的一次請求調用以下圖所示,第一個包就是 TCP 三次握手的 SYN 包。
(點擊放大圖像)
根據 TCP 頭協議,可看出來。
三次握手成功後,下面客戶端發起了 RPC 請求,以下圖所示。
(點擊放大圖像)
能夠看出 TCP 包含了一個 message,由 navi-pbrpc 的協議棧規定的 header+payload 構成,
繼續深刻分析 message 中的內容,以下圖所示:
(點擊放大圖像)
其中
服務端響應 RPC 請求,仍是由 navi-pbrpc 的協議棧規定的 header+payload 構成,能夠看出 body 就是 PB 例子裏面的二進制數據。
(點擊放大圖像)
最後,客戶端退出,四次分手結束。
本文系統性地介紹了 RPC 包含的核心概念和技術,帶着讀者從一個實際的例子去映射理解。不少東西都是走馬觀花,每個關鍵字都能成爲一個很大的話題,但願這個提綱挈領的介紹可讓讀者在大腦裏面有一個系統的體系去看待 RPC。