深入瞭解 gRPC:協議

PingCAP發表於2017-07-18

RC3 版本對於 TiKV 來說最重要的功能就是支援了 gRPC,也就意味著後面大家可以非常方便的使用自己喜歡的語音對接 TiKV 了。

gRPC 是基於 HTTP/2 協議的,要深刻理解 gRPC,理解下 HTTP/2 是必要的,這裡先簡單介紹一下 HTTP/2 相關的知識,然後在介紹下 gRPC 是如何基於 HTTP/2 構建的。

HTTP/1.x

HTTP 協議可以算是現階段 Web 上面最通用的協議了,在之前很長一段時間,很多應用都是基於 HTTP/1.x 協議,HTTP/1.x 協議是一個文字協議,可讀性非常好,但其實並不高效,筆者主要碰到過幾個問題:

Parser

如果要解析一個完整的 HTTP 請求,首先我們需要能正確的讀出 HTTP header。HTTP header 各個 fields 使用 \r\n 分隔,然後跟 body 之間使用 \r\n\r\n 分隔。解析完 header 之後,我們才能從 header 裡面的 content-length 拿到 body 的 size,從而讀取 body。

這套流程其實並不高效,因為我們需要讀取多次,才能將一個完整的 HTTP 請求給解析出來,雖然在程式碼實現上面,有很多優化方式,譬如:

  • 一次將一大塊資料讀取到 buffer 裡面避免多次 IO read
  • 讀取的時候直接匹配 \r\n 的方式流式解析

但上面的方式對於高效能服務來說,終歸還是會有開銷。其實最主要的問題在於,HTTP/1.x 的協議是 文字協議,是給人看的,對機器不友好,如果要對機器友好,二進位制協議才是更好的選擇。

如果大家對解析 HTTP/1.x 很感興趣,可以研究下 http-parser,一個非常高效小巧的 C library,見過不少框架都是整合了這個庫來處理 HTTP/1.x 的。

Request/Response

HTTP/1.x 另一個問題就在於它的互動模式,一個連線每次只能一問一答,也就是client 傳送了 request 之後,必須等到 response,才能繼續傳送下一次請求。

這套機制是非常簡單,但會造成網路連線利用率不高。如果需要同時進行大量的互動,client 需要跟 server 建立多條連線,但連線的建立也是有開銷的,所以為了效能,通常這些連線都是長連線一直保活的,雖然對於 server 來說同時處理百萬連線也沒啥太大的挑戰,但終歸效率不高。

Push

用 HTTP/1.x 做過推送的同學,大概就知道有多麼的痛苦,因為 HTTP/1.x 並沒有推送機制。所以通常兩種做法:

  • Long polling 方式,也就是直接給 server 掛一個連線,等待一段時間(譬如 1 分鐘),如果 server 有返回或者超時,則再次重新 poll。
  • Web-socket,通過 upgrade 機制顯示的將這條 HTTP 連線變成裸的 TCP,進行雙向互動。

相比 Long polling,筆者還是更喜歡 web-socket 一點,畢竟更加高效,只是 web-socket 後面的互動並不是傳統意義上面的 HTTP 了。

Hello HTTP/2

雖然 HTTP/1.x 協議可能仍然是當今網際網路運用最廣泛的協議,但隨著 Web 服務規模的不斷擴大,HTTP/1.x 越發顯得捉襟見肘,我們急需另一套更好的協議來構建我們的服務,於是就有了 HTTP/2。

HTTP/2 是一個二進位制協議,這也就意味著它的可讀性幾乎為 0,但幸運的是,我們還是有很多工具,譬如 Wireshark, 能夠將其解析出來。

在瞭解 HTTP/2 之前,需要知道一些通用術語:

  • Stream: 一個雙向流,一條連線可以有多個 streams。
  • Message: 也就是邏輯上面的 request,response。
  • Frame::資料傳輸的最小單位。每個 Frame 都屬於一個特定的 stream 或者整個連線。一個 message 可能有多個 frame 組成。

Frame Format

Frame 是 HTTP/2 裡面最小的資料傳輸單位,一個 Frame 定義如下(直接從官網 copy 的):

+-----------------------------------------------+
|                 Length (24)                   |
+---------------+---------------+---------------+
|   Type (8)    |   Flags (8)   |
+-+-------------+---------------+-------------------------------+
|R|                 Stream Identifier (31)                      |
+=+=============================================================+
|                   Frame Payload (0...)                      ...
+---------------------------------------------------------------+複製程式碼

Length:也就是 Frame 的長度,預設最大長度是 16KB,如果要傳送更大的 Frame,需要顯示的設定 max frame size。
Type:Frame 的型別,譬如有 DATA,HEADERS,PRIORITY 等。
Flag 和 R:保留位,可以先不管。
Stream Identifier:標識所屬的 stream,如果為 0,則表示這個 frame 屬於整條連線。
Frame Payload:根據不同 Type 有不同的格式。

可以看到,Frame 的格式定義還是非常的簡單,按照官方協議,贊成可以非常方便的寫一個出來。

Multiplexing

HTTP/2 通過 stream 支援了連線的多路複用,提高了連線的利用率。Stream 有很多重要特性:

  • 一條連線可以包含多個 streams,多個 streams 傳送的資料互相不影響。
  • Stream 可以被 client 和 server 單方面或者共享使用。
  • Stream 可以被任意一段關閉。
  • Stream 會確定好傳送 frame 的順序,另一端會按照接受到的順序來處理。
  • Stream 用一個唯一 ID 來標識。

這裡在說一下 Stream ID,如果是 client 建立的 stream,ID 就是奇數,如果是 server 建立的,ID 就是偶數。ID 0x00 和 0x01 都有特定的使用場景,不會用到。

Stream ID 不可能被重複使用,如果一條連線上面 ID 分配完了,client 會新建一條連線。而 server 則會給 client 傳送一個 GOAWAY frame 強制讓 client 新建一條連線。

為了更大的提高一條連線上面的 stream 併發,可以考慮調大 SETTINGS_MAX_CONCURRENT_STREAMS,在 TiKV 裡面,我們就遇到過這個值比較小,整體吞吐上不去的問題。

這裡還需要注意,雖然一條連線上面能夠處理更多的請求了,但一條連線遠遠是不夠的。一條連線通常只有一個執行緒來處理,所以並不能充分利用伺服器多核的優勢。同時,每個請求編解碼還是有開銷的,所以用一條連線還是會出現瓶頸。

在 TiKV 有一個版本中,我們就過分相信一條連線跑多 streams 這種方式沒有問題,就讓 client 只用一條連線跟 TiKV 互動,結果發現效能完全沒法用,不光處理連線的執行緒 CPU 跑滿,整體的效能也上不去,後來我們換成了多條連線,情況才好轉。

Priority

因為一條連線允許多個 streams 在上面傳送 frame,那麼在一些場景下面,我們還是希望 stream 有優先順序,方便對端為不同的請求分配不同的資源。譬如對於一個 Web 站點來說,優先載入重要的資源,而對於一些不那麼重要的圖片啥的,則使用低的優先順序。

我們還可以設定 Stream Dependencies,形成一棵 streams priority tree。假設 Stream A 是 parent,Stream B 和 C 都是它的孩子,B 的 weight 是 4,C 的 weight 是 12,假設現在 A 能分配到所有的資源,那麼後面 B 能分配到的資源只有 C 的 1/3。

Flow Control

HTTP/2 也支援流控,如果 sender 端傳送資料太快,receiver 端可能因為太忙,或者壓力太大,或者只想給特定的 stream 分配資源,receiver 端就可能不想處理這些資料。譬如,如果 client 給 server 請求了一個視屏,但這時候使用者暫停觀看了,client 就可能告訴 server 別在傳送資料了。

雖然 TCP 也有 flow control,但它僅僅只對一個連線有效果。HTTP/2 在一條連線上面會有多個 streams,有時候,我們僅僅只想對一些 stream 進行控制,所以 HTTP/2 單獨提供了流控機制。Flow control 有如下特性:

  • Flow control 是單向的。Receiver 可以選擇給 stream 或者整個連線設定 window size。
  • Flow control 是基於信任的。Receiver 只是會給 sender 建議它的初始連線和 stream 的 flow control window size。
  • Flow control 不可能被禁止掉。當 HTTP/2 連線建立起來之後,client 和 server 會交換 SETTINGS frames,用來設定 flow control window size。
  • Flow control 是 hop-by-hop,並不是 end-to-end 的,也就是我們可以用一箇中間人來進行 flow control。

這裡需要注意,HTTP/2 預設的 window size 是 64 KB,實際這個值太小了,在 TiKV 裡面我們直接設定成 1 GB。

HPACK

在一個 HTTP 請求裡面,我們通常在 header 上面攜帶很多改請求的元資訊,用來描述要傳輸的資源以及它的相關屬性。在 HTTP/1.x 時代,我們採用純文字協議,並且使用 \r\n 來分隔,如果我們要傳輸的後設資料很多,就會導致 header 非常的龐大。另外,多數時候,在一條連線上面的多數請求,其實 header 差不了多少,譬如我們第一個請求可能 GET /a.txt,後面緊接著是 GET /b.txt,兩個請求唯一的區別就是 URL path 不一樣,但我們仍然要將其他所有的 fields 完全發一遍。

HTTP/2 為了結果這個問題,使用了 HPACK。雖然 HPACK 的 RFC 文件 看起來比較恐怖,但其實原理非常的簡單易懂。

HPACK 提供了一個靜態和動態的 table,靜態 table 定義了通用的 HTTP header fields,譬如 method,path 等。傳送請求的時候,只要指定 field 在靜態 table 裡面的索引,雙方就知道要傳送的 field 是什麼了。

對於動態 table,初始化為空,如果兩邊互動之後,發現有新的 field,就新增到動態 table 上面,這樣後面的請求就可以跟靜態 table 一樣,只需要帶上相關的 index 就可以了。

同時,為了減少資料傳輸的大小,使用 Huffman 進行編碼。這裡就不再詳細說明 HPACK 和 Huffman 如何編碼了。

小結

上面只是大概列舉了一些 HTTP/2 的特性,還有一些,譬如 push,以及不同的 frame 定義等都沒有提及,大家感興趣,可以自行參考 HTTP/2 RFC 文件

Hello gRPC

gRPC 是 Google 基於 HTTP/2 以及 protobuf 的,要了解 gRPC 協議,只需要知道 gRPC 是如何在 HTTP/2 上面傳輸就可以了。

gRPC 通常有四種模式,unary,client streaming,server streaming 以及 bidirectional streaming,對於底層 HTTP/2 來說,它們都是 stream,並且仍然是一套 request + response 模型。

Request

gRPC 的 request 通常包含 Request-Headers, 0 或者多個 Length-Prefixed-Message 以及 EOS。

Request-Headers 直接使用的 HTTP/2 headers,在 HEADERS 和 CONTINUATION frame 裡面派發。定義的 header 主要有 Call-Definition 以及 Custom-Metadata。Call-Definition 裡面包括 Method(其實就是用的 HTTP/2 的 POST),Content-Type 等。而 Custom-Metadata 則是應用層自定義的任意 key-value,key 不建議使用 grpc- 開頭,因為這是為 gRPC 後續自己保留的。

Length-Prefixed-Message 主要在 DATA frame 裡面派發,它有一個 Compressed flag 用來表示改 message 是否壓縮,如果為 1,表示該 message 採用了壓縮,而壓縮算啊定義在 header 裡面的 Message-Encoding 裡面。然後後面跟著四位元組的 message length 以及實際的 message。

EOS(end-of-stream) 會在最後的 DATA frame 裡面帶上了 END_STREAM 這個 flag。用來表示 stream 不會在傳送任何資料,可以關閉了。

Response

Response 主要包含 Response-Headers,0 或者多個 Length-Prefixed-Message 以及 Trailers。如果遇到了錯誤,也可以直接返回 Trailers-Only。

Response-Headers 主要包括 HTTP-Status,Content-Type 以及 Custom-Metadata 等。Trailers-Only 也有 HTTP-Status ,Content-Type 和 Trailers。Trailers 包括了 Status 以及 0 或者多個 Custom-Metadata。

HTTP-Status 就是我們通常的 HTTP 200,301,400 這些,很通用就不再解釋。Status 也就是 gRPC 的 status, 而 Status-Message 則是 gRPC 的 message。Status-Message 採用了 Percent-Encoded 的編碼方式,具體參考這裡

如果在最後收到的 HEADERS frame 裡面,帶上了 Trailers,並且有 END_STREAM 這個 flag,那麼就意味著 response 的 EOS。

Protobuf

gRPC 的 service 介面是基於 protobuf 定義的,我們可以非常方便的將 service 與 HTTP/2 關聯起來。

  • Path : /Service-Name/{method name}
  • Service-Name : ?( {proto package name} "." ) {service name}
  • Message-Type : {fully qualified proto message name}
  • Content-Type : "application/grpc+proto"

後記

上面只是對 gRPC 協議的簡單理解,可以看到,gRPC 的基石就是 HTTP/2,然後在上面使用 protobuf 協議定義好 service RPC。雖然看起來很簡單,但如果一門語言沒有 HTTP/2,protobuf 等支援,要支援 gRPC 就是一件非常困難的事情了。

悲催的是,Rust 剛好沒有 HTTP/2 支援,也僅僅有一個可用的 protobuf 實現。為了支援 gRPC,我們 team 付出了很大的努力,也走了很多彎路,從最初使用純 Rust 的 rust-grpc 專案,到後來自己基於 c-grpc 封裝了 grpc-rs,還是有很多可以說的,後面在慢慢道來。如果你對 gRPC 和 rust 都很感興趣,歡迎參與開發。

gRPC-rs: github.com/pingcap/grp…

作者:唐劉

相關文章