HTTP-2 工作原理與 Go 實戰
在瞭解了上一篇文章的 net/rpc 內容(從 Go 應用中的 net/rpc 到 gRPC)後,現在是時候深入瞭解 HTTP/2 了,它是 gRPC 協議的基礎。
HTTP/2 原理及 Go 實戰指南
本文偏重理論講解,內容會比較密集。我們主要聚焦 HTTP/2 的核心概念,之後簡要介紹如何在 Go 中啓用它。建議泡杯咖啡,坐下來慢慢看,讓我們一步步拆解這個話題。
HTTP/2 優勢
HTTP/2 相比 HTTP/1.1 是一次重大升級,如今已經成爲各處的默認標準。如果你曾用 Chrome 開發者工具查看網絡請求,大概率已經見過 HTTP/2 連接的運行狀態。
用 Chrome 檢查 HTTP/2 連接
那麼 HTTP/2 爲什麼如此重要?HTTP/1.1 又有什麼問題?
HTTP/1.1 確實引入了管道機制,這個設計從理論上看很完美。原理很簡單:多個請求可以共享同一個連接,不必等待前一個請求完成就能發起下一個。
HTTP/1.1 管道機制:順序處理請求
問題在於請求必須按順序發出,響應也必須按相同順序返回。如果某個響應延遲了——比如服務器需要額外時間處理——隊列中的其他所有請求都得等着。
如果網絡出現 "小故障" 導致某個請求延遲,也會出現這種情況。整個響應管道會停滯,直到那個延遲的請求處理完成。
HTTP/1.1 中的隊頭阻塞
這個問題就是所謂的隊頭阻塞(Head-of-Line blocking)。
爲了繞過這個限制,HTTP/1.1 客戶端(比如你的瀏覽器)開始對同一服務器建立多個 TCP 連接,讓請求能夠更自由、併發地流動。
雖然這個方法可行,但效率並不高:
- 更多連接意味着客戶端和服務器端都要消耗更多資源
- 每個連接都要經過 TCP 握手過程,增加了額外延遲
"那麼,HTTP/2 解決這個問題了嗎?"
解決了... 大部分吧。
HTTP/2 將單一連接分割成多個獨立的數據流。每個數據流都有其唯一標識符,即流 ID,這些數據流能夠並行工作。這種設計在應用層(HTTP 所在層)解決了隊頭阻塞(HoL)問題。即使某個數據流發生延遲,也不會影響其他數據流繼續傳輸。
單一連接上的多流數據幀
但 HTTP/2 仍然基於 TCP 運行,所以並沒有完全擺脫隊頭阻塞問題。
在傳輸層,TCP 堅持按順序嚮應用層交付數據包。如果某個數據包丟失或延遲,TCP 會讓所有後續包等待,直到解決這個缺失部分。一旦延遲的數據包到達,TCP 就會按正確順序將排隊的數據包交付給 HTTP/2 層(或應用層)。
即便其他數據流的內容已經在緩衝區準備就緒,服務器仍需等待延遲數據流到達後才能處理剩餘部分。
要徹底突破 TCP 的侷限性,就得考慮像 QUIC 這樣基於 UDP(用戶數據報協議)的協議,這也是 HTTP/3 的核心動力。
當然,HTTP/2 不僅解決了 HTTP/1.1 的痛點,還開啓了新的可能性。讓我們深入瞭解它是如何運作的。
HTTP/2 工作原理
客戶端建立 TLS 連接時,首先發送一個 ClientHello
消息。該消息包含 ALPN(應用層協議協商)擴展,列出客戶端支持的協議清單。通常包括用於 HTTP/2 的 "h2"、作爲後備方案的 "http/1.1" 以及其他協議。
服務器的 TLS 協議棧會將這份清單與自身支持的協議進行匹配。如果雙方都認可 "h2",服務器就會在 ServerHello
響應中確認這個選擇。
此後,TLS 握手會按常規流程繼續,包括設置加密密鑰、驗證證書等步驟。
連接序言
握手完成後,客戶端會發送一個連接序言。它以一個特定的 24 字節序列開始: PRI*HTTP/2.0\r\n\r\nSM\r\n\r\n
。這個序列用於確認正在使用的是 HTTP/2 協議。此時還未開始壓縮和分幀。
緊接着連接序言,客戶端會發送一個 SETTINGS 幀。這是一個與任何流都無關的連接級控制幀,相當於向服務器表明:"這是我的偏好設置。" 其中包括流量控制選項、最大幀大小等配置。
服務端與客戶端交換 SETTINGS 幀
服務器理解客戶端的意圖後,會發送自己的連接序言作爲響應,其中包含一個 SETTINGS
幀。 當交換完成後,連接就建立好了。
客戶端準備發送請求時,會創建一個帶有唯一標識符的新數據流,稱爲流 ID。客戶端發起的數據流 ID 總是奇數 — 1、3、5 等。
你可能會問,爲什麼流 ID 要用奇數而不是像 1、2、3 這樣連續編號?這裏有個巧妙的規則:
- 奇數流 ID 專門用於客戶端發起的請求。
- 偶數流 ID 留給服務器使用,通常用於服務器推送等服務器端發起的功能。
- 流 ID 0 比較特殊,僅用於連接級別(非流級別)的控制幀,這些控制幀作用於整個連接。
當數據流就緒後,客戶端發送一個 HEADERS
幀。 這個幀包含了所有你期望的頭部信息——相當於 HTTP/1.1 請求行和頭部信息(比如 GET/HTTP/1.1
及其後續內容)。不過,這些頭部的結構和傳輸方式有所不同。
- 結構:HTTP/2 引入了僞頭部字段,用於定義方法、路徑和狀態等信息。之後纔是我們熟悉的頭部字段,如
User-Agent
、Content-Type
等。 - 傳輸:頭部信息採用 HPACK 算法壓縮,以二進制格式傳輸。
"僞頭部?HPACK 壓縮?這是什麼操作?"
讓我們先來理解僞頭部字段。
如果你經常使用 Chrome 的開發者工具或其他檢查工具,這些可能已經很眼熟了。 在 HTTP/2 中,僞頭部字段是一種將特殊頭部與常規頭部分開的方式。這些特殊頭部(如 :method
、 :path
、 :scheme
和 :status
)總是排在最前面。之後纔是常規頭部,比如 Accept
、 Host
和 Content-Type
,它們按照常規格式排列。
HTTP/1.1 與 HTTP/2 頭部格式對比
在 HTTP/1.1 中,這類信息分散在請求行和頭部字段中。這種設計並不夠優雅,往往需要依賴約定或上下文來補充缺失的信息。舉例來說:
- 協議類型(HTTP 或 HTTPS)通常由連接方式暗示。如果是使用 443 端口的 TLS 連接,那自然就是 HTTPS。
- 在 HTTP/1.1 中爲虛擬主機而添加的
Host
請求頭,僅僅是衆多請求頭之一,並非請求結構的核心組成部分。
HTTP/2 引入僞頭部字段(以冒號開頭,如: method 或: path)後,這些模糊之處便一掃而空。
"那 HPACK 壓縮又是怎麼回事?"
與 HTTP/1.1 採用換行符分隔的純文本頭部( \r\n
)不同,HTTP/2 使用二進制格式對頭部進行編碼。這就是 HPACK 壓縮算法大顯身手的地方,它是專門爲 HTTP/2 設計的。HPACK 不僅能節省空間,還能避免重複傳輸相同的頭部數據。
HPACK 巧妙地運用兩個表格來管理頭部:靜態表和動態表。 靜態表就像是客戶端和服務器之間已共享的字典。它包含了 61 個最常用的 HTTP 頭部。如果你想了解詳情,可以查看 net/http2
包中的 static_table.go 文件。
常用 HTTP 頭部的靜態表
假設你發送了一個帶有 :method:GET
頭部的 GET 請求。
HPACK 不需要傳輸整個頭部,只需發送數字 2 即可。這個數字對應靜態表中的鍵值對 :method:GET
,通信雙方都能理解其含義。 如果鍵值匹配但具體值不同,比如說是 etag:some-random-value
,HPACK 依然可以重用這個鍵(在這個例子中是 34),只需傳輸更新後的值就行。這樣一來,就不用重複傳輸完整的頭部名稱了。
" 那
some-random-value
會怎麼處理呢?"
它會通過霍夫曼編碼,被編碼爲 34:huffman("some-random-value")
(僞代碼)。有趣的是,整個頭部 etag:some-random-value
會被添加到動態表中。
動態表最初是空的,隨着新的頭部(不在靜態表中的)被髮送而逐漸增長。這使得 HPACK 成爲一個有狀態的協議,也就是說客戶端和服務器在整個連接期間都需要維護各自的動態表。 每個加入動態表的新首部都會獲得一個唯一索引值,從 62 開始(因爲 1-61 已被靜態表佔用)。此後便可使用該索引值,無需重複傳輸首部。這種設計具有以下特點:
- 連接級別:動態表在同一連接的所有數據流間共享。服務端和客戶端各自維護一份副本。
- 容量限制:動態表默認最大容量爲 4 KB(4,096 字節),可通過
SETTINGS_HEADER_TABLE_SIZE
幀中的SETTINGS
參數調整。當表滿時,舊首部會被逐出以騰出空間容納新首部。
數據幀詳解
如果存在請求體,會通過 DATA
幀發送。當請求體大於最大幀大小(默認 16KB)時,會被拆分成多個 DATA
幀,這些幀共用同一個流 ID。
單個 TCP 連接承載多個數據流
"那麼,幀中的流 ID 在哪裏?"
問得好。我們還沒有討論幀的結構。
HTTP/2 中的幀不僅僅是數據或頭部的容器。每個幀都包含一個 9 字節的幀頭。這與我們之前討論的 HTTP 頭部不同,這是一個幀頭部。
來看看具體結構:首先是長度,用於標識幀負載的大小(不包括幀頭本身)。接着是類型,用來區分幀的種類(比如 DATA、HEADERS、PRIORITY 等)。然後是標誌位,提供幀的額外信息。舉個例子, END_STREAM
標誌(0x1)表示該流上不會再有後續幀。
最後是流標識符。這是一個 32 位的數值,用於標識幀所屬的流(最高有效位是保留位,必須設爲 0)。
"那流內幀的順序怎麼辦?要是幀亂序到達呢?"
沒錯,雖然流標識符告訴我們幀屬於哪個流,但它並不能指定幀的順序。 答案就在 TCP 層。由於 HTTP/2 是基於 TCP 運行的,該協議能確保數據包按序傳輸。即便數據包在網絡中經由不同路徑傳輸,TCP 也能保證接收方收到的數據包順序與發送時完全一致。
這與我們之前討論的隊頭阻塞問題息息相關。
當服務器接收到 HEADERS
幀時,會使用與請求相同的流 ID 創建新的數據流。
服務器首先會發送自己的 HEADERS
幀,其中包含響應狀態和頭部信息(使用 HPACK
壓縮)。隨後,響應主體通過 DATA
幀發送。得益於多路複用技術,服務器可以將多個流的幀交錯發送,在同一連接上同時傳輸不同響應的數據塊。
在客戶端,響應幀會按照流 ID 進行排序。客戶端對 HEADERS
幀進行解壓,並按順序處理 DATA
幀。
即使同時存在多個活躍流,所有內容依然能保持對齊。
流量控制
當接收到一個幀時,如果其 END_STREAM
標誌位被設置(幀頭部標誌位字段的第 1 位被置爲 1),這就是一個信號。它告訴接收方:"就這樣了,這個流上不會再有更多幀了。" 此時,服務器可以發送回請求的數據,並在響應中設置自己的 END_STREAM
標誌來結束這個流。
但是結束流並不會關閉整個連接。連接保持開放狀態,其他流可以繼續正常運作。
如果服務器需要主動關閉連接,它會發送一個 GOAWAY
幀。這是一個連接級別的控制幀,用於優雅地關閉連接。 當服務器發送 GOAWAY
幀時,會包含它計劃處理的最後一個流 ID。這條消息實際上在說:"我準備收尾了,更高 ID 的流將不會被處理,但已在進行中的流可以正常完成。" 這就是爲什麼它被稱爲優雅關閉。
發送 GOAWAY
之後,發送方通常會稍作等待,讓接收方有時間處理這條消息並停止發送新流。這個短暫的停頓可以避免突然的 TCP 重置(RST),否則會立即終止所有流,造成混亂。
HTTP/2 工具箱中還有一些實用功能。在連接期間,通信雙方可以發送 WINDOW_UPDATE
幀來控制數據流量,用 PING
幀來檢測連接是否存活,通過 PRIORITY
幀來精確調整流的優先級。如果出現問題, RST_STREAM
幀可以選擇性地關閉單個數據流,而不會影響整個連接。
以上就是 HTTP/2 的主要內容。接下來,我們看看如何在 Go 中實現這些功能。
Go 語言中的 HTTP/2 實現
你可能沒注意到,Go 語言的 net/http
包其實已經內置支持 HTTP/2 了。
"等等,是不是說 HTTP/2 默認就啓用了?"
這個問題不能簡單地回答是或否。
如果你的服務跑在 HTTPS 上,HTTP/2 很可能已經自動啓用了。但如果只是普通的 HTTP,那就未必了。以下幾種常見場景可能導致 HTTP/2 無法生效:
- 你的服務使用普通 HTTP,僅僅用了簡單的
ListenAndServe
。 - 你使用了 Cloudflare 代理。這種情況下,用戶到 Cloudflare 的請求可能用 HTTP/2,但從 Cloudflare 到你服務器(源站)的連接通常還是用 HTTP/1.1。
- 你在啓用了 HTTP/2 的 Nginx 後面。Nginx 作爲 TLS 終結點,負責解密請求和加密響應,而與你的服務之間仍用 HTTP/1.1 通信。
混合協議:HTTP/2 與 HTTP/1.1
如果想要服務直接使用 HTTP/2,需要配置 SSL/TLS。
從技術角度來說,HTTP/2 可以不使用 TLS 運行,但這並非外部流量的標準做法。不過,在微服務或私有網絡等內部環境中倒是可行。如果你好奇的話,不妨一試。
即便不帶 TLS 運行 HTTP/2,客戶端可能依然默認使用 HTTP/1.1。以下方案並不能保證客戶端(外部服務)一定會用 HTTP/2 與你的 HTTP 服務器通信。
來看個簡單示例。首先,我們在 8080 端口搭建一個基礎的 HTTP 服務器:
再寫個基礎 HTTP 客戶端來測試:
爲了突出核心概念,這裏省略了錯誤處理。
從輸出可以看到,請求和響應都使用的是 HTTP/1.1,這完全符合預期。在沒有 HTTPS 或特定配置的情況下,HTTP/2 確實不會被啓用。
默認情況下,Go HTTP 客戶端使用的是 DefaultTransport
,它本身就支持 HTTP/1.1 和 HTTP/2 兩種協議。其中還有一個很實用的字段 ForceAttemptHTTP2
,這個字段默認是開啓的:
"既然我們的客戶端和服務器都支持 HTTP/2,爲什麼它們不用 HTTP/2 呢?"
沒錯,雙方確實都具備 HTTP/2 能力——但僅限於 HTTPS 場景。在普通 HTTP 下,還缺少一個關鍵環節:對非加密 HTTP/2 的支持。不過只需簡單調整就能啓用非加密的 HTTP/2:
通過設置 protocols.SetUnencryptedHTTP2(true)
來啓用非加密 HTTP/2 後,客戶端和服務器就能直接通過 HTTP/2 通信了,無需 HTTPS。這個小改動就能讓一切都水到渠成。 有趣的是,Go 還通過 golang.org/x/net/http2
包支持 HTTP/2,這讓你能獲得更多控制權。以下是一個配置示例:
這說明 HTTP/2 實際上並不一定要依賴 TLS,它只是一個基於 HTTP/1.1 基礎上運行的協議。不過在大多數情況下,如果你的服務器已經啓用了 TLS,Go 的默認 HTTP 客戶端會自動使用 HTTP/2,並在需要時回退到 HTTP/1.1。這一切都不需要額外的配置步驟。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/-s98QGSUtSl6rkxTh4FPsg?poc_token=HOzatmej6MIov-3ykS2d27R8y5ojXWxbvhGeAUvM