RPC詳解


RPC(Remote Procedure Call),即遠程過程調用,是一個分布式系統間通信的必備技術,本文體系性地介紹了 RPC 包含的核心概念和技術,希望讀者讀完文章,一提到 RPC,腦中不是零碎的知識,而是具體的一個腦圖般的體系。本文並不會深入到每一個主題剖析,只做提綱挈領的介紹。

RPC 最核心要解決的問題就是在分布式系統間,如何執行另外一個地址空間上的函數、方法,就仿佛在本地調用一樣,個人總結的 RPC 最核心的概念和技術包括如下,如圖所示:

(點擊放大圖像)

下面依次展開每個部分。

傳輸(Transport)

TCP 協議是 RPC 的 基石,一般來說通信是建立在 TCP 協議之上的,而且 RPC 往往需要可靠的通信,因此不采用 UDP。

這里重申下 TCP 的關鍵詞:面向連接的,全雙工,可靠傳輸(按序、不重、不丟、容錯),流量控制(滑動窗口)。

另外,要理解 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 協議棧存在端口的概念,端口是進程獲取數據的渠道。

I/O 模型(I/O Model)

做一個高性能 /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 網絡編程》中就有提到了,它們分別是:

  1. 傳統的阻塞 I/O(Blocking I/O)
  2. 非阻塞 I/O(Non-blocking I/O)
  3. I/O 多路復用(I/O multiplexing)
  4. 異步 I/O(Asynchronous I/O)

這里不細說每種 I/O 模型。這里舉一個形象的例子,讀者就可以領會這四種 I/O 的區別,就用 銀行辦業務 這個生活的場景描述。

下圖是使用 傳統的阻塞 I/O 模型。一個櫃員服務所有客戶,可見當客戶填寫單據的時候也就是發生網絡 I/O 的時候,櫃員,也就是寶貴的線程或者進程就會被阻塞,白白浪費了 CPU 資源,無法服務后面的請求。

下圖是上一個的進化版,如果一個櫃員不夠,那么就 並發處理,對應采用線程池或者多進程方案,一個客戶對應一個櫃員,這明顯加大了並發度,在並發不高的情況下性能夠用,但是仍然存在櫃員被 I/O 阻塞的可能。

下圖是 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 操作完成后會通知用戶線程來調用其回調函數。

進程 / 線程模型(Thread/Process Model)

進程 / 線程模型往往和 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 和線程模型。

(點擊放大圖像)

Schema 和序列化(Schema & Data Serialization)

當 I/O 完成后,數據可以由程序處理,那么如何識別這些二進制的數據,是下一步要做的。序列化和反序列化,是做對象到二進制數據的轉換,程序是可以理解對象的,對象一般含有 schema 或者結構,基於這些語義來做特定的業務邏輯處理。

考察一個序列化框架一般會關注以下幾點:

  • Encoding format。是 human readable 還是 binary。
  • Schema declaration。也叫作契約聲明,基於 IDL,比如 Protocol Buffers/Thrift,還是自描述的,比如 JSON、XML。另外還需要看是否是強類型的。
  • 語言平台的中立性。比如 Java 的 Native Serialization 就只能自己玩,而 Protocol Buffers 可以跨各種語言和平台。
  • 新老契約的兼容性。比如 IDL 加了一個字段,老數據是否還可以反序列化成功。
  • 和壓縮算法的契合度。跑 benchmark 和實際應用都會結合各種壓縮算法,例如 gzip、snappy。
  • 性能。這是最重要的,序列化、反序列化的時間,序列化后數據的字節大小是考察重點。

序列化方式非常多,常見的有 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 看看就好,知道個大概,沒必要細究,因為樣本數據、測試環境、版本等都可能會影響結果。

協議結構(Wire Protocol)

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

圖中的說明:

  1. 字節流
  2. 第一幀
  3. 第二幀

方式 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,可以使用大尾端或者小尾端編碼。

可靠性(Reliability)

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)都采用了這個類庫。

易用性(Ease of use)

RPC 是需要讓上層寫業務邏輯來實現功能的,如何優雅地啟停一個 server,注入 endpoint,客戶端怎么連,重試調用,超時控制,同步異步調用,SDK 是否需要交換等等,都決定了基於 RPC 構建服務,甚至 SOA 的工程效率與生產力高低。這里不做展開,看各種 RPC 的文檔就知道他們的易用性如何了。

工業界的 RPC 框架一覽

國內

國外

上述列出來的都是現在互聯網企業常用的解決方案,暫時不考慮傳統的 SOAP,XML-RPC 等。這些是有網絡資料的,實際上很多公司內部都會針對自己的業務場景,以及和公司內的平台相融合(比如監控平台等),自研一套框架,但是殊途同歸,都逃不掉剛剛上面所列舉的 RPC 的要考慮的各個部分。

Demo 展示

為了使讀者更好地理解上面所述的各個章節,下面做一個簡單例子分析。使用 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 頭協議,可看出來。

  • ff 15 = 65301 是客戶端的端口
  • 1f a2 = 8098 是服務端的端口
  • header 的長度 44 字節是 20 字節頭 +20 字節 option+padding 構成的。

三次握手成功后,下面客戶端發起了 RPC 請求,如下圖所示。

(點擊放大圖像)

可以看出 TCP 包含了一個 message,由 navi-pbrpc 的協議棧規定的 header+payload 構成,

繼續深入分析 message 中的內容,如下圖所示:

(點擊放大圖像)

其中

  • 61 70 = ap 是頭中的的 provider 標識
  • body length 是 2,注意 navi-pbrpc 采用了小尾端。
  • payload 是 08 7f,08 在 PB 中理解為第一個屬性,是 varint 整型,7f 表示傳輸的是 127 這個整型。

服務端響應 RPC 請求,還是由 navi-pbrpc 的協議棧規定的 header+payload 構成,可以看出 body 就是 PB 例子里面的二進制數據。

(點擊放大圖像)

最后,客戶端退出,四次分手結束。

總結

本文系統性地介紹了 RPC 包含的核心概念和技術,帶着讀者從一個實際的例子去映射理解。很多東西都是蜻蜓點水,每一個關鍵字都能成為一個很大的話題,希望這個提綱挈領的介紹可以讓讀者在大腦里面有一個系統的體系去看待 RPC。

歡迎訪問作者的博客 http://neoremind.com

作者介紹

張旭,目前工作在 Hulu,從事 Big data 領域的研發工作,曾經在百度 ECOM 和程序化廣告部從事系統架構工作,熱愛開源,在 github 貢獻多個開源軟件,id:neoremind,關注大數據、Web 后端技術、廣告系統技術以及致力於編寫高質量的代碼。

 


免責聲明!

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



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