深入剖析 HTTP3 協議

自 2017 年起 HTTP3 協議已發佈了 34 個 Draft,推出在即,Chrome、Nginx 等軟件都在跟進實現最新的草案。本文將介紹 HTTP3 協議規範、應用場景及實現原理。

2015 年 HTTP2 協議正式推出後,已經有接近一半的互聯網站點在使用它:

(圖片來自 https://w3techs.com/technologies/details/ce-http2)
HTTP2 協議雖然大幅提升了 HTTP/1.1 的性能,然而,基於 TCP 實現的 HTTP2 遺留下 3 個問題:

HTTP3 協議解決了這些問題:

本文將會從 HTTP3 協議的概念講起,從連接遷移的實現上學習 HTTP3 的報文格式,再圍繞着隊頭阻塞問題來分析多路複用與 QPACK 動態表的實現。雖然正式的 RFC 規範還未推出,但最近的草案 Change 只有微小的變化,所以現在學習 HTTP3 正當其時,這將是下一代互聯網最重要的基礎設施。本文也是我在 2020 年 8 月 3 號 Nginx 中文社區與 QCON 共同組織的 QCON 公開課中部分內容的文字總結。

HTTP3 協議到底是什麼?

就像 HTTP2 協議一樣,HTTP3 並沒有改變 HTTP1 的語義。那什麼是 HTTP 語義呢?在我看來,它包括以下 3 個點:

HTTP3 在保持 HTTP1 語義不變的情況下,更改了編碼格式,這由 2 個原因所致:

首先,是爲了減少編碼長度。下圖中 HTTP1 協議的編碼使用了 ASCII 碼,用空格、冒號以及 \ r\n 作爲分隔符,編碼效率很低:

HTTP2 與 HTTP3 採用二進制、靜態表、動態表與 Huffman 算法對 HTTP Header 編碼,不只提供了高壓縮率,還加快了發送端編碼、接收端解碼的速度。

其次,由於 HTTP1 協議不支持多路複用,這樣高併發只能通過多開一些 TCP 連接實現。然而,通過 TCP 實現高併發有 3 個弊端:

因此,HTTP2 與 HTTP3 都在應用層實現了多路複用功能:

(圖片來自:https://blog.cloudflare.com/http3-the-past-present-and-future/)

HTTP2 協議基於 TCP 有序字節流實現,因此**應用層的多路複用並不能做到無序地併發,在丟包場景下會出現隊頭阻塞問題。**如下面的動態圖片所示,服務器返回的綠色響應由 5 個 TCP 報文組成,而黃色響應由 4 個 TCP 報文組成,當第 2 個黃色報文丟失後,即使客戶端接收到完整的 5 個綠色報文,但 TCP 層不會允許應用進程的 read 函數讀取到最後 5 個報文,併發成了一紙空談:

當網絡繁忙時,丟包概率會很高,多路複用受到了很大限制。因此, HTTP3 採用 UDP 作爲傳輸層協議,重新實現了無序連接,並在此基礎上通過有序的 QUIC Stream 提供了多路複用 ,如下圖所示:

(圖片來自:https://blog.cloudflare.com/http3-the-past-present-and-future/)

最早這一實驗性協議由 Google 推出,並命名爲 gQUIC,因此,IETF 草案中仍然保留了 QUIC 概念,用來描述 HTTP3 協議的傳輸層和表示層。HTTP3 協議規範由以下 5 個部分組成:

  1. QUIC 層由 https://tools.ietf.org/html/draft-ietf-quic-transport-29 描述,它定義了連接、報文的可靠傳輸、有序字節流的實現;
  2. TLS 協議會將 QUIC 層的部分報文頭部暴露在明文中,方便代理服務器進行路由。https://tools.ietf.org/html/draft-ietf-quic-tls-29 規範定義了 QUIC 與 TLS 的結合方式;
  3. 丟包檢測、RTO 重傳定時器預估等功能由 https://tools.ietf.org/html/draft-ietf-quic-recovery-29 定義,目前擁塞控制使用了類似 TCP New RENO 的算法,未來有可能更換爲基於帶寬檢測的算法(例如 BBR);
  4. 基於以上 3 個規範,https://tools.ietf.org/html/draft-ietf-quic-http-29 定義了 HTTP 語義的實現,包括服務器推送、請求響應的傳輸等;
  5. 在 HTTP2 中,由 HPACK 規範定義 HTTP 頭部的壓縮算法。由於 HPACK 動態表的更新具有時序性,無法滿足 HTTP3 的要求。在 HTTP3 中,QPACK 定義 HTTP 頭部的編碼:https://tools.ietf.org/html/draft-ietf-quic-qpack-16。注意,以上規範的最新草案都到了 29,而 QPACK 相對簡單,它目前更新到 16。

自 1991 年誕生的 HTTP/0.9 協議已不再使用, 但 1996 推出的 HTTP/1.0、1999 年推出的 HTTP/1.1、2015 年推出的 HTTP2 協議仍然共存於互聯網中(HTTP/1.0 在企業內網中還在廣爲使用,例如 Nginx 與上游的默認協議還是 1.0 版本),即將面世的 HTTP3 協議的加入,將會進一步增加協議適配的複雜度 。接下來,我們將深入 HTTP3 協議的細節。

連接遷移功能是怎樣實現的?

對於當下的 HTTP1 和 HTTP2 協議,傳輸請求前需要先完成耗時 1 個 RTT 的 TCP 三次握手、耗時 1 個 RTT 的 TLS 握手(TLS1.3),由於它們分屬內核實現的傳輸層、openssl 庫實現的表示層,所以難以合併在一起,如下圖所示:

(圖片來自:https://blog.cloudflare.com/http3-the-past-present-and-future/)

在 IoT 時代,移動設備接入的網絡會頻繁變動,從而導致設備 IP 地址改變。**對於通過四元組(源 IP、源端口、目的 IP、目的端口)定位連接的 TCP 協議來說,這意味着連接需要斷開重連,所以上述 2 個 RTT 的建鏈時延、TCP 慢啓動都需要重新來過。**而 HTTP3 的 QUIC 層實現了連接遷移功能,允許移動設備更換 IP 地址後,只要仍保有上下文信息(比如連接 ID、TLS 密鑰等),就可以複用原連接。

在 UDP 報文頭部與 HTTP 消息之間,共有 3 層頭部,定義連接且實現了 Connection Migration 主要是在 Packet Header 中完成的,如下圖所示:

這 3 層 Header 實現的功能各不相同:

爲了進一步提升網絡傳輸效率,Packet Header 又可以細分爲兩種:

其中,Long Packet Header 的格式如下圖所示:

建立連接時,連接是由服務器通過 Source Connection ID 字段分配的,這樣,後續傳輸時,雙方只需要固定住 Destination Connection ID,就可以在客戶端 IP 地址、端口變化後,繞過 UDP 四元組(與 TCP 四元組相同),實現連接遷移功能。下圖是 Short Packet Header 頭部的格式,這裏就不再需要傳輸 Source Connection ID 字段了:

上圖中的 Packet Number 是每個報文獨一無二的序號,基於它可以實現丟失報文的精準重發。如果你通過抓包觀察 Packet Header,會發現 Packet Number 被 TLS 層加密保護了,這是爲了防範各類網絡攻擊的一種設計。下圖給出了 Packet Header 中被加密保護的字段:

其中,顯示爲 E(Encrypt)的字段表示被 TLS 加密過。當然,Packet Header 只是描述了最基本的連接信息,其上的 Stream 層、HTTP 消息也是被加密保護的:

現在我們已經對 HTTP3 協議的格式有了基本的瞭解,接下來我們通過隊頭阻塞問題,看看 Packet 之上的 QUIC Frame、HTTP3 Frame 幀格式。

Stream 多路複用時的隊頭阻塞是怎樣解決的?

其實,解決隊頭阻塞的方案,就是允許微觀上有序發出的 Packet 報文,在接收端無序到達後也可以應用於併發請求中。比如上文的動態圖中,如果丟失的黃色報文對其後發出的綠色報文不造成影響,隊頭阻塞問題自然就得到了解決:

在 Packet Header 之上的 QUIC Frame Header,定義了有序字節流 Stream,而且 Stream 之間可以實現真正的併發。HTTP3 的 Stream,借鑑了 HTTP2 中的部分概念,所以在討論 QUIC Frame Header 格式之前,我們先來看看 HTTP2 中的 Stream 長成什麼樣子:

(圖片參見:https://developers.google.com/web/fundamentals/performance/http2)

每個 Stream 就像 HTTP1 中的 TCP 連接,它保證了承載的 HEADERS frame(存放 HTTP Header)、DATA frame(存放 HTTP Body)是有序到達的,多個 Stream 之間可以並行傳輸。在 HTTP3 中,上圖中的 HTTP2 frame 會被拆解爲兩層,我們先來看底層的 QUIC Frame。

一個 Packet 報文中可以存放多個 QUIC Frame,當然所有 Frame 的長度之和不能大於 PMTUD(Path Maximum Transmission Unit Discovery,這是大於 1200 字節的值),你可以把它與 IP 路由中的 MTU 概念對照理解:

每一個 Frame 都有明確的類型:

前 4 個字節的 Frame Type 字段描述的類型不同,接下來的編碼也不相同,下表是各類 Frame 的 16 進制 Type 值:

n8uOZE

在上表中,我們只要分析 0x08-0x0f 這 8 種 STREAM 類型的 Frame,就能弄明白 Stream 流的實現原理,自然也就清楚隊頭阻塞是怎樣解決的了。Stream Frame 用於傳遞 HTTP 消息,它的格式如下所示:

可見,Stream Frame 頭部的 3 個字段,完成了多路複用、有序字節流以及報文段層面的二進制分隔功能,包括:

你可能會奇怪,爲什麼會有 8 種 Stream Frame 呢?這是因爲 0x08-0x0f 這 8 種類型其實是由 3 個二進制位組成,它們實現了以下 3 標誌位的組合:

Stream 數據中並不會直接存放 HTTP 消息,因爲 HTTP3 還需要實現服務器推送、權重優先級設定、流量控制等功能,所以 Stream Data 中首先存放了 HTTP3 Frame:

其中,Length 指明瞭 HTTP 消息的長度,而 Type 字段(請注意,低 2 位有特殊用途,在 QPACK 章節中會詳細介紹)包含了以下類型:

總結一下,QUIC Stream Frame 定義了有序字節流,且多個 Stream 間的傳輸沒有時序性要求,這樣,HTTP 消息基於 QUIC Stream 就實現了真正的多路複用,隊頭阻塞問題自然就被解決掉了。

QPACK 編碼是如何解決隊頭阻塞問題的?

最後,我們再看下 HTTP Header 頭部的編碼方式,它需要面對另一種隊頭阻塞問題。

與 HTTP2 中的 HPACK 編碼方式相似,HTTP3 中的 QPACK 也採用了靜態表、動態表及 Huffman 編碼:

(圖片參見:https://www.oreilly.com/content/http2-a-new-excerpt/)

先來看靜態表的變化。在上圖中,GET 方法映射爲數字 2,這是通過客戶端、服務器協議實現層的硬編碼完成的。在 HTTP2 中,共有 61 個靜態表項:

而在 QPACK 中,則上升爲 98 個靜態表項,比如 Nginx 上的 ngx_htt_v3_static_table 數組所示:

你也可以從這裏找到完整的 HTTP3 靜態表。對於 Huffman 以及整數的編碼,QPACK 與 HPACK 並無多大不同,但動態表編解碼方式差距很大。

所謂動態表,就是將未包含在靜態表中的 Header 項,在其首次出現時加入動態表,這樣後續傳輸時僅用 1 個數字表示,大大提升了編碼效率。因此,動態表是天然具備時序性的,如果首次出現的請求出現了丟包,後續請求解碼 HPACK 頭部時,一定會被阻塞!

QPACK 是如何解決隊頭阻塞問題的呢?事實上,QPACK 將動態表的編碼、解碼獨立在單向 Stream 中傳輸,僅當單向 Stream 中的動態表編碼成功後,接收端才能解碼雙向 Stream 上 HTTP 消息裏的動態表索引。

我們又引入了單向 Stream 和雙向 Stream 概念,不要頭疼,它其實很簡單。單向指只有一端可以發送消息,雙向則指兩端都可以發送消息。還記得上一小節的 QUIC Stream Frame 頭部嗎?其中的 Stream ID 別有玄機,除了標識 Stream 外,它的低 2 位還可以表達以下組合:

因此,當 Stream ID 是 0、4、8、12 時,這就是客戶端發起的雙向 Stream(HTTP3 不支持服務器發起雙向 Stream),它用於傳輸 HTTP 請求與響應。單向 Stream 有很多用途,所以它在數據前又多出一個 Stream Type 字段:

Stream Type 有以下取值:

由於 HTTP3 的 STREAM 之間是亂序傳輸的,因此,若先發送的編碼 Stream 後到達,雙向 Stream 中的 QPACK 頭部就無法解碼,此時傳輸 HTTP 消息的雙向 Stream 就會進入 Block 阻塞狀態(兩端可以通過控制幀定義阻塞 Stream 的處理方式)。

小結

最後對本文內容做個小結。

基於四元組定義連接並不適用於下一代 IoT 網絡,HTTP3 創造出 Connection ID 概念實現了連接遷移,通過融合傳輸層、表示層,既縮短了握手時長,也加密了傳輸層中的絕大部分字段,提升了網絡安全性。

HTTP3 在 Packet 層保障了連接的可靠性,在 QUIC Frame 層實現了有序字節流,在 HTTP3 Frame 層實現了 HTTP 語義,這徹底解開了隊頭阻塞問題,真正實現了應用層的多路複用。

QPACK 使用獨立的單向 Stream 分別傳輸動態表編碼、解碼信息,這樣亂序、併發傳輸 HTTP 消息的 Stream 既不會出現隊頭阻塞,也能基於時序性大幅壓縮 HTTP Header 的體積。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://www.taohui.tech/2021/02/04/%E7%BD%91%E7%BB%9C%E5%8D%8F%E8%AE%AE/%E6%B7%B1%E5%85%A5%E5%89%96%E6%9E%90HTTP3%E5%8D%8F%E8%AE%AE/