HTTP/2 頭部壓縮技術介紹

JerryQu發表於2016-04-13

我們知道,HTTP/2 協議由兩個 RFC 組成:一個是 RFC 7540,描述了 HTTP/2 協議本身;一個是 RFC 7541,描述了 HTTP/2 協議中使用的頭部壓縮技術。本文將通過實際案例帶領大家詳細地認識 HTTP/2 頭部壓縮這門技術。

為什麼要壓縮

在 HTTP/1 中,HTTP 請求和響應都是由「狀態行、請求 / 響應頭部、訊息主體」三部分組成。一般而言,訊息主體都會經過 gzip 壓縮,或者本身傳輸的就是壓縮過後的二進位制檔案(例如圖片、音訊),但狀態行和頭部卻沒有經過任何壓縮,直接以純文字傳輸。

隨著 Web 功能越來越複雜,每個頁面產生的請求數也越來越多,根據 HTTP Archive 的統計,當前平均每個頁面都會產生上百個請求。越來越多的請求導致消耗在頭部的流量越來越多,尤其是每次都要傳輸 UserAgent、Cookie 這類不會頻繁變動的內容,完全是一種浪費。

以下是我隨手開啟的一個頁面的抓包結果。可以看到,傳輸頭部的網路開銷超過 100kb,比 HTML 還多:

http-header-overhead-overview

下面是其中一個請求的明細。可以看到,為了獲得 58 位元組的資料,在頭部傳輸上花費了好幾倍的流量:

http-header-overhead-detail

HTTP/1 時代,為了減少頭部消耗的流量,有很多優化方案可以嘗試,例如合併請求、啟用 Cookie-Free 域名等等,但是這些方案或多或少會引入一些新的問題,這裡不展開討論。

壓縮後的效果

接下來我將使用訪問本部落格的抓包記錄來說明 HTTP/2 頭部壓縮帶來的變化。如何使用 Wireshark 對 HTTPS 網站進行抓包並解密,請看我的這篇文章

首先直接上圖。下圖選中的 Stream 是首次訪問本站,瀏覽器發出的請求頭:

http2-header-first

從圖片中可以看到這個 HEADERS 流的長度是 206 個位元組,而解碼後的頭部長度有 451 個位元組。由此可見,壓縮後的頭部大小減少了一半多。

然而這就是全部嗎?再上一張圖。下圖選中的 Stream 是點選本站連結後,瀏覽器發出的請求頭:

http2-header-second

可以看到這一次,HEADERS 流的長度只有 49 個位元組,但是解碼後的頭部長度卻有 470 個位元組。這一次,壓縮後的頭部大小几乎只有原始大小的 1/10。

為什麼前後兩次差距這麼大呢?我們把兩次的頭部資訊展開,檢視同一個欄位兩次傳輸所佔用的位元組數:

http2-header-first-cookie

http2-header-second-cookie

對比後可以發現,第二次的請求頭部之所以非常小,是因為大部分鍵值對只佔用了一個位元組。尤其是 UserAgent、Cookie 這樣的頭部,首次請求中需要佔用很多位元組,後續請求中都只需要一個位元組。

技術原理

下面這張截圖,取自 Google 的效能專家 Ilya Grigorik 在 Velocity 2015 • SC 會議中分享的「HTTP/2 is here, let’s optimize!」,非常直觀地描述了 HTTP/2 中頭部壓縮的原理:

hpack-header-compression

我再用通俗的語言解釋下,頭部壓縮需要在支援 HTTP/2 的瀏覽器和服務端之間:

  • 維護一份相同的靜態字典(Static Table),包含常見的頭部名稱,以及特別常見的頭部名稱與值的組合;
  • 維護一份相同的動態字典(Dynamic Table),可以動態地新增內容;
  • 支援基於靜態哈夫曼碼錶的哈夫曼編碼(Huffman Coding);

靜態字典的作用有兩個:1)對於完全匹配的頭部鍵值對,例如 :method: GET,可以直接使用一個字元表示;2)對於頭部名稱可以匹配的鍵值對,例如 cookie: xxxxxxx,可以將名稱使用一個字元表示。HTTP/2 中的靜態字典如下(以下只擷取了部分,完整表格在這裡):

Index Header Name Header Value
1 :authority
2 :method GET
3 :method POST
4 :path /
5 :path /index.html
6 :scheme http
7 :scheme https
8 :status 200
32 cookie
60 via
61 www-authenticate

同時,瀏覽器可以告知服務端,將 cookie: xxxxxxx 新增到動態字典中,這樣後續整個鍵值對就可以使用一個字元表示了。類似的,服務端也可以更新對方的動態字典。需要注意的是,動態字典上下文有關,需要為每個 HTTP/2 連線維護不同的字典。

使用字典可以極大地提升壓縮效果,其中靜態字典在首次請求中就可以使用。對於靜態、動態字典中不存在的內容,還可以使用哈夫曼編碼來減小體積。HTTP/2 使用了一份靜態哈夫曼碼錶(詳見),也需要內建在客戶端和服務端之中。

這裡順便說一下,HTTP/1 的狀態行資訊(Method、Path、Status 等),在 HTTP/2 中被拆成鍵值對放入頭部(冒號開頭的那些),同樣可以享受到字典和哈夫曼壓縮。另外,HTTP/2 中所有頭部名稱必須小寫。

實現細節

瞭解了 HTTP/2 頭部壓縮的基本原理,最後我們來看一下具體的實現細節。HTTP/2 的頭部鍵值對有以下這些情況:

1)整個頭部鍵值對都在字典中

這是最簡單的情況,使用一個位元組就可以表示這個頭部了,最左一位固定為 1,之後七位存放鍵值對在靜態或動態字典中的索引。例如下圖中,頭部索引值為 2(0000010),在靜態字典中查詢可得 :method: GET

index-header-field

2)頭部名稱在字典中,更新動態字典

對於這種情況,首先需要使用一個位元組表示頭部名稱:左兩位固定為 01,之後六位存放頭部名稱在靜態或動態字典中的索引。接下來的一個位元組第一位 H 表示頭部值是否使用了哈夫曼編碼,剩餘七位表示頭部值的長度 L,後續 L 個位元組就是頭部值的具體內容了。例如下圖中索引值為 32(100000),在靜態字典中查詢可得 cookie;頭部值使用了哈夫曼編碼(1),長度是 28(0011100);接下來的 28 個位元組是 cookie 的值,將其進行哈夫曼解碼就能得到具體內容。

incremental-index-header

客戶端或服務端看到這種格式的頭部鍵值對,會將其新增到自己的動態字典中。後續傳輸這樣的內容,就符合第 1 種情況了。

3)頭部名稱不在字典中,更新動態字典

這種情況與第 2 種情況類似,只是由於頭部名稱不在字典中,所以第一個位元組固定為 01000000;接著申明名稱是否使用哈夫曼編碼及長度,並放上名稱的具體內容;再申明值是否使用哈夫曼編碼及長度,最後放上值的具體內容。例如下圖中名稱的長度是 5(0000101),值的長度是 6(0000110)。對其具體內容進行哈夫曼解碼後,可得 pragma: no-cache

incremental-index-header-newname

客戶端或服務端看到這種格式的頭部鍵值對,會將其新增到自己的動態字典中。後續傳輸這樣的內容,就符合第 1 種情況了。

4)頭部名稱在字典中,不允許更新動態字典

這種情況與第 2 種情況非常類似,唯一不同之處是:第一個位元組左四位固定為 0001,只剩下四位來存放索引了,如下圖:

never-index-header-field

這裡需要介紹另外一個知識點:對整數的解碼。上圖中第一個位元組為 00011111,並不代表頭部名稱的索引為 15(1111)。第一個位元組去掉固定的 0001,只剩四位可用,將位數用 N 表示,它只能用來表示小於「2 ^ N – 1 = 15」的整數 I。對於 I,需要按照以下規則求值(RFC 7541 中的虛擬碼,via):

對於上圖中的資料,按照這個規則算出索引值為 32(00011111 00010001,15 + 17),代表 cookie。需要注意的是,協議中所有寫成(N+)的數字,例如 Index (4+)、Name Length (7+),都需要按照這個規則來編碼和解碼。

這種格式的頭部鍵值對,不允許被新增到動態字典中(但可以使用哈夫曼編碼)。對於一些非常敏感的頭部,比如用來認證的 Cookie,這麼做可以提高安全性。

5)頭部名稱不在字典中,不允許更新動態字典

這種情況與第 3 種情況非常類似,唯一不同之處是:第一個位元組固定為 00010000。這種情況比較少見,沒有截圖,各位可以腦補。同樣,這種格式的頭部鍵值對,也不允許被新增到動態字典中,只能使用哈夫曼編碼來減少體積。

實際上,協議中還規定了與 4、5 非常類似的另外兩種格式:將 4、5 格式中的第一個位元組第四位由 1 改為 0 即可。它表示「本次不更新動態詞典」,而 4、5 表示「絕對不允許更新動態詞典」。區別不是很大,這裡略過。

明白了頭部壓縮的技術細節,理論上可以很輕鬆寫出 HTTP/2 頭部解碼工具了。我比較懶,直接找來 node-http2 中的 compressor.js 驗證一下:

頭部原始資料來自於本文第三張截圖,執行結果如下(靜態字典只擷取了一部分):

可以看到,這段從 Wireshark 拷出來的頭部資料可以正常解碼,動態字典也得到了更新(62 – 67)。

總結

在進行 HTTP/2 網站效能優化時很重要一點是「使用盡可能少的連線數」,本文提到的頭部壓縮是其中一個很重要的原因:同一個連線上產生的請求和響應越多,動態字典積累得越全,頭部壓縮效果也就越好。所以,針對 HTTP/2 網站,最佳實踐是不要合併資源,不要雜湊域名。

預設情況下,瀏覽器會針對這些情況使用同一個連線:

  • 同一域名下的資源;
  • 不同域名下的資源,但是滿足兩個條件:1)解析到同一個 IP;2)使用同一個證照;

上面第一點容易理解,第二點則很容易被忽略。實際上 Google 已經這麼做了,Google 一系列網站都共用了同一個證照,可以這樣驗證:

使用多域名加上相同的 IP 和證照部署 Web 服務有特殊的意義:讓支援 HTTP/2 的終端只建立一個連線,用上 HTTP/2 協議帶來的各種好處;而只支援 HTTP/1.1 的終端則會建立多個連線,達到同時更多併發請求的目的。這在 HTTP/2 完全普及前也是一個不錯的選擇。

本文就寫到這裡,希望能給對 HTTP/2 感興趣的同學帶來幫助,也歡迎大家繼續關注本部落格的「HTTP/2 專題」。

打賞支援我寫出更多好文章,謝謝!

打賞作者

打賞支援我寫出更多好文章,謝謝!

任選一種支付方式

HTTP/2 頭部壓縮技術介紹 HTTP/2 頭部壓縮技術介紹

相關文章