后端如何发出请求_gRPC系列(三) 如何借助HTTP2实现传输
本系列分為四大部分:
- gRPC系列(一) 什么是RPC?
- gRPC系列(二) 如何用Protobuf組織內容
- gRPC系列(三) 如何借助HTTP2實現傳輸
- gRPC系列(四) 框架如何賦能分布式系統 (盡情期待)
回顧
在系列二中,我們一起學習了gRPC如何使用Protobuf來組織數據,達到高效編解碼、高壓縮率的目標。本文我們將更進一步,看看這些數據是如何在網絡中被傳輸的,達到以更低的資源實現更高效傳輸的目標。內容將圍繞以下幾點展開:
- HTTP2 要解決的問題,HTTP1.1的缺點
- HTTP2 的原理,它是如何降低傳輸成本,借此我們更深入理解何為
二進制編碼;同時它是如何提高網絡資源利用效率,重溫多路復用的思想 - 拉通Protobuf和HTTP2,通過抓包,從
數據和協議角度洞悉gRPC調用
網絡傳輸的目標
數據的傳輸,都是被切割成一個個小塊,包在層層網絡協議頭里,通過一個個路由器依次轉發,最終到達目的地,被重新組裝起來。這是網絡傳輸的基本原理,在這個過程中,有兩個亙古不變的目標:
- 更快的傳輸。快的背后就是少,傳輸的數據越少、越小,整體的速度也就越快。
- 更低的資源消耗。這背后是資源的高效利用,就像cpu那樣,壓榨的越厲害,就越節約資源。
隨著行業的發展,對上述兩個目標的追求也更加極致,要想傳輸速度更快,傳輸的數據體積要小。數據體積拆開來看,有兩個部分:
- 請求本身的數據。這是系列(二) 中討論的核心,用Protobuf實現極致的壓縮,感興趣可以回看
- 協議本身的消耗。協議需要自我表達,這會消耗一部分空間。這是本文的重點,會討論HTTP2如何降低HTTP1.1在協議上的消耗
HTTP1.1 被視為差生
HTTP1.1以其簡單、可讀性高、超高普及率、歷史悠久,作為經典的存在,為互聯網的普及做出了重要貢獻。但在當今超高的流量、超高的使用頻率背景下,打開一個頁面動輒幾十個請求,使得速度已經難以滿足貪婪人類的需求。這主要表現在以下幾個方面:
一、 冗余文本過多,導致傳輸體積很大
作為一款經典的無狀態協議,它使得Web后端可以靈活地轉發、橫向擴展,但其代價是每個請求都會帶上冗余重復的Header,這些文本內容會消耗很多空間,和更快傳輸的目標相左。
二、 并發能力差,網絡資源利用率低
HTTP1.1 是基于文本的協議,請求的內容打包在header/body中,內容通過rn來分割,同一個TCP連接中,無法區分request/response是屬于哪個請求,所以無法通過一個TCP連接并發地發送多個請求,只能等上一個請求的response回來了,才能發送下一個請求,否則無法區分誰是誰。
于是H1.1提出了一個pipeline的特性,允許請求方一口氣并發多個request,但對服務方有一個變態的要求,需要對應的response按照request的順序嚴格排列,因為不按順序排列就分不清楚response是屬于哪個request的。這給Proxy(Nginx等)帶來了復雜性,同時如果第一個請求遲遲不返回,那后面的請求都會受影響,所以普及率不高。
但當今的Web頁面有玲瑯滿目的圖片、js、css,如果讓請求一個個串行執行,那頁面的渲染會變得極慢。于是只能同時創建多個TCP連接,實現并發下載數據,快速渲染出頁面。這會給瀏覽器造成較大的資源消耗,電腦會變卡。很多瀏覽器為了兼顧下載速度和資源消耗,會對同一個域名限制并發的TCP連接數量,如Chrome是6個左右,剩下的請求則需要排隊,Network下的Waterfall就可以觀察排隊情況 (見下圖右邊的顏色條)。
H1.1時,有6個并發連接,可以看到最下面三個請求在排隊:
HTTP2中,可以看出請求時同時發出的,沒有排隊,且只占用一個連接:
狡猾的人類為了避開這個數量限制,將圖片、css、js等資源放在不同域名下(或二級域名),避開排隊導致的渲染延遲??焖傧螺d的目標實現了,但這和更低的資源消耗目標相違背,背后都是高昂的帶寬、CDN成本。
HTTP2 當救世主
H1.1 在速度和成本上的權衡讓人糾結不已,HTTP2的出現就是為了優化這些問題,在更快的傳輸和更低的成本兩個目標上更進了一步。有以下幾個基本點:
- HTTP2 未改變HTTP的語義(如GET/POST等),只是在傳輸上做了優化
- 引入幀、流的概念,在TCP連接中,可以區分出多個request/response
- 一個域名只會有一個TCP連接,借助幀、流可以實現多路復用,降低資源消耗
- 引入二進制編碼,降低header帶來的空間占用
核心可分為 頭部壓縮 和 多路復用。這兩個點都服務于更快的傳輸、更低的資源消耗這兩個目標,與上文呼應。
頭部壓縮
現在的web頁面,大多比較復雜,新打開一個地址,動輒產生幾十個請求,這會發送大量的header,大部分內容都是一樣的內容,以baidu為例:
request:
GET HTTP/1.1
Host: www.baidu.com
Cache-Control: no-cache
Postman-Token: a9702bac-94c4-c7da-2041-7c7ac5f85b6eresponse:
Access-Control-Allow-Credentials: true
Connection: keep-alive
Content-Encoding: gzip
Content-Type: text/html; charset=UTF-8
Server: Apache
Transfer-Encoding: chunked
Vary: Accept-Encoding這些文本內容一次次重復地發送,占用了大量的帶寬,如何將這些成本降下去,而又保留HTTP無狀態的優點呢?
基于這個想法,誕生了HPACK[2],全稱為HTTP2頭部壓縮,它以極富創造力的方式,提供了兩種方式極大地降低了header的傳輸占用。
一、將高頻使用的Header編成一個靜態表,每個header對應一個數組索引,每次只用傳這個索引,而不是冗長的文本。表總共有61項,下圖是前30項:
- 傳 3 代表 "POST",這用一個字節表示了原來4個字節
- 傳28代表content-length,這用一個字節表示了原來14個字節(value下文會討論)
可以預見這種方式,在大量的請求環境下,可以明顯降低傳輸內容。服務端根據內容查表,就可以還原出header。
二、支持動態地在表中增加header
Host: www.baidu.com在上面的實例中,打開baidu時,對應域名的所有請求都會帶上Host,這又是重復冗余的數據。但由于各家網站的host不相同,無法像上面那樣做成一個靜態的表。HPACK支持動態地在表中增加header,例如:
62 Host: www.baidu.com在請求發起前,通過協議將上面Header添加到表中,則后面的請求都只用發送62即可,不用再發送文本,這又節約了大量空間。(請求方/服務方的表成員會保持同步一致)
上面兩個分別被成為靜態表和動態表。靜態表是協議級別的約定,是不變的內容。動態表則是基于當前TCP連接進行協商的結果,發送請求時會相互設置好header,讓請求方和服務方維護同一份動態表,后續的請求可復用。連接銷毀時,動態表也會注銷。
多路復用
H1.1核心的尷尬點在于,在同一個TCP連接中,沒辦法區分response是屬于哪個請求,一旦多個請求返回的文本內容混在一起,就天下大亂,所以請求只能一個個串行排隊發送。這直接導致了TCP資源的閑置。
HTTP2為了解決這個問題,提出了流的概念,每一次請求對應一個流,有一個唯一ID,用來區分不同的請求?;诹鞯母拍?#xff0c;進一步提出了幀,一個請求的數據會被分成多個幀,方便進行數據分割傳輸,每個幀都唯一屬于某一個流ID,將幀按照流ID進行分組,即可分離出不同的請求。這樣同一個TCP連接中就可以同時并發多個請求,不同請求的幀數據可穿插在一起,根據流ID分組即可。這樣直接解決了H1.1的核心痛點,通過這種復用TCP連接的方式,不用再同時建多個連接,提升了TCP的利用效率。 這也是多路復用思想的一種落地方式,在很多消息隊列協議中也廣泛存在,如AMQP[4],其channel的概念和流如出一轍,大道相通。
在HTTP2中,流是一個邏輯上的概念,實際上就是一個int類型的ID,可順序自增,只要不沖突即可,每條幀數據都會攜帶一個流ID,當一串串幀在TCP通道中傳輸時,通過其流ID,即可區分出不同的請求。
幀則有更多較為復雜的作用,HTTP2幾乎所有數據交互,都是以幀為單位進行的,包括header、body、約定配置(除了Magic串),這天然地就需要給幀進行分類,于是協議約定了以下幀類型:
- HEADERS:幀僅包含 HTTP header信息。
- DATA:幀包含消息的所有或部分請求數據。
- PRIORITY:指定分配給流的優先級。服務方可先處理高優先請求
- RST_STREAM:錯誤通知:一個推送承諾遭到拒絕。終止某個流。
- SETTINGS:指定連接配置。(用于配置,流ID為0) [會ACK確認收到]
- PUSH_PROMISE:通知一個將資源推送到客戶端的意圖。
- PING:檢測信號和往返時間。(流ID為0)[會ACK]
- GOAWAY:停止為當前連接生成流的停止通知。
- WINDOW_UPDATE:用于流控制,約定發送窗口大小。
- CONTINUATION:用于繼續傳送header片段序列。
一次HTTP2的請求有以下過程:
- 通過一個或多個SETTINGS幀約定一些數據(會有ACK機制,確認約定內容)
- 請求方通過HEADERS幀將
請求Aheader打包發出 - 請求B可穿插···
- 請求方通過DATA幀將
請求Arequest數據打包發出 - 服務方通過HEADERS幀將
請求Aresponse header打包發出 - 請求C可穿插···
- 服務方通過DATA幀將
請求Aresponse數據打包發出
深入HTTP2
前文簡單介紹了,頭部壓縮和多路復用的具體思路和解決問題的方法,接下來我們深入HTTP2,看看這兩個特性是如何落地的,在數據上形成直觀地把握,也借此了解何為二進制編碼。
任何一個應用層的傳輸協議,都需要解決一個問題,那就是如何表示數據結尾,如何分割數據。在H1.1中,我們知道,它粗暴地先發Header,再發body,每個header通過rn文本內容來分割,header和body通過rnrn來分割,通過content-length的值讀取body,一個請求的內容就成功結束。
// 一次請求的返回
200 OKrnHeader1:Value1rnHeader2:Value2rnHeader3:Value3rnrnI am body
// 網絡中實際傳輸的是上面文本的ascii編碼HTTP2 為了降低協議占用,不會使用文本分割,也不會使用文本來表示header。它是如何表示一幀開始、一幀結束、header傳完了、body傳完了呢?
下面是幀格式,所有幀都是一個固定的 9 字節頭部 (payload 之前) 跟一個指定長度的數據(payload):
+-----------------------------------------------+
| Length (24) |
+---------------+---------------+---------------+
| Type (8) | Flags (8) |
+-+-------------+---------------+-------------------------------+
|R| Stream Identifier (31) |
+=+=============================================================+
| Frame Payload (0...) ...
+---------------------------------------------------------------+- Length 代表整個幀的長度,用一個 24 位無符號整數表示。頭部的 9 字節不算在這個長度里。從payload開始讀Length這么多字節,一幀數據也就讀完結束。
- Type 定義 幀 的類型,用 8 bits 表示。幀類型決定了幀的格式和語義,不同類型有差異
- Flags 是為幀類型相關而預留的布爾標識。標識對于不同的幀類型賦予了不同的語義,例如下面會提到的Padding
- R 是一個保留的比特位。這個比特的語義沒有定義,發送時它必須被設置為 (0x0), 接收時需要忽略。
- Stream Identifier 唯一標示一個流,用 31 位無符號整數表示??蛻舳私⒌?sid 必須為奇數,服務端建立的 sid 必須為偶數,值 (0x0) 保留給與整個連接相關聯的幀 (連接控制消息),而不是單個流
- Frame Payload 是主體內容,由幀類型決定(上面的9個字節都是協議本身的消耗,payload才是請求本身的主要內容)
不同的幀類型,有不同的Payload格式,我們分別介紹DATA幀和HEADDERS幀:
DATA幀的Payload:
+---------------+|Pad Length? (8)|+---------------+-----------------------------------------------+| Data (*) ...+---------------------------------------------------------------+| Padding (*) ...+---------------------------------------------------------------+- Pad Length: ? 表示此字段的出現時有條件的,當幀的Flags(8)的第三位為1時,才有效,否則會被忽略
- Data: 傳遞的數據,其長度上限等于幀的 payload 長度減去其他出現的字段長度(如果有pad的話)。在gRPC中,Data這部分內容就是用Protobuf將數據編碼的結果
- Padding: 填充字節,沒有具體語義,發送時必須設為 0,作用是混淆報文長度,為安全目的服務
Data幀的Flags(8)目前有兩個位有意義:
- END_STREAM: bit 0 設為 1 代表當前流的最后一幀,告訴接收方請求數據發送完畢,否則還要繼續等下一幀(接收方)
- PADDED: bit 3 設為 1 代表存在 Padding
HEADER幀Payload:
+---------------+|Pad Length? (8)|+-+-------------+-----------------------------------------------+|E| Stream Dependency? (31) |+-+-------------+-----------------------------------------------+| Weight? (8) |+-+-------------+-----------------------------------------------+| Header Block Fragment (*) ...+---------------------------------------------------------------+| Padding (*) ...+---------------------------------------------------------------+- Pad Length: 同DATA幀
- E: 一個比特位聲明流的依賴性是否是排他的,存在則代表 PRIORITY flag 被設置
- Stream Dependency: 指定一個 stream identifier,代表當前流所依賴的流的 id,存在則代表 PRIORITY flag 被設置
- Weight: 一個無符號 8 為整數,代表當前流的優先級權重值 (1~256),存在則代表 PRIORITY flag 被設置
- Header Block Fragment: header 塊片段,header依次打包排列在里面
- Padding: 同DATA幀
HEADERS 幀有以下標識 (flags):
- END_STREAM: bit 0 設為 1 代表當前請求 header 發送完了(可能有CONTINUATION幀,可以認為是HEADERS的一部分)
- END_HEADERS: bit 2 設為 1 代表 header 塊結束
- PADDED: bit 3 設為 1 代表 Pad 被設置,存在 Pad Length 和 Padding
- PRIORITY: bit 5 設為 1 表示存在 Exclusive Flag (E), Stream Dependency, 和 Weight
請求的header打包在Header Block Fragment ,我們重點關注一下,以便理解header是如何被傳輸的。
由于上面頭部壓縮的內容,我們知道header可以存在于靜態表、動態表中。此時只需要傳一個index即可表達對應的header,減少傳輸內容。 請求傳遞的header情況有以下幾種:
- header 的key、value 在靜態表/動態表中,此時只需要傳遞一個index即可
- header 的key 在靜態、動態表中,而value由于多種多樣,不在表中(如Host),此時key可以由index表示,但value需要傳遞原內容
- header 的key、value完全不在靜態、動態表中,key、value都需要傳遞原內容(字符串)
- 希望將本次傳遞的header寫入動態表中,下次只需要傳index 即可
- 不希望本次傳遞的header寫入動態表中
Header Block Fragment 中打包header的方式也就是按照上面幾種情況展開,具體篇幅較多,本文找一個復雜點的例子: key、value都不在表中,且需要添加進表中的情況進行舉例:(更詳細HPACK細節可見[6]、[7])
+---+---+---+---+---+---+---+---+
| 0 | 1 | 0 | // 通過頭8個bit表示是哪種case
+---+---+-----------------------+
| H | Key Length (7+) |
+---+---------------------------+
| Key String (Length octets) |
+---+---------------------------+
| H | Value Length (7+) |
+---+---------------------------+
| Value String (Length octets) |
+-------------------------------+- 頭8個bit中
01000000表達了兩點
- header的key不在表中(
000000)、value也不在(需要傳文本內容) - 希望將此header追加到動態表中,供下次使用(
01開頭表示需要追加到表中)
- Value Length 代表對應value的長度,借此可讀取完整的Value String
- 其余的情況都可以用頭8個bit表示[7]
- 多個上面的結構前后拼接在一起,就可以在一個HEADERS幀中表示多個header了
- 第二行H為1表示value用了霍夫曼編碼[9],可以理解為一種文本壓縮策略
上面的場景下,header的內容包含key的Index,value的長度、value的文本內容,其實可分為兩種:
- 數字的表達。 key的Index、key/value文本內容的長度
- 字符串的表達。key的內容(如custom-key)、value的內容(custom-value)
對于 custom-key: custom-header表達示例:(來源[10])
編碼數據的十六進制表示:
400a 6375 7374 6f6d 2d6b 6579 0d63 7573 | @.custom-key.cus
746f 6d2d 6865 6164 6572 | tom-header解碼過程:40 | == Literal indexed == (01000000表示要追加到表中)
0a | Literal name (len = 10) (得到key長度)
6375 7374 6f6d 2d6b 6579 | custom-key
0d | Literal value (len = 13) (得到value長度)
6375 7374 6f6d 2d68 6561 6465 72 | custom-header (一個key:value 讀取完畢)解碼結果可得header: custom-key:custom-header
并將其加入動態表,下次直接只傳index 上圖中有Key Length (7+) 和 Value Length (7+),這是上面提到的數字的表達,可以看到有7+這個表示。這里面有一個擴展問題,如果Value的長度比較大,7個bit表示不了咋辦。
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| ? | ? | ? | 1 1 1 1 1 | 第一個字節 N = 5
+---+---+---+-------------------+
| 1 | Value-(2^N-1) LSB |
+---+---------------------------+...
+---+---------------------------+
| 0 | Value-(2^N-1) MSB |
+---+---------------------------+當長度len比較小,len < 2^N - 1, 則直接用第一個字節即可表達。(N <= 7)。 如果len >= 2^N - 1,則需要用后續的字節繼續表達。規則是:
- 選擇一個N,如上面N=5,將第一個字節的后N位全部設為1,則第一個字節表達了 2^N - 1, 剩下的len - (2^N - 1) 用后面的字節表示。
- 將len - (2^N - 1) 用二進制表示出來,將二進制位分別分給下面的字節
- 只占用后面字節的后7位
- 如果第一位為0,則表示表達完畢,為1 則表示下一個字節還在繼續表示len
示例: (來源[10])
- 表達長度為: 1337,設N = 5
- 1337 大于 31(2^5-1),并使用 5 位前綴表示。5 位前綴使用其最大值(31)填充
- 除第一個字節外,后面字節表達 1337 - 31 = 1306
- 1036 二進制串為: 010100011010,用多個字節表達
0 1 2 3 4 5 6 7+---+---+---+---+---+---+---+---+| X | X | X | 1 | 1 | 1 | 1 | 1 | 第一個字節表達了 2^N - 1 = 31, 下面的字節表達 1337 - 31 = 1306| 1 | 0 | 0 | 1 | 1 | 0 | 1 | 0 | 后面一截: 0011010 (低位)| 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 前面一截: 01010 (高位)+---+---+---+---+---+---+---+---+gRPC 請求抓包
上文已經搞清楚了HTTP2的傳輸原理,接下來通過wireshark透視一下gRPC調用的過程。
請求內容:
請求返回:
幀示例:
先約定配置,SETTINGS幀有ACK表達確認
- 請求的Method在header中傳遞
- 參數用DATA幀
- 返回狀態用HEADER幀
- 返回數據用DATA幀
可見調用語義和HTTP并無差別,但通過協議優化,在很大程度上降低了傳輸的體積,節省資源的同時,也較好地提升了性能。
看了單個請求的抓包樣例,我們得再看看gRPC的stream是什么鬼,代碼約定如下:
// proto
service XXX {rpc StreamTest(stream StreamTestReq) returns (stream StreamTestResp);
}
message StreamTestReq {int64 i = 1;
}
message StreamTestResp {int64 j = 1;
}
// server端代碼
func (s *XXXService) StreamTest(re v1pb.XXX_StreamTestServer ) (err error) {for {data, err := re.Recv()if err != nil {break}// 將客戶端發送來的值乘以10再返回給它err = re.Send(&v1pb.StreamTestResp{J: data.I * 10 }) }return
}
// client 端代碼
func TestStream(t *testing.T) {c, _ := service2.daClient.StreamTest(context.TODO())go func(){for {rec, err := c.Recv()if err != nil {break}fmt.Printf("resp: %vn", rec.J)}}()for _, x := range []int64{1,2,3,4,5,6,7,8,9}{_ = c.Send(&dav1.StreamTestReq{I: x})time.Sleep(100*time.Millisecond)}_ = c.CloseSend()
}
// client端輸出結果
resp: 10
resp: 20
resp: 30
resp: 40
resp: 50
resp: 60
resp: 70
resp: 80
resp: 90- 上面是一個雙向stream流
- client和server端同時在收發數據
- client連續發送9次后,中斷過程。常規的流式服務,如視頻編解碼,可以一直持續直到結束
- 服務端將client的參數*10后返回
我們不禁要問,這種流式請求和常規的gRPC有沒有區別? 這從抓包便可知分曉:
上面只提取了http2 和grpc的協議內容,否則會被tcp的ack打亂視野,可以從圖上看到:
- 請求的method只發送了一次
- 服務端的回復header也只返回了一次(200 OK 那行)
- 剩下的就是: client的data幀和server 端的data幀交替
- 其實全場就只有一次請求(stream ID 未變化)
stream模式,其實就是gRPC從協議層支持了,在一次長請求中,分批地處理小量數據,達到多次請求的效果,像流水一樣可以延綿不絕,直到某一方終止。
試想下,如果gRPC內部不支持這種模式,其實也能自己實現流式的服務,只不過在形式上要多調用幾次接口而已。 從上面抓包來看,這種封裝在無論在性能和語義上都更好。
進一步提升
參見HTTP3,拋棄TCP協議,擁抱QUIC。
參考資料
- [1] https://juejin.im/post/5b88a4f56fb9a01a0b31a67e
- [2] https://blog.csdn.net/u010129119/article/details/79392545
- [3] https://tools.ietf.org/html/rfc7541#section-2.3.1
- [4] https://www.rabbitmq.com/tutorials/amqp-concepts.html
- [5]https://zhuanlan.zhihu.com/p/34662800
- [6] https://http2.github.io/http2-spec/compression.html#index.address.space
- [7] https://github.com/halfrost/Halfrost-Field/blob/master/contents/Protocol/HTTP:2_Header-Compression.md
- [8] https://zhuanlan.zhihu.com/p/149821222
- [9]https://zh.wikipedia.org/zh-hans/%E9%9C%8D%E5%A4%AB%E6%9B%BC%E7%BC%96%E7%A0%81
- [10] https://github.com/halfrost/Halfrost-Field/blob/master/contents/Protocol/HTTP:2_HPACK-Example.md#1-%E6%95%B4%E6%95%B0%E8%A1%A8%E7%A4%BA%E7%9A%84%E7%A4%BA%E4%BE%8B
總結
以上是生活随笔為你收集整理的后端如何发出请求_gRPC系列(三) 如何借助HTTP2实现传输的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: centos mysql安装_mysql
- 下一篇: 栀子花百花开是哪首歌啊?