RPC詳解

weixin_34026276發表於2018-03-25

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 後端技術、廣告系統技術以及致力於編寫高質量的程式碼。

 

謀膽並重

相關文章