本文主體源自體系化認識RPC,文章整體寫得不錯,本文稍作細化,同時更正了部分不實之處。
RPC技術在分佈式系統中有廣泛的使用,因而在雲計算平臺中也是經常使用的技術,本文體系性地介紹了 RPC 包含的核心概念和技術,希望讀者讀完文章,一提到 RPC,腦中不是零碎的知識,而是具體的一個腦圖般的體系。本文並不會深入到每一個主題剖析,只做提綱挈領的介紹。
RPC 最核心要解決的問題就是在分佈式系統間,如何執行另外一個地址空間上的函數、方法,就彷彿在本地調用一樣,個人總結的 RPC 最核心的概念和技術包括如下,如圖所示:
下面依次展開每個部分。
傳輸(Transport)
TCP 協議是 RPC 的 基石,一般來說通信是建立在 TCP 協議之上的,而且 RPC 往往需要可靠的通信,因此不採用 UDP。
這裏重申下 TCP 的關鍵詞:面向連接的,全雙工,可靠傳輸(保序、不重、不丟、容錯),流量控制(滑動窗口),擁塞控制(擁塞窗口)。
另外,要理解 RPC 中的嵌套 header+body,協議棧每一層都包含了下一層協議的全部數據,只不過包了一個頭而已,如下圖所示的 TCP segment 包含了應用層的數據,套了一個頭而已。
TCP頭格式
通過上圖TCP頭的格式,你需要注意這麼幾點:
- TCP的包是沒有IP地址的,那是IP層上的事。但是有源端口和目標端口。
- 一個TCP連接需要四個元組來表示是同一個連接(src_ip, src_port, dst_ip, dst_port)準確說是五元組,還有一個是協議。但因爲這裏只是說TCP協議,所以,這裏我只說四元組。
- 注意上圖中的四個非常重要的東西:
- Sequence Number是包的序號,用來解決網絡包亂序(reordering)問題。
- Acknowledgement Number就是ACK——用於確認收到,用來解決不丟包的問題。
- Window又叫Advertised-Window,也就是著名的滑動窗口(Sliding Window),用於解決流控的。
- TCP Flag ,也就是包的類型,主要是用於操控TCP的狀態機的。
TCP的狀態機(面向連接與全雙工)
其實,網絡上的傳輸是沒有連接的,包括TCP也是一樣的。而TCP所謂的“連接”,其實只不過是在通訊的雙方維護一個“連接狀態”,讓它看上去好像有連接一樣。所以,TCP的狀態變換是非常重要的。
下面是:“TCP協議的狀態機”(圖片來源) 和 “TCP建鏈接”、“TCP斷鏈接”、“傳數據” 的對照圖,我把兩個圖並排放在一起,這樣方便在你對照着看。另外,下面這兩個圖非常非常的重要,你一定要記牢。(吐個槽:看到這樣複雜的狀態機,就知道這個協議有多複雜,複雜的東西總是有很多坑爹的事情,所以TCP協議其實也挺坑爹的)
很多人會問,爲什麼建鏈接要3次握手,斷鏈接需要4次揮手?
對於建鏈接的3次握手,主要是要初始化Sequence Number 的初始值。通信的雙方要互相通知對方自己的初始化的Sequence Number(縮寫爲ISN:Inital Sequence Number)——所以叫SYN,全稱Synchronize Sequence Numbers。也就上圖中的 x 和 y。這個號要作爲以後的數據通信的序號,以保證應用層接收到的數據不會因爲網絡上的傳輸的問題而亂序(TCP會用這個序號來拼接數據)。
對於4次揮手,其實你仔細看是2次,因爲TCP是全雙工的,所以,發送方和接收方都需要Fin和Ack。只不過,有一方是被動的,所以看上去就成了所謂的4次揮手。如果兩邊同時斷連接,那就會就進入到CLOSING狀態,然後到達TIME_WAIT狀態。下圖是雙方同時斷連接的示意圖(你同樣可以對照着TCP狀態機看):
這裏有個問題:如果對端宕機,TCP另一端能否及時感知?
答案是不會,原因是TCP是一種有連接的協議,但是這個連接並不是指有一條實際的電路,而是一種虛擬的電路。TCP的建立連接和斷開連接都是通過發送數據實現的,也就是我們常說的三次握手、四次揮手。TCP兩端保存了一種數據的狀態,就代表這種連接,TCP兩端之間的路由設備只是將數據轉發到目的地,並不知道這些數據實際代表了什麼含義,也並沒有在其中保存任何的狀態信息,也就是說中間的路由設備沒有什麼連接的概念,只是將數據轉發到目的地,只有數據的發送者和接受者兩端真正的知道傳輸的數據代表着一條連接。
如何能及時感知到TCP對端掉線呢?保持連接並不是毫無代價的,如果這種異常斷開的連接有很多,那麼勢必會耗費大量的資源,必須要想辦法檢測出這種異常連接。
檢測的方法很簡單,只要讓B端主動通過這個連接向A端繼續發送數據即可。上文說過,A端異常斷開後,和A端直接連接的路由器是知道的。當B端發送的數據經過轉發後到達這個路由器後,必然最終會返回B端一個目的不可達。此時B端立刻就會知道這條連接其實已經異常斷開了。
但是B端不可能知道什麼時候會出現這種異常,所以B端必須定時發送數據來檢測連接是否異常斷開。數據的內容無關緊要,任何數據都能達到這個效果。這個數據就是我們經常在TCP編程中所說的心跳。
TCP協議本身就提供了一種這樣的機制來探測對端的存活。TCP協議有一個KEEP_LIVE開關,只要打開這個開關就會定時發送一些數據長度爲零的探測心跳包,發送的頻率和次數都可以設置,具體的方法在網上搜索tcp keepalive即可,網上有很多文章,這裏不再贅述。
除了使用TCP協議本身的保活開關機制,還可以在應用層主動發送心跳數據包,那麼在應用層主動發送心跳數據包的方式和TCP協議本身的保活機制有什麼區別呢?
應用層的心跳數據包會耗費更多的帶寬,因爲TCP協議的保活機制發送的是數據長度爲零心跳包,而應用層的心跳數據包長度則必然會大於0。
應用層的心跳數據包可以帶一些應用所需要的數據,隨應用自己控制,而TCP協議的保活機制則是對於應用層透明的,無法利用心跳攜帶數據。
數據傳輸中的Sequence Number(保序)
下圖是Wireshark的截圖:
你可以看到,SeqNum的增加是和傳輸的字節數相關的。上圖中,三次握手後,來了兩個Len:1440的包,而第二個包的SeqNum就成了1441。然後第一個ACK回的是1441,表示第一個1440收到了。
注意:如果你用Wireshark抓包程序看3次握手,你會發現SeqNum總是爲0,不是這樣的,Wireshark爲了顯示更友好,使用了Relative SeqNum——相對序號,你只要在右鍵菜單中的protocol preference 中取消掉就可以看到“Absolute SeqNum”了。
TCP重傳機制(可靠性)
TCP要保證所有的數據包都可以到達,所以,必需要有重傳機制。
注意,接收端給發送端的Ack確認只會確認最後一個連續的包,比如,發送端發了1,2,3,4,5一共五份數據,接收端收到了1,2,於是回ack 3,然後收到了4(注意此時3沒收到),此時的TCP會怎麼辦?我們要知道,因爲正如前面所說的,SeqNum和Ack是以字節數爲單位,所以ack的時候,不能跳着確認,只能確認最大的連續收到的包,不然,發送端就以爲之前的都收到了。
TCP滑動窗口(流量控制)
需要說明一下,如果你不瞭解TCP的滑動窗口這個事,你等於不瞭解TCP協議。我們都知道,TCP必需要解決的可靠傳輸以及包亂序(reordering)的問題,所以,TCP必需要知道網絡實際的數據處理帶寬或是數據處理速度,這樣纔不會引起網絡擁塞,導致丟包。
所以,TCP引入了一些技術和設計來做網絡流控,Sliding Window是其中一個技術。 前面我們說過,TCP頭裏有一個字段叫Window,又叫Advertised-Window,這個字段是接收端告訴發送端自己還有多少緩衝區可以接收數據。於是發送端就可以根據這個接收端的處理能力來發送數據,而不會導致接收端處理不過來。 爲了說明滑動窗口,我們需要先看一下TCP緩衝區的一些數據結構:
上圖中,我們可以看到:
接收端LastByteRead指向了TCP緩衝區中讀到的位置,NextByteExpected指向的地方是收到的連續包的最後一個位置,LastByteRcved指向的是收到的包的最後一個位置,我們可以看到中間有些數據還沒有到達,所以有數據空白區。
發送端的LastByteAcked指向了被接收端Ack過的位置(表示成功發送確認),LastByteSent表示發出去了,但還沒有收到成功確認的Ack,LastByteWritten指向的是上層應用正在寫的地方。
於是:
接收端在給發送端回ACK中會彙報自己的AdvertisedWindow = MaxRcvBuffer – LastByteRcvd – 1;
而發送方會根據這個窗口來控制發送數據的大小,以保證接收方可以處理。
下面我們來看一下發送方的滑動窗口示意圖:
上圖中分成了四個部分,分別是:(其中那個黑模型就是滑動窗口)
- 1已收到ack確認的數據。
- 2發還沒收到ack的。
- 3在窗口中還沒有發出的(接收方還有空間)。
- 4窗口以外的數據(接收方沒空間)
下面是個滑動後的示意圖(收到36的ack,併發出了46-51的字節):
下面我們來看一個接受端控制發送端的圖示:
Zero Window
上圖,我們可以看到一個處理緩慢的Server(接收端)是怎麼把Client(發送端)的TCP Sliding Window給降成0的。此時,你一定會問,如果Window變成0了,TCP會怎麼樣?是不是發送端就不發數據了?是的,發送端就不發數據了,你可以想像成“Window Closed”,那你一定還會問,如果發送端不發數據了,接收方一會兒Window size 可用了,怎麼通知發送端呢?
解決這個問題,TCP使用了Zero Window Probe技術,縮寫爲ZWP,也就是說,發送端在窗口變成0後,會發ZWP的包給接收方,讓接收方來ack他的Window尺寸,一般這個值會設置成3次,第次大約30-60秒(不同的實現可能會不一樣)。如果3次過後還是0的話,有的TCP實現就會發RST把鏈接斷了。
TCP的擁塞處理 – Congestion Handling
上面我們知道了,TCP通過Sliding Window來做流控(Flow Control),但是TCP覺得這還不夠,因爲Sliding Window需要依賴於連接的發送端和接收端,其並不知道網絡中間發生了什麼。TCP的設計者覺得,一個偉大而牛逼的協議僅僅做到流控並不夠,因爲流控只是網絡模型4層以上的事,TCP的還應該更聰明地知道整個網絡上的事。
具體一點,我們知道TCP通過一個timer採樣了RTT並計算RTO,但是,如果網絡上的延時突然增加,那麼,TCP對這個事做出的應對只有重傳數據,但是,重傳會導致網絡的負擔更重,於是會導致更大的延遲以及更多的丟包,於是,這個情況就會進入惡性循環被不斷地放大。試想一下,如果一個網絡內有成千上萬的TCP連接都這麼行事,那麼馬上就會形成“網絡風暴”,TCP這個協議就會拖垮整個網絡。這是一個災難。
所以,TCP不能忽略網絡上發生的事情,而無腦地一個勁地重發數據,對網絡造成更大的傷害。對此TCP的設計理念是:TCP不是一個自私的協議,當擁塞發生的時候,要做自我犧牲。就像交通阻塞一樣,每個車都應該把路讓出來,而不要再去搶路了。
關於擁塞控制的論文請參看《Congestion Avoidance and Control》(PDF)
擁塞控制主要是四個算法:1)慢啓動,2)擁塞避免,3)擁塞發生,4)快速恢復。這四個算法不是一天都搞出來的,這個四算法的發展經歷了很多時間,到今天都還在優化中。 備註:
1988年,TCP-Tahoe 提出了1)慢啓動,2)擁塞避免,3)擁塞發生時的快速重傳
1990年,TCP Reno 在Tahoe的基礎上增加了4)快速恢復
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 網絡編程》中就有提到了,它們分別是:
- 傳統的阻塞 I/O(Blocking I/O)
- 非阻塞 I/O(Non-blocking I/O)
- I/O 多路複用(I/O multiplexing)
- 異步 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 模式。
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。
協議結構(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。
方式 3:固定長度。RPC 經常採用這種方式,使用 header+payload 的方式。
比如 HTTP 協議,建立在 TCP 之上最廣泛使用的 RPC,HTTP 頭中肯定有一個 body length 告知應用層如何去讀懂一個 message,做 HTTP 包的識別。
可靠性(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 框架一覽
國內
Dubbo。來自阿里巴巴 http://dubbo.I/O/
Motan。新浪微博自用 https://github.com/weibocom/motan
Dubbox。噹噹基於 dubbo 的 https://github.com/dangdangdotcom/dubbox
rpcx。基於 Golang 的 https://github.com/smallnest/rpcx
Navi & Navi-pbrpc。作者開源的 https://github.com/neoremind/navi https://github.com/neoremind/navi-pbrpc
國外
Thrift from facebook https://thrift.apache.org
Avro from hadoop https://avro.apache.org
Finagle by twitter https://twitter.github.I/O/finagle
gRPC by Google http://www.grpc.I/O (Google inside use Stuppy)
Hessian from cuacho http://hessian.caucho.com
Coral Service inside amazon (not open sourced)
https://coolshell.cn/articles/11564.html