HTTP2 詳解

魚_樂發表於2018-08-31

原文地址: blog.wangriyu.wang/2018/05-HTT…

維基百科關於 HTTP/2 的介紹,可以看下定義和發展歷史:

Wiki

RFC 7540 定義了 HTTP/2 的協議規範和細節,本文的細節主要來自此文件,建議先看一遍本文,再回過頭來照著協議大致過一遍 RFC,如果想深入某些細節再仔細翻看 RFC

RFC7540

Why use it ?

HTTP/1.1 存在的問題:

1、TCP 連線數限制

對於同一個域名,瀏覽器最多隻能同時建立 6~8 個 TCP 連線 (不同瀏覽器不一樣)。為了解決數量限制,出現了 域名分片 技術,其實就是資源分域,將資源放在不同域名下 (比如二級子域名下),這樣就可以針對不同域名建立連線並請求,以一種討巧的方式突破限制,但是濫用此技術也會造成很多問題,比如每個 TCP 連線本身需要經過 DNS 查詢、三步握手、慢啟動等,還佔用額外的 CPU 和記憶體,對於伺服器來說過多連線也容易造成網路擁擠、交通阻塞等,對於移動端來說問題更明顯,可以參考這篇文章: Why Domain Sharding is Bad News for Mobile Performance and Users

image

image

在圖中可以看到新建了六個 TCP 連線,每次新建連線 DNS 解析需要時間(幾 ms 到幾百 ms 不等)、TCP 慢啟動也需要時間、TLS 握手又要時間,而且後續請求都要等待佇列排程

2、線頭阻塞 (Head Of Line Blocking) 問題

每個 TCP 連線同時只能處理一個請求 - 響應,瀏覽器按 FIFO 原則處理請求,如果上一個響應沒返回,後續請求 - 響應都會受阻。為了解決此問題,出現了 管線化 - pipelining 技術,但是管線化存在諸多問題,比如第一個響應慢還是會阻塞後續響應、伺服器為了按序返回相應需要快取多個響應占用更多資源、瀏覽器中途斷連重試伺服器可能得重新處理多個請求、還有必須客戶端 - 代理 - 伺服器都支援管線化

3、Header 內容多,而且每次請求 Header 不會變化太多,沒有相應的壓縮傳輸優化方案

4、為了儘可能減少請求數,需要做合併檔案、雪碧圖、資源內聯等優化工作,但是這無疑造成了單個請求內容變大延遲變高的問題,且內嵌的資源不能有效地使用快取機制

5、明文傳輸不安全

HTTP2 的優勢:

1、二進位制分幀層 (Binary Framing Layer)

幀是資料傳輸的最小單位,以二進位制傳輸代替原本的明文傳輸,原本的報文訊息被劃分為更小的資料幀:

image

h1 和 h2 的報文對比:

image
image

圖中 h2 的報文是重組解析過後的,可以發現一些頭欄位發生了變化,而且所有頭欄位均小寫

strict-transport-security: max-age=63072000; includeSubdomains 欄位是伺服器開啟 HSTS 策略,讓瀏覽器強制使用 HTTPS 進行通訊,可以減少重定向造成的額外請求和會話劫持的風險

伺服器開啟 HSTS 的方法是: 以 nginx 為例,在相應站點的 server 模組中新增 add_header Strict-Transport-Security "max-age=63072000; includeSubdomains" always; 即可

在 Chrome 中可以開啟 chrome://net-internals/#hsts 進入瀏覽器的 HSTS 管理介面,可以增加 / 刪除 / 查詢 HSTS 記錄,比如下圖:

image

在 HSTS 有效期內且 TLS 證照仍有效,瀏覽器訪問 blog.wangriyu.wang 會自動加上 https:// ,而不需要做一次查詢重定向到 https

關於幀詳見: How does it work ?- 幀

2、多路複用 (MultiPlexing)

在一個 TCP 連線上,我們可以向對方不斷髮送幀,每幀的 stream identifier 的標明這一幀屬於哪個流,然後在對方接收時,根據 stream identifier 拼接每個流的所有幀組成一整塊資料。 把 HTTP/1.1 每個請求都當作一個流,那麼多個請求變成多個流,請求響應資料分成多個幀,不同流中的幀交錯地傳送給對方,這就是 HTTP/2 中的多路複用。

流的概念實現了單連線上多請求 - 響應並行,解決了線頭阻塞的問題,減少了 TCP 連線數量和 TCP 連線慢啟動造成的問題

所以 http2 對於同一域名只需要建立一個連線,而不是像 http/1.1 那樣建立 6~8 個連線:

image
image

關於流詳見: How does it work ?- 流

3、服務端推送 (Server Push)

瀏覽器傳送一個請求,伺服器主動向瀏覽器推送與這個請求相關的資源,這樣瀏覽器就不用發起後續請求。

Server-Push 主要是針對資源內聯做出的優化,相較於 http/1.1 資源內聯的優勢:

  • 客戶端可以快取推送的資源
  • 客戶端可以拒收推送過來的資源
  • 推送資源可以由不同頁面共享
  • 伺服器可以按照優先順序推送資源

關於服務端推送詳見: How does it work ?- Server-Push

4、Header 壓縮 (HPACK)

使用 HPACK 演算法來壓縮首部內容

關於 HPACK 詳見: How does it work ?- HPACK

5、應用層的重置連線

對於 HTTP/1 來說,是通過設定 tcp segment 裡的 reset flag 來通知對端關閉連線的。這種方式會直接斷開連線,下次再發請求就必須重新建立連線。HTTP/2 引入 RST_STREAM 型別的 frame,可以在不斷開連線的前提下取消某個 request 的 stream,表現更好。

6、請求優先順序設定

HTTP/2 裡的每個 stream 都可以設定依賴 (Dependency) 和權重,可以按依賴樹分配優先順序,解決了關鍵請求被阻塞的問題

7、流量控制

每個 http2 流都擁有自己的公示的流量視窗,它可以限制另一端傳送資料。對於每個流來說,兩端都必須告訴對方自己還有足夠的空間來處理新的資料,而在該視窗被擴大前,另一端只被允許傳送這麼多資料。

關於流量控制詳見: How does it work ?- 流量控制

8、HTTP/1 的幾種優化可以棄用

合併檔案、內聯資源、雪碧圖、域名分片對於 HTTP/2 來說是不必要的,使用 h2 儘可能將資源細粒化,檔案分解地儘可能散,不用擔心請求數多

How does it work ?

幀 - Frame

幀的結構

所有幀都是一個固定的 9 位元組頭部 (payload 之前) 跟一個指定長度的負載 (payload):

+-----------------------------------------------+
|                 Length (24)                   |
+---------------+---------------+---------------+
|   Type (8)    |   Flags (8)   |
+-+-------------+---------------+-------------------------------+
|R|                 Stream Identifier (31)                      |
+=+=============================================================+
|                   Frame Payload (0...)                      ...
+---------------------------------------------------------------+
複製程式碼
  • Length 代表整個 frame 的長度,用一個 24 位無符號整數表示。除非接收者在 SETTINGS_MAX_FRAME_SIZE 設定了更大的值 (大小可以是 2^14(16384) 位元組到 2^24-1(16777215) 位元組之間的任意值),否則資料長度不應超過 2^14(16384) 位元組。頭部的 9 位元組不算在這個長度裡
  • Type 定義 frame 的型別,用 8 bits 表示。幀型別決定了幀主體的格式和語義,如果 type 為 unknown 應該忽略或拋棄。
  • Flags 是為幀型別相關而預留的布林標識。標識對於不同的幀型別賦予了不同的語義。如果該標識對於某種幀型別沒有定義語義,則它必須被忽略且傳送的時候應該賦值為 (0x0)
  • R 是一個保留的位元位。這個位元的語義沒有定義,傳送時它必須被設定為 (0x0), 接收時需要忽略。
  • Stream Identifier 用作流控制,用 31 位無符號整數表示。客戶端建立的 sid 必須為奇數,服務端建立的 sid 必須為偶數,值 (0x0) 保留給與整個連線相關聯的幀 (連線控制訊息),而不是單個流
  • Frame Payload 是主體內容,由幀型別決定

共分為十種型別的幀:

  • HEADERS: 報頭幀 (type=0x1),用來開啟一個流或者攜帶一個首部塊片段
  • DATA: 資料幀 (type=0x0),裝填主體資訊,可以用一個或多個 DATA 幀來返回一個請求的響應主體
  • PRIORITY: 優先順序幀 (type=0x2),指定傳送者建議的流優先順序,可以在任何流狀態下傳送 PRIORITY 幀,包括空閒 (idle) 和關閉 (closed) 的流
  • RST_STREAM: 流終止幀 (type=0x3),用來請求取消一個流,或者表示發生了一個錯誤,payload 帶有一個 32 位無符號整數的錯誤碼 (Error Codes),不能在處於空閒 (idle) 狀態的流上傳送 RST_STREAM 幀
  • SETTINGS: 設定幀 (type=0x4),設定此 連線 的引數,作用於整個連線
  • PUSH_PROMISE: 推送幀 (type=0x5),服務端推送,客戶端可以返回一個 RST_STREAM 幀來選擇拒絕推送的流
  • PING: PING 幀 (type=0x6),判斷一個空閒的連線是否仍然可用,也可以測量最小往返時間 (RTT)
  • GOAWAY: GOWAY 幀 (type=0x7),用於發起關閉連線的請求,或者警示嚴重錯誤。GOAWAY 會停止接收新流,並且關閉連線前會處理完先前建立的流
  • WINDOW_UPDATE: 視窗更新幀 (type=0x8),用於執行流量控制功能,可以作用在單獨某個流上 (指定具體 Stream Identifier) 也可以作用整個連線 (Stream Identifier 為 0x0),只有 DATA 幀受流量控制影響。初始化流量視窗後,傳送多少負載,流量視窗就減少多少,如果流量視窗不足就無法傳送,WINDOW_UPDATE 幀可以增加流量視窗大小
  • CONTINUATION: 延續幀 (type=0x9),用於繼續傳送首部塊片段序列,見 首部的壓縮與解壓縮

DATA 幀格式

 +---------------+
 |Pad Length? (8)|
 +---------------+-----------------------------------------------+
 |                            Data (*)                         ...
 +---------------------------------------------------------------+
 |                           Padding (*)                       ...
 +---------------------------------------------------------------+
複製程式碼
  • Pad Length: ? 表示此欄位的出現時有條件的,需要設定相應標識 (set flag),指定 Padding 長度,存在則代表 PADDING flag 被設定
  • Data: 傳遞的資料,其長度上限等於幀的 payload 長度減去其他出現的欄位長度
  • Padding: 填充位元組,沒有具體語義,傳送時必須設為 0,作用是混淆報文長度,與 TLS 中 CBC 塊加密類似,詳見 httpwg.org/specs/rfc75…

DATA 幀有如下標識 (flags):

  • END_STREAM: bit 0 設為 1 代表當前流的最後一幀
  • PADDED: bit 3 設為 1 代表存在 Padding

例子:

image

image

image

HEADERS 幀格式

 +---------------+
 |Pad Length? (8)|
 +-+-------------+-----------------------------------------------+
 |E|                 Stream Dependency? (31)                     |
 +-+-------------+-----------------------------------------------+
 |  Weight? (8)  |
 +-+-------------+-----------------------------------------------+
 |                   Header Block Fragment (*)                 ...
 +---------------------------------------------------------------+
 |                           Padding (*)                       ...
 +---------------------------------------------------------------+
複製程式碼
  • Pad Length: 指定 Padding 長度,存在則代表 PADDING flag 被設定
  • E: 一個位元位宣告流的依賴性是否是排他的,存在則代表 PRIORITY flag 被設定
  • Stream Dependency: 指定一個 stream identifier,代表當前流所依賴的流的 id,存在則代表 PRIORITY flag 被設定
  • Weight: 一個無符號 8 為整數,代表當前流的優先順序權重值 (1~256),存在則代表 PRIORITY flag 被設定
  • Header Block Fragment: header 塊片段
  • Padding: 填充位元組,沒有具體語義,作用與 DATA 的 Padding 一樣,存在則代表 PADDING flag 被設定

HEADERS 幀有以下標識 (flags):

  • END_STREAM: bit 0 設為 1 代表當前 header 塊是傳送的最後一塊,但是帶有 END_STREAM 標識的 HEADERS 幀後面還可以跟 CONTINUATION 幀 (這裡可以把 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

例子:

image

image

首部的壓縮與解壓縮

HTTP/2 裡的首部欄位也是一個鍵具有一個或多個值。這些首部欄位用於 HTTP 請求和響應訊息,也用於服務端推送操作。

首部列表 (Header List) 是零個或多個首部欄位 (Header Field) 的集合。當通過連線傳送時,首部列表通過壓縮演算法(即下文 HPACK) 序列化成首部塊 (Header Block)。然後,序列化的首部塊又被劃分成一個或多個叫做首部塊片段 (Header Block Fragment) 的位元組序列,並通過 HEADERS、PUSH_PROMISE,或者 CONTINUATION 幀進行有效負載傳送。

Cookie 首部欄位需要 HTTP 對映特殊對待,見 8.1.2.5. Compressing the Cookie Header Field

一個完整的首部塊有兩種可能

  • 一個 HEADERS 幀或 PUSH_PROMISE 幀加上設定 END_HEADERS flag
  • 一個未設定 END_HEADERS flag 的 HEADERS 幀或 PUSH_PROMISE 幀,加上多個 CONTINUATION 幀,其中最後一個 CONTINUATION 幀設定 END_HEADERS flag

必須將首部塊作為連續的幀序列傳送,不能插入任何其他型別或其他流的幀。尾幀設定 END_HEADERS 標識代表首部塊結束,這讓首部塊在邏輯上等價於一個單獨的幀。接收端連線片段重組首部塊,然後解壓首部塊重建首部列表。

image

SETTINGS 幀格式

httpwg.org/specs/rfc75…

一個 SETTINGS 幀的 payload 由零個或多個引數組成,每個引數的形式如下:

 +-------------------------------+
 |       Identifier (16)         |
 +-------------------------------+-------------------------------+
 |                        Value (32)                             |
 +---------------------------------------------------------------+
複製程式碼
  • Identifier: 代表引數型別,比如 SETTINGS_HEADER_TABLE_SIZE 是 0x1
  • Value: 相應引數的值

在建立連線開始時雙方都要傳送 SETTINGS 幀以表明自己期許對方應做的配置,對方接收後同意配置引數便返回帶有 ACK 標識的空 SETTINGS 幀表示確認,而且連線後任意時刻任意一方也都可能再傳送 SETTINGS 幀調整,SETTINGS 幀中的引數會被最新接收到的引數覆蓋

SETTINGS 幀作用於整個連線,而不是某個流,而且 SETTINGS 幀的 stream identifier 必須是 0x0,否則接收方會認為錯誤 (PROTOCOL_ERROR)。

SETTINGS 幀包含以下引數:

  • SETTINGS_HEADER_TABLE_SIZE (0x1): 用於解析 Header block 的 Header 壓縮表的大小,初始值是 4096 位元組
  • SETTINGS_ENABLE_PUSH (0x2): 可以關閉 Server Push,該值初始為 1,表示允許服務端推送功能
  • SETTINGS_MAX_CONCURRENT_STREAMS (0x3): 代表傳送端允許接收端建立的最大流數目
  • SETTINGS_INITIAL_WINDOW_SIZE (0x4): 指明傳送端所有流的流量控制視窗的初始大小,會影響所有流,該初始值是 2^16 - 1(65535) 位元組,最大值是 2^31 - 1,如果超出最大值則會返回 FLOW_CONTROL_ERROR
  • SETTINGS_MAX_FRAME_SIZE (0x5): 指明傳送端允許接收的最大幀負載的位元組數,初始值是 2^14(16384) 位元組,如果該值不在初始值 (2^14) 和最大值 (2^24 - 1) 之間,返回 PROTOCOL_ERROR
  • SETTINGS_MAX_HEADER_LIST_SIZE (0x6): 通知對端,傳送端準備接收的首部列表大小的最大位元組數。該值是基於未壓縮的首部域大小,包括名稱和值的位元組長度,外加每個首部域的 32 位元組的開銷

SETTINGS 幀有以下標識 (flags):

  • ACK: bit 0 設為 1 代表已接收到對方的 SETTINGS 請求並同意設定,設定此標誌的 SETTINGS 幀 payload 必須為空

例子:

image

實際抓包會發現 HTTP2 請求建立連線傳送 SETTINGS 幀初始化前還有一個 Magic 幀 (建立 HTTP/2 請求的前言)。

在 HTTP/2 中,要求兩端都要傳送一個連線前言,作為對所使用協議的最終確認,並確定 HTTP/2 連線的初始設定,客戶端和服務端各自傳送不同的連線前言。

客戶端的前言內容 (對應上圖中編號 23 的幀) 包含一個內容為 PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n 的序列加上一個可以為空的 SETTINGS 幀,在收到 101(Switching Protocols) 響應 (代表 upgrade 成功) 後傳送,或者作為 TLS 連線的第一個傳輸的應用資料。如果在預先知道服務端支援 HTTP/2 的情況下啟用 HTTP/2 連線,客戶端連線前言在連線建立時傳送。

服務端的前言 (對應上圖中編號 26 的幀) 包含一個可以為空的 SETTINGS 幀,在建立 HTTP/2 連線後作為第一幀傳送。詳見 HTTP/2 Connection Preface

傳送完前言後雙方都得向對方傳送帶有 ACK 標識的 SETTINGS 幀表示確認,對應上圖中編號 29 和 31 的幀。

請求站點的全部幀序列,幀後面的數字代表所屬流的 id,最後以 GOAWAY 幀關閉連線:

image

GOAWAY 幀帶有最大的那個流識別符號 (比如圖中第 29 幀是最大流),對於傳送方來說會繼續處理完不大於此數字的流,然後再真正關閉連線

流 - Stream

流只是一個邏輯上的概念,代表 HTTP/2 連線中在客戶端和伺服器之間交換的獨立雙向幀序列,每個幀的 Stream Identifier 欄位指明瞭它屬於哪個流。

流有以下特性:

  • 單個 h2 連線可以包含多個併發的流,兩端之間可以交叉傳送不同流的幀
  • 流可以由客戶端或伺服器來單方面地建立和使用,或者共享
  • 流可以由任一方關閉
  • 幀在流上傳送的順序非常重要,最後接收方會把相同 Stream Identifier (同一個流) 的幀重新組裝成完整訊息報文

流的狀態

image

注意圖中的 send 和 recv 物件是指端點,不是指當前的流

idle

所有流以“空閒”狀態開始。在這種狀態下,沒有任何幀的交換

其狀態轉換:

  • 傳送或者接收一個 HEADERS 幀會使空閒 idle 流變成開啟 open 狀態,其中 HEADERS 幀的 Stream Identifier 欄位指明瞭流 id。同樣的 HEADERS 幀(帶有 END_STREAM )也可以使一個流立即進入 half-closed 狀態。
  • 服務端必須在一個開啟 open 或者半關閉 (遠端) half-closed(remote) 狀態的流 (由客戶端發起的) 上傳送 PUSH_PROMISE 幀,其中 PUSH_PROMISE 幀的 Promised Stream ID 欄位指定了一個預示的新流 (由服務端發起),
    • 在服務端該新流會由空閒 idle 狀態進入被保留的 (本地) reserved(local) 狀態
    • 在客戶端該新流會由空閒 idle 狀態進入被保留的 (遠端) reserved(remote) 狀態

3.2 - Starting HTTP/2 for "http" URIs 中介紹了一種特殊情況:

客戶端發起一個 HTTP/1.1 請求,請求帶有 Upgrade 機制,想建立 h2c 連線,服務端同意升級返回 101 響應。 升級之前傳送的 HTTP/1.1 請求被分配一個流識別符號 0x1,並被賦予預設優先順序值。從客戶端到服務端這個流 1 隱式地轉為 "half-closed" 狀態,因為作為 HTTP/1.1 請求它已經完成了。HTTP/2 連線開始後,流 1 用於響應。詳細過程可以看下文的 HTTP/2 的協議協商機制

此狀態下接收到 HEADERS 和 PRIORITY 以外的幀被視為 PROTOCOL_ERROR

狀態圖中 send PPrecv PP 是指連線的雙方端點傳送或接收了 PUSH_PROMISE,不是指某個空閒流傳送或接收了 PUSH_PROMISE,是 PUSH_PROMISE 的出現促使一個預示的流從 idle 狀態轉為 reserved

在下文 Server-Push 中會詳細介紹服務端推送的內容和 PUSH_PROMISE 的使用情形

reserved (local) / reserved (remote)

PUSH_PROMISE 預示的流由 idle 狀態進入此狀態,代表準備進行 Server push

其狀態轉換:

  • PUSH_PROMISE 幀預示的流的響應以 HEADERS 幀開始,這會立即將該流在服務端置於半關閉 (遠端) half-closed(remote) 狀態,在客戶端置於半關閉 (本地) half-closed(local) 狀態,最後以攜帶 END_STREAM 的幀結束,這會將流置於關閉 closed 狀態
  • 任一端點都可以傳送 RST_STREAM 幀來終止這個流,其狀態由 reserved 轉為 closed

reserved(local) 狀態下的流不能傳送 HEADERS、RST_STREAM、PRIORITY 以外的幀,接收到 RST_STREAM、PRIORITY、WINDOW_UPDATE 以外的幀被視為 PROTOCOL_ERROR

reserved(remote) 狀態下的流不能傳送 RST_STREAM、WINDOW_UPDATE、PRIORITY 以外的幀,接收到 HEADERS、RST_STREAM、PRIORITY 以外的幀被視為 PROTOCOL_ERROR

open

處於 open 狀態的流可以被兩個對端用來傳送任何型別的幀

其狀態轉換:

  • 任一端都可以傳送帶有 END_STREAM 標識的幀,傳送方會轉入 half-closed(local) 狀態;接收方會轉入 half-closed(remote) 狀態
  • 任一端都可以傳送 RST_STREAM 幀,這會使流立即進入 closed 狀態
half-closed (local)

流是雙向的,半關閉表示這個流單向關閉了,local 代表本端到對端的方向關閉了,remote 代表對端到本端的方向關閉了

此狀態下的流不能傳送 WINDOW_UPDATE、PRIORITY、RST_STREAM 以外的幀

當此狀態下的流收到帶有 END_STREAM 標識的幀或者任一方傳送 RST_STREAM 幀,會轉為 closed 狀態

此狀態下的流收到的 PRIORITY 幀用以調整流的依賴關係順序,可以看下文的流優先順序

half-closed (remote)

此狀態下的流不會被對端用於傳送幀,執行流量控制的端點不再有義務維護接收方的流控制視窗。

一個端點在此狀態的流上接收到 WINDOW_UPDATE、PRIORITY、RST_STREAM 以外的幀,應該響應一個 STREAM_CLOSED 流錯誤

此狀態下的流可以被端點用於傳送任意型別的幀,且此狀態下該端點仍會觀察流級別的流控制的限制

當此狀態下的流傳送帶有 END_STREAM 標識的幀或者任一方傳送 RST_STREAM 幀,會轉為 closed 狀態

closed

代表流已關閉

此狀態下的流不能傳送 PRIORITY 以外的幀,傳送 PRIORITY 幀是調整那些依賴這個已關閉的流的流優先順序,端點都應該處理 PRIORITY 幀,儘管如果該流從依賴關係樹中移除了也可以忽略優先順序幀

此狀態下在收到帶有 END_STREAM 標識的 DATA 或 HEADERS 幀後的一小段時間內 (period) 仍可能接收到 WINDOW_UPDATE 或 RST_STREAM 幀,因為在遠端對端接收並處理 RST_STREAM 或帶有 END_STREAM 標誌的幀之前,它可能會傳送這些型別的幀。但是端點必須忽略接收到的 WINDOW_UPDATE 或 RST_STREAM

如果一個流傳送了 RST_STREAM 幀後轉入此狀態,而對端接收到 RST_STREAM 幀時可能已經傳送了或者處在傳送佇列中,這些幀是不可撤銷的,傳送 RST_STREAM 幀的端點必須忽略這些幀。

一個端點可以限制 period 的長短,在 period 內接受的幀會忽略,超出 period 的幀被視為錯誤。

一個端點傳送了 RST_STREAM 幀後接收到流控制幀(比如 DATA),仍會計入流量視窗,即使這些幀會被忽略,因為對端肯定是在接收到 RST_STREAM 幀前傳送的流控制幀,對端會認為流控制已生效

一個端點可能會在傳送了 RST_STREAM 幀後收到 PUSH_PROMISE 幀,即便預示的流已經被重置 (reset),PUSH_PROMISE 幀也能使預示流變成 reserved 狀態。因此,需要 RST_STREAM 來關閉一個不想要的預示流。

PRIORITY 幀可以被任意狀態的流傳送和接收,未知型別的幀會被忽略

流狀態的轉換

下面看兩個例子來理解流狀態:

image

(1)、Server 在 Client 發起的一個流上傳送 PUSH_PROMISE 幀,其 Promised Stream ID 指定一個預示流用於後續推送,send PP 後這個預示流在服務端從 idle 狀態轉為 reserve(local) 狀態,客戶端 recv PP 後這個流從 idle 狀態轉為 reserve(remote) 狀態

(2)(3)、此時預示流處於保留狀態,客戶端如果選擇拒絕接受推送,可以傳送 RST 幀關閉這個流;服務端如果此時出問題了也可以傳送 RST 幀取消推送。不管哪一方傳送或接收到 RST,此狀態都轉為 closed

(4)、沒有出現重置說明推送仍有效,則服務端開始推送,首先傳送的肯定是響應的 HEADERS 首部塊,此時流狀態轉為半關閉 half-closed(remote);客戶端接收到 HEADERS 後流狀態轉為半關閉 half-closed(local)

(5)(6)、半關閉狀態下的流應該還會繼續推送諸如 DATA 幀、CONTINUATION 幀這樣的資料幀,如果這個過程碰到任一方發起重置,則流會關閉進入 closed 狀態

(7)、如果一切順利,資源隨著資料幀響應完畢,最後一幀會帶上 END_STREAM 標識代表這個流結束了,此時流轉為 closed 狀態

image

(1)、客戶端發起請求,首先傳送一個 HEADERS 幀,其 Stream Identifier 建立一個新流,此流從 idle 狀態轉為 open 狀態

(2)(3)、如果客戶端取消請求可以傳送 RST 幀,服務端出錯也可以傳送 RST 幀,不管哪一方接收或傳送 RST,流關閉進入 closed 狀態;

(4)、如果請求結束(END_STREAM),流轉為半關閉狀態。假如是 GET 請求,一般 HEADERS 幀就是最後一幀,send H 後流會立即進入半關閉狀態。假如是 POST 請求,待資料傳完,最後一幀帶上 END_STREAM 標識,流轉為半關閉

(5)(6)、客戶端半關閉後服務端開始返回響應,此時任一方接收或傳送 RST,流關閉;

(7)、如果一切順利,等待響應結束(END_STREAM),流關閉

流的識別符號

流 ID 是 31 位無符號整數,客戶端發起的流必須是奇數,服務端發起的流必須是偶數,0x0 保留為連線控制訊息不能用於建立新流。

HTTP/1.1 Upgrade to HTTP/2 時響應的流 ID 是 0x1,在升級完成之後,流 0x1 在客戶端會轉為 half-closed (local) 狀態,因此這種情況下客戶端不能用 0x1 初始化一個流

新建立的流的 ID 必須大於所有已使用過的數字,接收到一個錯誤大小的 ID 應該返回 PROTOCOL_ERROR 響應

使用一個新流時隱式地關閉了對端發起的 ID 小於當前流的且處於 idle 狀態的流,比如一個流傳送一個 HEADERS 幀開啟了 ID 為 7 的流,但還從未向 ID 為 5 的流傳送過幀,則流 0x5 會在 0x7 傳送完或接收完第一幀後轉為 closed 狀態

一個連線內的流 ID 不能重用

流的優先順序

客戶端可以通過 HEADERS 幀的 PRIORITY 資訊指定一個新建立流的優先順序,其他期間也可以傳送 PRIORITY 幀調整流優先順序

設定優先順序的目的是為了讓端點表達它所期望對端在併發的多個流之間如何分配資源的行為。更重要的是,當傳送容量有限時,可以使用優先順序來選擇用於傳送幀的流。

流可以被標記為依賴其他流,所依賴的流完成後再處理當前流。每個依賴 (dependency) 後都跟著一個權重 (weight),這一數字是用來確定依賴於相同的流的可分配可用資源的相對比例

流依賴(Stream Dependencies)

每個流都可以顯示地依賴另一個流,包含依賴關係表示優先將資源分配給指定的流(上層節點)而不是依賴流

一個不依賴於其他流的流會指定 stream dependency 為 0x0 值,因為不存在的 0x0 流代表依賴樹的根

一個依賴於其他流的流叫做依賴流,被依賴的流是當前流的父級。如果被依賴的流不在當前依賴樹中(比如狀態為 idle 的流),被依賴的流會使用一個預設優先順序

當依賴一個流時,該流會新增進父級的依賴關係中,共享相同父級的依賴流不會相對於彼此進行排序,比如 B 和 C 依賴 A,新新增一個依賴流 D,BCD 的順序是不固定的:

    A                 A
   / \      ==>      /|\
  B   C             B D C
複製程式碼

獨佔標識 (exclusive) 允許插入一個新層級(新的依賴關係),獨佔標識導致該流成為父級的唯一依賴流,而其他依賴流變為其子級,比如同樣插入一個新依賴流 E (帶有 exclusive):

                      A
    A                 |
   /|\      ==>       E
  B D C              /|\
                    B D C
複製程式碼

在依賴關係樹中,只有當一個依賴流所依賴的所有流(父級最高為 0x0 的鏈)被關閉或者無法繼續在上面執行,這個依賴流才應該被分配資源

依賴權重

所有依賴流都會分配一個 1~256 權重值

相同父級的依賴流按權重比例分配資源,比如流 B 依賴於 A 且權重值為 4,流 C 依賴於 A 且權重值為 12,當 A 不再執行時,B 理論上能分配的資源只有 C 的三分之一

優先順序調整 (Reprioritization)

使用 PRIORITY 幀可以調整流優先順序

PRIORITY 幀內容與 HEADERS 幀的優先順序模組相同:

 +-+-------------------------------------------------------------+
 |E|                  Stream Dependency (31)                     |
 +-+-------------+-----------------------------------------------+
 |   Weight (8)  |
 +-+-------------+
複製程式碼
  • 如果父級重新設定了優先順序,則依賴流會隨其父級流一起移動。若調整優先順序的流帶有獨佔標識,會導致新的父流的所有子級依賴於這個流

  • 如果一個流調整為依賴自己的一個子級,則這個將被依賴的子級首先移至調整流的父級之下(即同一層),再移動那個調整流的整棵子樹,移動的依賴關係保持其權重

看下面這個例子: 第一個圖是初始關係樹,現在 A 要調整為依賴 D,根據第二點,現將 D 移至 x 之下,再把 A 調整為 D 的子樹(圖 3),如果 A 調整時帶有獨佔標識根據第一點 F 也歸為 A 子級(圖 4)

    x                x                x                 x
    |               / \               |                 |
    A              D   A              D                 D
   / \            /   / \            / \                |
  B   C     ==>  F   B   C   ==>    F   A       OR      A
     / \                 |             / \             /|\
    D   E                E            B   C           B C F
    |                                     |             |
    F                                     E             E
               (intermediate)   (non-exclusive)    (exclusive)
複製程式碼
流優先順序的狀態管理

當一個流從依賴樹中移除,它的子級可以調整為依賴被關閉流的父級(應該就是連線上一層節點),新的依賴權重將根據關閉流的權重以及流自身的權重重新計算。

從依賴樹中移除流會導致某些優先順序資訊丟失。資源在具有相同父級的流之間共享,這意味著如果這個集合中的某個流關閉或者阻塞,任何空閒容量將分配給最近的相鄰流。然而,如果此集合的共有依賴(即父級節點)從樹中移除,這些子流將與更上一層的流共享資源

一個例子: 流 A 和流 B 依賴相同父級節點,而流 C 和流 D 都依賴 A,在移除流 A 之前的一段時間內,A 和 D 都無法執行(可能任務阻塞了),則 C 會分配到 A 的所有資源; 如果 A 被移除出樹了,A 的權重按比重新計算分配給 C 和 D,此時 D 仍舊阻塞,C 分配的資源相較之前變少了。對於同等的初始權重,C 獲取到的可用資源是三分之一而不是二分之一(為什麼是三分之一?文件中沒有說明細節,權重如何重新分配也不太清楚,下面是按我的理解解釋的)

X 的資源為 1,ABCD 初始權重均為 16,*號代表節點當前不可用,圖一中 C 和 B 各佔一半資源,而 A 移除後 CD 的權重重新分配變為 8,所以圖二中 C 和 B 佔比變為 1:2,R(C) 變為 1/3

          X(v:1.0)               X(v:1.0)
         / \                    /|\
        /   \                  / | \
      *A     B       ==>      /  |  \
    (w:16) (w:16)            /   |   \
      / \                   C   *D    B
     /   \                (w:8)(w:8)(w:16)
    C    *D
 (w:16) (w:16)


 R(C)=16/(16+16)=1/2 ==>  R(C)=8/(8+16)=1/3
複製程式碼

可能向一個流建立依賴關係的優先順序資訊還在傳輸中,那個流就已經關閉了。如果一個依賴流的依賴指向沒有相關優先順序資訊(即父節點無效),則這個依賴流會分配預設優先順序,這可能會造成不理想的優先順序,因為給流分配了不在預期的優先順序。

為了避免上述問題,一個端點應該在流關閉後的一段時間內保留流的優先順序調整狀態資訊,此狀態保留時間越長,流被分配錯誤的或者預設的優先順序可能性越低。

類似地,處於“空閒”狀態的流可以被分配優先順序或成為其他流的父節點。這允許在依賴關係樹中建立分組節點,從而實現更靈活的優先順序表示式。空閒流以預設優先順序開始

流優先順序狀態資訊的保留可能增加終端的負擔,因此這種狀態可以被限制。終端可能根據負荷來決定保留的額外的狀態的數目;在高負荷下,可以丟棄額外的優先順序狀態來限制資源的任務。在極端情況下,終端甚至可以丟棄啟用或者保留狀態流的優先順序資訊。如果使用了固定的限制,終端應當至少保留跟 SETTINGS_MAX_CONCURRENT_STREAMS 設定一樣大小的流狀態

預設優先順序

所有流都是初始為非獨佔地依賴於流 0x0。

Pushed 流初始依賴於相關的流(見 Server-Push)。

以上兩種情況,流的權重都指定為 16。

Server-Push

PUSH_PROMISE 幀格式

 +---------------+
 |Pad Length? (8)|
 +-+-------------+-----------------------------------------------+
 |R|                  Promised Stream ID (31)                    |
 +-+-----------------------------+-------------------------------+
 |                   Header Block Fragment (*)                 ...
 +---------------------------------------------------------------+
 |                           Padding (*)                       ...
 +---------------------------------------------------------------+
複製程式碼
  • Pad Length: 指定 Padding 長度,存在則代表 PADDING flag 被設定
  • R: 保留的1bit位
  • Promised Stream ID: 31 位的無符號整數,代表 PUSH_PROMISE 幀保留的流,對於傳送者來說該流識別符號必須是可用於下一個流的有效值
  • Header Block Fragment: 包含請求首部域的首部塊片段
  • Padding: 填充位元組,沒有具體語義,作用與 DATA 的 Padding 一樣,存在則代表 PADDING flag 被設定

PUSH_PROMISE 幀有以下標識 (flags):

  • END_HEADERS: bit 2 置 1 代表 header 塊結束
  • PADDED: bit 3 置 1 代表 Pad 被設定,存在 Pad Length 和 Padding

Push 的過程

結合上文關於 Server-Push 的流狀態轉換

PUSH_PROMISE 幀只能在對端(客戶端)發起的且流狀態為 open 或者 half-closed (remote) 的流上傳送

PUSH_PROMISE 幀準備推送的響應總是和來自於客戶端的請求相關聯。服務端在該請求所在的流上傳送 PUSH_PROMISE 幀。PUSH_PROMISE 幀包含一個 Promised Stream ID,該流識別符號是從服務端可用的流識別符號裡選出來的。

如果服務端收到了一個對文件的請求,該文件包含內嵌的指向多個圖片檔案的連結,且服務端選擇向客戶端推送那些額外的圖片,那麼在傳送包含圖片連結的 DATA 幀之前傳送 PUSH_PROMISE 幀可以確保客戶端在發現內嵌的連結之前,能夠知道有一個資源將要被推送過來。同樣地,如果服務端準備推送被首部塊引用的響應 (比如,在 Link 首部欄位 裡的),在傳送首部塊之前傳送一個 PUSH_PROMISE 幀,可以確保客戶端不再請求那些資源

一旦客戶端收到了 PUSH_PROMISE 幀,並選擇接收被推送的響應,客戶端就不應該為準備推送的響應發起任何請求,直到預示的流被關閉以後。

image

image

注意圖中推送的四個資源各預示了一個流 (Promised Stream ID),而傳送 PUSH_PROMISE 幀的還是在客戶端發起的請求流 (Stream Identifier = 1) 上,客戶端收到 PUSH_PROMISE 幀並選擇接收便不會對這四個資源發起請求,之後服務端會發起預示的流然後推送資源相關的響應

不管出於什麼原因,如果客戶端決定不再從服務端接收準備推送的響應,或者如果服務端花費了太長時間準備傳送被預示的響應,客戶端可以傳送一個 RST_STREAM 幀,該幀可以使用 CANCEL 或者 REFUSED_STEAM 碼,並引用被推送的流識別符號。

nginx 配置 Server-Push

server-push 需要服務端設定,並不是說瀏覽器發起請求,與此請求相關的資源服務端就會自動推送

以 nginx 為例,從版本 1.13.9 開始正式支援 hppt2 serverpush 功能,

在相應 server 或 location 模組中加入 http2_push 欄位加上相對路徑的檔案即可在請求該資源時推送相關資源,比如我的部落格設定如下,訪問首頁時有四個檔案會由伺服器主動推送過去而不需要客戶端請求:

  server_name  blog.wangriyu.wang;
  root /blog;
  index index.html index.htm;

  location = /index.html {
    http2_push /css/style.css;
    http2_push /js/main.js;
    http2_push /img/yule.jpg;
    http2_push /img/avatar.jpg;
  }
複製程式碼

通過瀏覽器控制檯可以檢視 Push 響應:

image

也可以用 nghttp 測試 push 響應 (* 號代表是服務端推送的):

image

上面 http2_push 的設定適合靜態資源,服務端事先知道哪些檔案是客戶端需要的,然後選擇性推送

假如是後臺應用動態生成的檔案(比如 json 檔案),伺服器事先不知道要推送什麼,可以用 Link 響應頭來做自動推送

在 server 模組中新增 http2_push_preload on;

  server_name  blog.wangriyu.wang;
  root /blog;
  index index.html index.htm;

  http2_push_preload on;
複製程式碼

然後設定響應頭 (add_header) 或者後臺程式生成資料檔案返回時帶上響應頭 Link 標籤,比如

Link: </style.css>; as=style; rel=preload, </main.js>; as=script; rel=preload, </image.jpg>; as=image; rel=preload
複製程式碼

nginx 會根據 Link 響應頭主動推送這些資源

更多nginx 官方介紹見 Introducing HTTP/2 Server Push with NGINX 1.13.9

Server-Push 潛在的問題

看了這篇文章 HTTP/2 中的 Server Push 討論,發現 Server-Push 有個潛在的問題

Server-Push 滿足條件時便會發起推送,可是客戶端已經有快取了想傳送 RST 拒收,而伺服器在收到 RST 之前已經推送資源了,雖然這部分推送無效但是肯定會佔用頻寬

比如我上面部落格關於 http2_push 的配置,我每次開啟首頁伺服器都會推送那四個檔案,而實際上瀏覽器知道自己有快取使用的也是本地快取,也就是說本地快取未失效的期間內,伺服器的 Server-Push 只是起到了佔用頻寬的作用

當然實際上對我的小站點來說影響並不大,但是如果網站需要大量推送的話,需要考慮並測試 Server-Push 是否會影響使用者的後續訪問

另外服務端可以設定 Cookie 或者 Session 記錄訪問時間,然後之後的訪問判斷是否需要 Push;還有就是客戶端可以限制 PUSH 流的數目,也可以設定一個很低的流量視窗來限制 PUSH 傳送的資料大小

至於哪些資源需要推送,在《web 效能權威指南》中就提到幾種策略,比如 Apache 的 mod_spdy 能夠識別 X-Associated-Content 首部,當中列出了希望伺服器推送的資源;另外網上有人已經做了基於 Referer 首部的中介軟體來處理 Server-Push;或者服務端能更智慧的識別文件,根據當前流量決定是否推送或者推送那些資源。相信以後會有更多關於 Server-Push 的實現和應用

流量控制

多路複用的流會競爭 TCP 資源,進而導致流被阻塞。流控制機制確保同一連線上的流不會相互干擾。流量控制作用於單個流或整個連線。HTTP/2 通過使用 WINDOW_UPDATE 幀來提供流量控制。

流控制具有以下特徵:

  • 流量控制是特定於連線的。兩種級別的流量控制都位於單跳的端點之間,而不是整個端到端的路徑。比如 server 前面有一個 front-end proxy 如 Nginx,這時就會有兩個 connection,browser-Nginx, Nginx—server,flow control 分別作用於兩個 connection。詳情見: How is HTTP/2 hop-by-hop flow control accomplished? - stackoverflow
  • 流量控制是基於 WINDOW_UPDATE 幀的。接收方公佈自己打算在每個流以及整個連線上分別接收多少位元組。這是一個以信用為基礎的方案。
  • 流量控制是有方向的,由接收者全面控制。接收方可以為每個流和整個連線設定任意的視窗大小。傳送方必須尊重接收方設定的流量控制限制。客戶方、服務端和中間代理作為接收方時都獨立地公佈各自的流量控制視窗,作為傳送方時都遵守對端的流量控制設定。
  • 無論是新流還是整個連線,流量控制視窗的初始值是 65535 位元組。
  • 幀的型別決定了流量控制是否適用於幀。目前,只有 DATA 幀會受流量控制影響,所有其它型別的幀並不消耗流量控制視窗的空間。這保證了重要的控制幀不會被流量控制阻塞。
  • 流量控制不能被禁用。
  • HTTP/2 只定義了 WINDOW_UPDATE 幀的格式和語義,並沒有規定接收方如何決定何時傳送幀、傳送什麼樣的值,也沒有規定傳送方如何選擇傳送包。具體實現可以選擇任何滿足需求的演算法。

WINDOW_UPDATE 幀格式

+-+-------------------------------------------------------------+
|R|                Window Size Increment (31)                   |
+-+-------------------------------------------------------------+
複製程式碼

Window Size Increment 表示除了現有的流量控制視窗之外,傳送端還可以傳送的位元組數。取值範圍是 1 到 2^31 - 1 位元組。

WINDOW_UPDATE 幀可以是針對一個流或者是針對整個連線的。如果是前者,WINDOW_UPDATE 幀的流識別符號指明瞭受影響的流;如果是後者,流識別符號為 0 表示作用於整個連線。

流量控制功能只適用於被標識的、受流量控制影響的幀。文件定義的幀型別中,只有 DATA 幀受流量控制影響。除非接收端不能再分配資源去處理這些幀,否則不受流量控制影響的幀必須被接收並處理。如果接收端不能再接收幀了,可以響應一個 FLOW_CONTROL_ERROR 型別的流錯誤或者連線錯誤。

WINDOW_UPDATE 可以由傳送過帶有 END_STREAM 標誌的幀的對端傳送。這意味著接收端可能會在 half-closed (remote) 或者 closed 狀態的流上收到 WINDOW_UPDATE 幀,接收端不能將其當做錯誤。

流量控制視窗

流量控制視窗是一個簡單的整數值,指出了准許傳送端傳送的資料的位元組數。視窗值衡量了接收端的快取能力。

除非將其當做連線錯誤,否則當接收端收到 DATA 幀時,必須總是從流量控制視窗中減掉其長度(不包括幀頭的長度,而且兩個級別的控制視窗都要減)。即使幀有錯誤,這也是有必要的,因為傳送端已經將該幀計入流量控制視窗,如果接收端沒有這樣做,傳送端和接收端的流量控制視窗就會不一致。

傳送端不能傳送受流量控制影響的、其長度超出接收端告知的兩種級別的流量控制視窗可用空間的幀。即使這兩種級別的流量控制視窗都沒有可用空間了,也可以傳送長度為 0、設定了 END_STREAM 標誌的幀(即空的 DATA 幀)。

當幀的接收端消耗了資料並釋放了流量控制視窗的空間時,可以傳送一個 WINDOW_UPDATE 幀。對於流級別和連線級別的流量控制視窗,需要分別傳送 WINDOW_UPDATE 幀。

新建連線時,流和連線的初始視窗大小都是 2^16 - 1(65535) 位元組。可以通過設定連線前言中 SETTINGS 幀的 SETTINGS_INITIAL_WINDOW_SIZE 引數改變流的初始視窗大小,這會作用於所有流。而連線的初始視窗大小不能改,但可以用 WINDOW_UPDATE 幀來改變流量控制視窗,這是為什麼連線前言往往帶有一個 WINDOW_UPDATE 幀的原因。

除了改變還未啟用的流的流量控制視窗外,SETTIGNS 幀還可以改變已活躍的流 (處於 open 或 half-closed (remote) 狀態的流)的初始流量控制視窗的大小。也就是說,當 SETTINGS_INITIAL_WINDOW_SIZE 的值變化時,接收端必須調整它所維護的所有流的流量控制視窗的值,不管是之前就開啟的流還是尚未開啟的流。

改變 SETTINGS_INITIAL_WINDOW_SIZE 可能引發流量控制視窗的可用空間變成負值。傳送端必須追蹤負的流量控制視窗,並且直到它收到了使流量控制視窗變成正值的 WINDOW_UPDATE 幀,才能傳送新的 DATA 幀。

例如,如果連線一建立客戶端就立即傳送 60KB 的資料,而服務端卻將初始視窗大小設定為 16KB,那麼客戶端一收到 SETTINGS 幀,就會將可用的流量控制視窗重新計算為 -44KB。客戶端保持負的流量控制視窗,直到 WINDOW_UPDATE 幀將視窗值恢復為正值,客戶端才可以繼續傳送資料。

如果改變 SETTINGS_INITIAL_WINDOW_SIZE 導致流量控制視窗超出了最大值,一端必須 將其當做型別為 FLOW_CONTROL_ERROR 的連線錯誤

如果接收端希望使用比當前值小的流量控制視窗,可以傳送一個新的 SETTINGS 幀。但是,接收端必須準備好接收超出該視窗值的資料,因為可能在收到 SETTIGNS 幀之前,傳送端已經傳送了超出該較小視窗值的資料。

合理使用流控制

流量控制的定義是用來保護端點在資源約束條件下的操作。例如,一個代理需要在很多連線之間共享記憶體,也有可能有緩慢的上游連線和快速的下游連線。流量控制解決了接收方無法在一個流上處理資料,但仍希望繼續處理同一連線中的其他流的情況。

不需要此功能的部署可以通告最大大小 (2^31 - 1) 的流量控制視窗,並且可以通過在收到任何資料時傳送 WINDOW_UPDATE 幀來維護此視窗大小保持不變。這可以有效禁用接受方的流控制。相反地,傳送方總是受控於接收方通告的流控制視窗的限制。

資源約束下(例如記憶體)的排程可以使用流量來限制一個對端可以消耗的記憶體量。需要注意的是如果在不知道頻寬延遲積的時候啟用流量控制可能導致無法最優的利用可用的網路資源 (RFC1323)。

即便是對當前的網路延遲乘積有充分的認識,流量控制的實現也可能很複雜。當使用流量控制時,接收端必須及時地從 TCP 接收緩衝區讀取資料。這樣做可能導致在一些例如 WINDOW_UPDATE 的關鍵幀在 HTTP/2 不可用時導致死鎖。但是流量控制可以保證約束資源能在不需要減少連線利用的情況下得到保護。

HTTP/2 的協議協商機制

非加密下的協商 - h2c

客戶端使用 HTTP Upgrade 機制請求升級,HTTP2-Settings 首部欄位是一個專用於連線的首部欄位,它包含管理 HTTP/2 連線的引數(使用 Base64 編碼),其前提是假設服務端會接受升級請求

 GET / HTTP/1.1
 Host: server.example.com
 Connection: Upgrade, HTTP2-Settings
 Upgrade: h2c
 HTTP2-Settings: <base64url encoding of HTTP/2 SETTINGS payload>
複製程式碼

伺服器如果支援 http/2 並同意升級,則轉換協議,否則忽略

HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: h2c
複製程式碼

此時潛在的存在一個流 0x1,客戶端上這個流在完成 h1 請求後便轉為 half-closed 狀態,服務端會用這個流返回響應

image

image

image

注意圖中第一個響應所在的流是 0x1,與上文所說的一致

目前瀏覽器只支援 TLS 加密下的 HTTP/2 通訊,所以上述情況在瀏覽器中目前是不可能碰到的,圖中顯示的是 nghttp 客戶端發起的請求

加密的協商機制 - h2

TLS 加密中在 Client-Hello 和 Server-Hello 的過程中通過 ALPN 進行協議協商

image

應用層協議協商在 TLS 握手第一步的擴充套件中,Client Hello 中客戶端指定 ALPN Next Protocol 為 h2 或者 http/1.1 說明客戶端支援的協議

image

服務端如果在 Server Hello 中選擇 h2 擴充套件,說明協商協議為 h2,後續請求響應跟著變化;如果服務端未設定 http/2 或者不支援 h2,則繼續用 http/1.1 通訊

分析例項

image

196: TLS 握手第一步 Client Hello,開始協議協商,且此處帶上了 Session Ticket

200: Server Hello 同意使用 h2,而且客戶端的會話票證有效,恢復會話,握手成功

202: 客戶端也恢復會話,開始加密後續訊息

205: 服務端發起一個連線前言 (SETTINGS),SETTINGS 幀中設定了最大並行流數量、初始視窗大小、最大幀長度,然後 (WINDOW_UPDATE) 擴大視窗大小

310: 客戶端也傳送一個連線前言 Magic,並初始化設定 (SETTINGS),SETTINGS 幀中設定了 HEADER TABLE 大小、初始視窗大小、最大並行流數量,然後 (WINDOW_UPDATE) 擴大視窗大小

311: 客戶端傳送完連線前言後可以立即跟上一個請求,GET / (HEADERS[1]),而且這個 HEADERS 幀還帶有 END_STREAM,這會使流 1 從 idle 狀態立即轉為 half-closed(local) 狀態 (open 是中間態)

image

311: 此訊息中還包含一個客戶端傳送給服務端的帶 ACK 的 SETTINGS 幀

312: 服務端也響應帶 ACK 的 SETTINGS 幀

321: 服務端在流 1 (此時狀態為 half-closed(remote)) 上傳送了四個 PUSH_PROMISE 幀,它們分別保留了流 2、4、6、8 用於後續推送,

image

321: 此訊息中還返回了上面請求的響應 (HEADERS - DATA),最後 DATA 帶上 END_STREAM,流 1 從 half-closed 轉為 closed

329: 調整流優先順序,依賴關係: 8 -> 6 -> 4 -> 2 -> 1 (都帶有獨佔標誌,而且權重均為 110)

image

342: 流 1 關閉後,流 2 得到分配資源,伺服器開始推送,資料由兩個 DATA 幀返回

344: 流 2 結束,開始推送流 4

356: 調整依賴關係

image

  1         1         1         1(w: 110)
  |         |         |         |
  2         2         2         2(w: 110)
  |         |         |         |
  4   ==>   4   ==>   6   ==>   6(w: 147)
  |         |         |         |
  6         8         4         8(w: 147)
  |         |         |         |
  8         6         8         4(w: 110)
複製程式碼

367、369、372: 推送 6 和 8 的流資料

377: 發起一個請求,開啟流 3,其中客戶端發起的請求都是依賴流 0x0

之後都是同樣的套路完成請求 - 響應,最後以 GOAWAY 幀關閉連線結束

HPACK 演算法

image

上圖來自 Ilya Grigorik 的 PPT - HTTP/2 is here, let's optimize!

可以清楚地看到 HTTP2 頭部使用的也是鍵值對形式的值,而且 HTTP1 當中的請求行以及狀態行也被分割成鍵值對,還有所有鍵都是小寫,不同於 HTTP1。除此之外,還有一個包含靜態索引表和動態索引表的索引空間,實際傳輸時會把頭部鍵值表壓縮,使用的演算法即 HPACK,其原理就是匹配當前連線存在的索引空間,若某個鍵值已存在,則用相應的索引代替首部條目,比如 “:method: GET” 可以匹配到靜態索引中的 index 2,傳輸時只需要傳輸一個包含 2 的位元組即可;若索引空間中不存在,則用字元編碼傳輸,字元編碼可以選擇哈夫曼編碼,然後分情況判斷是否需要存入動態索引表中

索引表

靜態索引

靜態索引表是固定的,對於客戶端服務端都一樣,目前協議商定的靜態索引包含 61 個鍵值,詳見 Static Table Definition - RFC 7541

比如前幾個如下

索引 欄位值 鍵值
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
動態索引

動態索引表是一個 FIFO 佇列維護的有空間限制的表,裡面含有非靜態表的索引。 動態索引表是需要連線雙方維護的,其內容基於連線上下文,一個 HTTP2 連線有且僅有一份動態表。 當一個首部匹配不到索引時,可以選擇把它插入動態索引表中,下次同名的值就可能會在表中查到索引並替換。 但是並非所有首部鍵值都會存入動態索引,因為動態索引表是有空間限制的,最大值由 SETTING 幀中的 SETTINGS_HEADER_TABLE_SIZE (預設 4096 位元組) 設定

  • 如何計算動態索引表的大小 (Table Size):

大小均以位元組為單位,動態索引表的大小等於所有條目大小之和,每個條目的大小 = 欄位長度 + 鍵值長度 + 32

這個額外的 32 位元組是預估的條目開銷,比如一個條目使用了兩個 64-bit 指標分別指向欄位和鍵值,並使用兩個 64-bit 整數來記錄欄位和鍵值的引用次數

golang 實現也是加上了 32: golang.org/x/net/http2…

SETTING 幀規定了動態表的最大大小,但編碼器可以另外選擇一個比 SETTINGS_HEADER_TABLE_SIZE 小的值作為動態表的有效負載量

  • 如何更新動態索引表的最大容量

修改最大動態表容量可以傳送一個 dynamic table size update 訊號來更改:

+---+---+---+---+---+---+---+---+
| 0 | 0 | 1 |   Max size (5+)   |
+---+---------------------------+
複製程式碼

字首 001 代表此位元組為 dynamic table size update 訊號,後面使用 N=5 的整數編碼方法表示新的最大動態表容量(不能超過 SETTINGS_HEADER_TABLE_SIZE),其計算方法下文會介紹。

需要注意的是這個訊號必須在首部塊傳送之前或者兩個首部塊傳輸的間隔傳送,可以通過傳送一個 Max size 為 0 的更新訊號來清空現有動態表

  • 動態索引表什麼時候需要驅逐條目
  1. 每當出現表大小更新的訊號時,需要判斷並驅逐隊尾的條目,即舊的索引,直到當前大小小於等於新的容量
  2. 每當插入新條目時,需要判斷並驅逐隊尾的條目,直到當前大小小於等於容量。這個情形下插入一個比 Max size 還大的新條目不會視作錯誤,但其結果是會清空動態索引表

關於動態索引表如何管理的,推薦看下 golang 的實現: golang.org/x/net/http2…,通過程式碼能更明白這個過程

索引地址空間

由靜態索引表和動態索引表可以組成一個索引地址空間:

  <----------  Index Address Space ---------->
  <-- Static  Table -->  <-- Dynamic Table -->
  +---+-----------+---+  +---+-----------+---+
  | 1 |    ...    | s |  |s+1|    ...    |s+k|
  +---+-----------+---+  +---+-----------+---+
                         ⍋                   |
                         |                   ⍒
                  Insertion Point      Dropping Point
複製程式碼

目前 s 就是 61,而有新鍵值要插入動態索引表時,從 index 62 開始插入佇列,所以動態索引表中索引從小到大依次存著從新到舊的鍵值

編碼型別表示

HPACK 編碼使用兩種原始型別: 無符號可變長度整數和八位位元組表示的字串,相應地規定了以下兩種編碼方式

整數編碼

一個整數編碼可以用於表示欄位索引值、首部條目索引值或者字串長度。 一個整數編碼含兩部分: 一個字首位元組和可選的後跟位元組序列,只有字首位元組不足以表達整數值時才需要後跟位元組,字首位元組中可用位元位 N 是整數編碼的一個引數

比如下面所示的是一個 N=5 的整數編碼(前三位元用於其他標識),如果我們要編碼的整數值小於 2^N - 1,直接用一個字首位元組表示即可,比如 10 就用 ???01010 表示

+---+---+---+---+---+---+---+---+
| ? | ? | ? |       Value       |
+---+---+---+-------------------+
複製程式碼

如果要編碼的整數值 X 大於等於 2^N - 1,字首位元組的可用位元位都設成 1,然後把 X 減去 2^N - 1 得到值 R,並用一個或多個位元組序列表示 R,位元組序列中每個位元組的最高有效位 (msb) 用於表示是否結束,msb 設為 0 時代表是最後一個位元組。具體編碼看下面的虛擬碼和例子

+---+---+---+---+---+---+---+---+
| ? | ? | ? | 1   1   1   1   1 |
+---+---+---+-------------------+
| 1 |    Value-(2^N-1) LSB      |
+---+---------------------------+
               ...
+---+---------------------------+
| 0 |    Value-(2^N-1) MSB      |
+---+---------------------------+
複製程式碼

編碼:

if I < 2^N - 1, encode I on N bits
else
    encode (2^N - 1) on N bits
    I = I - (2^N - 1)
    while I >= 128
         encode (I % 128 + 128) on 8 bits
         I = I / 128
    encode I on 8 bits
複製程式碼

解碼:

decode I from the next N bits
if I < 2^N - 1, return I
else
    M = 0
    repeat
        B = next octet
        I = I + (B & 127) * 2^M
        M = M + 7
    while B & 128 == 128
    return I
複製程式碼

比如使用 N=5 的整數編碼表示 1337:

1337 大於 31 (2^5 - 1), 將字首位元組後五位填滿 1

I = 1337 - (2^5 - 1) = 1306

I 仍然大於 128, I % 128 = 26, 26 + 128 = 154

154 二進位制編碼: 10011010, 這即是第一個後跟位元組

I = 1306 / 128 = 10, I 小於 128, 迴圈結束

將 I 編碼成二進位制: 00001010, 這即是最後一個位元組

+---+---+---+---+---+---+---+---+
| X | X | X | 1 | 1 | 1 | 1 | 1 |  Prefix = 31, I = 1306
| 1 | 0 | 0 | 1 | 1 | 0 | 1 | 0 |  1306 >= 128, encode(154), I=1306/128=10
| 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 |  10 < 128, encode(10), done
+---+---+---+---+---+---+---+---+
複製程式碼

解碼時讀取第一個位元組,發現後五位 (11111) 對應的值 I 等於 31(>= 2^N - 1),說明還有後跟位元組;令 M=0,繼續讀下一個位元組 B,I = I + (B & 127) * 2^M = 31 + 26 * 1 = 57,M = M + 7 = 7,最高有效位為 1,表示位元組序列未結束,B 指向下一個位元組;I = I + (B & 127) * 2^M = 57 + 10 * 128 = 1337,最高有效位為 0,表示位元組碼結束,返回 I

這裡也可以這樣處理 1306: 1306 = 0x51a = (0101 0001 1010)B,將 bit 序列從低到高按 7 個一組分組,則有第一組 001 1010,第二組 000 1010,加上最高有效位 0/1 便與上面的後跟位元組對應

字元編碼

一個字串可能代表 Header 條目的欄位或者鍵值。字元編碼使用位元組序列表示,要麼直接使用字元的八位位元組碼要麼使用哈夫曼編碼。

+---+---+---+---+---+---+---+---+
| H |    String Length (7+)     |
+---+---------------------------+
|  String Data (Length octets)  |
+-------------------------------+
複製程式碼
  • H: 一個位元位表示是否使用哈夫曼編碼
  • String Length: 代表位元組序列長度,即 String Data 的長度,使用 N=7 的整數編碼方式表示
  • String Data: 字串的八位位元組碼序列表示,如果 H 為 0,則此處就是原字元的八位位元組碼錶示;如果 H 為 1,則此處為原字元的哈夫曼編碼

RFC 7541 給出了一份字元的哈夫曼編碼表: Huffman Code,這是基於大量 HTTP 首部資料生成的哈夫曼編碼。

  • 當中第一列 (sym) 表示要編碼的字元,最後的特殊字元 “EOS” 代表字串結束
  • 第二列 (code as bits) 是二進位制哈夫曼編碼,向最高有效位對齊
  • 第三列 (code as hex) 是十六進位制哈夫曼編碼,向最低有效位對齊
  • 最後一列 (len) 代表編碼長度,單位 bit

使用哈夫曼編碼可能存在編碼不是整位元組的,會在後面填充 1 使其變成整位元組

比如下面的例子:

Literal Header Field with Incremental Indexing - Indexed Name

:authority: blog.wangriyu.wang 首部對應的編碼為:

41 8e 8e 83 cc bf 81 d5    35 86 f5 6a fe 07 54 df
複製程式碼

Literal Header Field with Incremental Indexing — Indexed Name 的編碼格式見下文

41 (0100 0001) 表示欄位存在索引值 1,即對應靜態表中第一項 :authority

8e (1000 1110) 最高有效位為 1 表示鍵值使用哈夫曼編碼,000 1110 表示位元組序列長度為 14

後面 8e 83 cc bf 81 d5 35 86 f5 6a fe 07 54 df 是一段哈夫曼編碼序列

由哈夫曼編碼表可知 100011 -> 'b', 101000 -> 'l', 00111 -> 'o', 100110 -> 'g', 010111 -> '.', 1111000 -> 'w', 00011 -> 'a', 101010 -> 'n', 100110 -> 'g', 101100 -> 'r', 00110 -> 'i', 1111010 -> 'y', 101101 -> 'u'

8e 83 cc bf 81 d5 35 86 f5 6a fe 07 54 df
                         |
                         ⍒
1000 1110 1000 0011 1100 1100 1011 1111 1000 0001 1101 0101 0011 0101 1000 0110 1111 0101 0110 1010 1111 1110 0000 0111 0101 0100 1101 1111
                         |
                         ⍒
100011 101000 00111 100110 010111 1111000 00011 101010 100110 101100 00110 1111010 101101 010111 1111000 00011 101010 100110 11111
                         |
                         ⍒
blog.wangriyu.wang  最後 11111 用於填充
複製程式碼

二進位制編碼

現在開始是 HPACK 真正的編解碼規範

已索引首部條目表示 (Indexed Header Field Representation)
  • Indexed Header Field

以 1 開始為標識,能在索引空間匹配到索引的首部會替換成這種形式,後面的 index 使用上述的整數編碼方式且 N = 7。 比如 :method: GET 可以用 0x82,即 10000010 表示

+---+---+---+---+---+---+---+---+
| 1 |        Index (7+)         |
+---+---------------------------+
複製程式碼

Indexed Header Field

未索引文字首部條目表示 (Literal Header Field Representation)

尚未被索引的首部有三種表示形式,第一種會新增進索引,第二種對於當前跳來說不會新增進索引,第三種絕對不被允許新增進索引

  1. 會新增索引的文字首部 (Literal Header Field with Incremental Indexing)

以 01 開始為標識,此首部會加入到解碼後的首部列表 (Header List) 中並且會把它作為新條目插入到動態索引表中

  • Literal Header Field with Incremental Indexing — Indexed Name

如果欄位已經存在索引,但鍵值未被索引,比如首部 :authority: blog.wangriyu.wang 的欄位 :authority 已存在索引但鍵值 blog.wangriyu.wang 不存在索引,則會替換成如下形式 (index 使用 N=6 的整數編碼表示)

+---+---+---+---+---+---+---+---+
| 0 | 1 |      Index (6+)       |
+---+---+-----------------------+
| H |     Value Length (7+)     |
+---+---------------------------+
| Value String (Length octets)  |
+-------------------------------+
複製程式碼

Literal Header Field with Incremental Indexing - Indexed Name

  • Literal Header Field with Incremental Indexing — New Name

如果欄位和鍵值均未被索引,比如 upgrade-insecure-requests: 1,則會替換成如下形式

+---+---+---+---+---+---+---+---+
| 0 | 1 |           0           |
+---+---+-----------------------+
| H |     Name Length (7+)      |
+---+---------------------------+
|  Name String (Length octets)  |
+---+---------------------------+
| H |     Value Length (7+)     |
+---+---------------------------+
| Value String (Length octets)  |
+-------------------------------+
複製程式碼

Literal Header Field with Incremental Indexing — New Name

  1. 不新增索引的首部 (Literal Header Field without Indexing)

以 0000 開始為標識,此首部會加入到解碼後的首部列表中,但不會插入到動態索引表中

  • Literal Header Field without Indexing — Indexed Name

如果欄位已經存在索引,但鍵值未被索引,則會替換成如下形式 (index 使用 N=4 的整數編碼表示)

+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 0 |  Index (4+)   |
+---+---+-----------------------+
| H |     Value Length (7+)     |
+---+---------------------------+
| Value String (Length octets)  |
+-------------------------------+
複製程式碼

Literal Header Field without Indexing - Indexed Name

  • Literal Header Field without Indexing — New Name

如果欄位和鍵值均未被索引,則會替換成如下形式。比如 strict-transport-security: max-age=63072000; includeSubdomains

+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 0 |       0       |
+---+---+-----------------------+
| H |     Name Length (7+)      |
+---+---------------------------+
|  Name String (Length octets)  |
+---+---------------------------+
| H |     Value Length (7+)     |
+---+---------------------------+
| Value String (Length octets)  |
+-------------------------------+
複製程式碼

Literal Header Field without Indexing - New Name

  1. 絕對不新增索引的首部 (Literal Header Field Never Indexed)

這與上一種首部類似,只是標識為 0001,首部也是會新增進解碼後的首部列表中但不會插入動態更新表。

區別在於這類首部發出是什麼格式表示,接收也是一樣的格式,作用於每一跳 (hop),如果中間通過代理,代理必須原樣轉發不能另行編碼。

而上一種首部只是作用當前跳,通過代理後可能會被重新編碼

golang 實現中使用一個 Sensitive 標明哪些欄位是絕對不新增索引的: golang.org/x/net/http2…

RFC 文件中詳細說明了這麼做的原因: Never-Indexed Literals

表示形式除了標識其他都跟上一種首部一樣:

  • Literal Header Field Never Indexed — Indexed Name
+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 1 |  Index (4+)   |
+---+---+-----------------------+
| H |     Value Length (7+)     |
+---+---------------------------+
| Value String (Length octets)  |
+-------------------------------+
複製程式碼
  • Literal Header Field Never Indexed — New Name
+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 1 |       0       |
+---+---+-----------------------+
| H |     Name Length (7+)      |
+---+---------------------------+
|  Name String (Length octets)  |
+---+---------------------------+
| H |     Value Length (7+)     |
+---+---------------------------+
| Value String (Length octets)  |
+-------------------------------+
複製程式碼
動態表最大容量更新 (Dynamic Table Size Update)

以 001 開始為標識,作用前面已經提過

+---+---+---+---+---+---+---+---+
| 0 | 0 | 1 |   Max size (5+)   |
+---+---------------------------+
複製程式碼

Literal Header Field without Indexing - Indexed Name

可以傳送 Max Size 為 0 的更新來清空動態索引表

Literal Header Field without Indexing - Indexed Name

例項

RFC 中給出了很多例項 Examples - RFC 7541,推薦看一遍加深理解

What then ?

HTTP/2 演示

http2.akamai.com/demo

http2.golang.org/

網站啟用 h2 的前後對比,使用 WebPageTest 做的測試,第一張是 h1,第二張是 h2:

image
image

使用 HTTP/2 建議

nginx 開啟 HTTP2 只需在相應的 HTTPS 設定後加上 http2 即可

listen [::]:443 ssl http2 ipv6only=on;
listen 443 ssl http2;
複製程式碼

以下幾點是 HTTP/1 和 HTTP/2 都同樣適用的

1、開啟壓縮

配置 gzip 等可以使傳輸內容更小,傳輸速度更快

例如 nginx 可以再 http 模組中加入以下欄位,其他欄位和詳細解釋可以谷歌

    gzip  on; // 開啟
    gzip_min_length 1k;
    gzip_comp_level 1; // 壓縮級別
    gzip_types text/plain application/javascript application/x-javascript application/octet-stream application/json text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png font/ttf font/otf image/svg+xml; // 需要壓縮的檔案型別
    gzip_vary on;
    gzip_disable "MSIE [1-6]\.";
複製程式碼

2、使用快取

給靜態資源設定一個快取期是非常有必要的,關於快取見另一篇博文 HTTP Message

例如 nginx 在 server 模組中新增以下欄位可以設定快取時間

 location ~* ^.+\.(ico|gif|jpg|jpeg|png|moc|mtn|mp3|mp4|mov)$ {
   access_log   off;
   expires      30d;
 }

 location ~* ^.+\.(css|js|txt|xml|swf|wav|json)$ {
   access_log   off;
   expires      5d;
 }

 location ~* ^.+\.(html|htm)$ {
   expires      24h;
 }

 location ~* ^.+\.(eot|ttf|otf|woff|svg)$ {
   access_log   off;
   expires 30d;
 }
複製程式碼

3、CDN 加速

CDN 的好處是就近訪問,延遲低,訪問快

4、減少 DNS 查詢

每個域名都需要 DNS 查詢,一般需要幾毫秒到幾百毫秒,移動環境下會更慢。DNS 解析完成之前,請求會被阻塞。減少 DNS 查詢也是優化項之一

瀏覽器的 DNS Prefetching 技術也是一種優化手段

5、減少重定向

重定向可能引入新的 DNS 查詢、新的 TCP 連線以及新的 HTTP 請求,所以減少重定向也很重要。

瀏覽器基本都會快取通過 301 Moved Permanently 指定的跳轉,所以對於永久性跳轉,可以考慮使用狀態碼 301。對於啟用了 HTTPS 的網站,配置 HSTS 策略,也可以減少從 HTTP 到 HTTPS 的重定向

但以下幾點就不推薦在 HTTP/2 中用了

1、域名分片

HTTP/2 對於同一域名使用一個 TCP 連線足矣,過多 TCP 連線浪費資源而且效果不見得一定好

而且資源分域會破壞 HTTP/2 的優先順序特性,還會降低頭部壓縮效果

2、資源合併

資源合併會不利於快取機制,而且單檔案過大對於 HTTP/2 的傳輸不好,儘量做到細粒化更有利於 HTTP/2 傳輸

3、資源內聯

HTTP/2 支援 Server-Push,相比較內聯優勢更大效果更好

而且內聯的資源不能有效快取

如果有共用,多頁面內聯也會造成浪費

HTTP/2 最佳實踐

使用 HTTP/2 儘可能用最少的連線,因為同一個連線上產生的請求和響應越多,動態字典積累得越全,頭部壓縮效果也就越好,而且多路複用效率高,不會像多連線那樣造成資源浪費

為此需要注意以下兩點:

  • 同一域名下的資源使用同一個連線,這是 HTTP/2 的特性
  • 不同域名下的資源,如果滿足能解析到同一 IP 或者使用的是同一個證照(比如泛域名證照),HTTP/2 可以合併多個連線

所以使用相同的 IP 和證照部署 Web 服務是目前最好的選擇,因為這讓支援 HTTP/2 的終端可以複用同一個連線,實現 HTTP/2 協議帶來的好處;而只支援 HTTP/1.1 的終端則會不同域名建立不同連線,達到同時更多併發請求的目的

比如 Google 一系列網站都是用的同一個證照:

image

但是這好像也會造成一個問題,我使用 nginx 搭建的 webserver,有三個虛擬主機,它們共用一套證照,其中兩個我顯示地配置了 http2,而剩下一個我並沒有配置 http2,結果我訪問未配置 http2 的站點時也變成了 http2。

大圖片傳輸碰到的問題

先比較一下 h1 和 h2 的頁面載入時間,圖中綠色代表發起請求收到響應等待負載的時間,藍色代表下載負載的時間:

image
image

可以發現 h2 載入時間還比 h1 慢一點,特別是碰到大圖片時差別更明顯

這篇文章對不同場景下 h1 和 h2 載入圖片做了測試: Real–world HTTP/2: 400gb of images per day

其結果是:

  • 對一個典型的富影像,延遲限制 (latency–bound) 的介面來說。使用一個高速,低延遲的連線,視覺完成度 (visual completion) 平均會快 5%。

  • 對一個影像極其多,頻寬限制 (bandwidth–bound) 的頁面來說。使用同樣的連線,視覺完成度平均將會慢 5–10%,但頁面的整體載入時間實際是減少了,因為得益於連線延遲少。

  • 一個高延遲,低速度的連線(比如移動端的慢速 3G) 會對頁面的視覺完成造成極大的延遲,但 h2 的視覺完成度明顯更高更好。

在所有的測試中,都可以看到: h2 使整體頁面的載入速度提高了,並且在初次繪製 (initial render) 上做的更好,雖然第二種情況中視覺完成度略微下降,但總體效果還是好的

視覺完成度下降的原因是因為沒有 HTTP/1.x 同時連線數量的限制,h2 可以同時發起多張圖片的請求,伺服器可以同時響應圖片的負載,可以從下面的動圖中看到

image

一旦圖片下載完成,瀏覽器就會繪製出它們,然而,小圖片下載後會渲染地更快,但是如果一個大圖片恰好是初始的檢視,那就會花費較長的時間載入,延遲視覺上的完成度。

chrome bug

上面的動圖是在 Safari 上的測試結果,圖片最後都下載成功了,而我在 Chrome 上測試時後面的部分圖片直接掛了,都報 ERR_SPDY_PROTOCOL_ERROR 錯誤,而且是百分百復現

image

去看了下 ERR_SPDY_PROTOCOL_ERROR 出在哪,發現是 Server reset stream,應該是哪出錯了導致流提前終止

image

然後再研究了一下 HTTP/2 的幀序列,發出的請求都在 629 號訊息中響應成功了,但是返回的資料幀只有流 15 上的,實際收到的圖片又不止流 15 對應的圖片,這是為什麼?

image

後面我繼續測試發現連續請求幾張大圖片,雖然 HEADERS 幀都開啟的是不同的流,返回的響應的 HEADERS 幀也還是對應前面的流 ID,但是響應的 DATA 幀都是從第一個開啟的流上返回的。

如果是小圖片的話,一個請求響應過後這個流就關閉了,下一張小圖是在其自己對應的流上返回的。只有連續幾張大圖會出現上述情形,這個機制很奇怪,我暫時還沒有找到解釋的文件。

至於 chrome 為什麼出錯呢,看一下 TCP 報文就會發現所有資料在一個連線上傳送,到後面 TCP 包會出現各種問題,丟包、重傳、失序、重包等等,不清楚 Safari 是否也是這樣,因為 wireshark 只能解 chrome 的包解不了 Safari 的包

image

《web 效能權威指南》中提及 HTTP/2 中一個 TCP 可能會造成的問題: 雖然消除了 HTTP 隊首阻塞現象,但 TCP 層次上仍存在隊首阻塞問題;如果 TCP 視窗縮放被禁用,那頻寬延遲積效應可能會限制連線的吞吐量;丟包時 TCP 擁塞視窗會縮小;

TCP 是一方面原因,還有另一方面應該是瀏覽器策略問題,估計也是 chrome bug,對比兩張動圖你會發現,safari 接收負載是輪流接收,我們幾個接收一點然後換幾個人接收,直到所有都接受完;而 chrome 則是按順序接收,這個接收完才輪到下一個接收,結果後面的圖片可能長時間未響應就掛了。

使用漸進式圖片

漸進式 jpg 代替普通 jpg 有利於提高視覺完成度,而且檔案更小:

輸入 convert --version 看看是否已安裝 ImageMagic,如果沒有先安裝: Mac 可以用 brew install imagemagick,Centos 可以用 yum install imagemagick

檢測是否為 progressive jpeg,如果輸出 None 說明不是 progressive jpeg;如果輸出 JPEG 說明是 progressive jpeg:

$ identify -verbose filename.jpg | grep Interlace
複製程式碼

將 basic jpeg 轉換成 progressive jpeg,interlace 引數:

$ convert -strip -interlace Plane source.jpg destination.jpg // 還可以指定質量 -quality 90

// 批量處理
$ for i in ./*.jpg; do convert -strip -interlace Plane $i $i; done
複製程式碼

也可以轉換 PNG 和 GIF,但是我試過 convert -strip -interlace Plane source.png destination.png 但轉換後的圖片往往會更大,不推薦這麼用,可以 convert source.png destination.jpg

ImageMagic 還有很多強大的功能

// 圖片縮放
$ convert -resize 50%x50% source.jpg destination.jpg
// 圖片格式轉換
$ convert source.jpg destination.png
// 配合 find 命令,將當前目錄下大於 100kb 的圖片按 75% 質量進行壓縮
$ find -E . -iregex '.*\.(jpg|png|bmp)' -size +100k -exec convert -strip +profile “*” -quality 75 {} {} \;
複製程式碼

png 壓縮推薦使用 pngquant

另外 photoshop 儲存圖片時也可以設定漸進或交錯:

漸進式圖片:選擇圖片格式為 JPEG => 選中“連續”

交錯式圖片:選擇圖片格式為 PNG/GIF => 選中“交錯”

SPDY 與 HTTP2 的關係

SPDY 是 HTTP2 的前身,大部分特性與 HTTP2 保持一致,包括伺服器端推送,多路複用和幀作為傳輸的最小單位。但 SPDY 與 HTTP2 也有一些實現上的不同,比如 SPDY 的頭部壓縮使用的是 DEFLATE 演算法,而 HTTP2 使用的是 HPACK 演算法,壓縮率更高。

QUIC 協議

Google 的 QUIC(Quick UDP Internet Connections) 協議,繼承了 SPDY 的特點。QUIC 是一個 UDP 版的 TCP + TLS + HTTP/2 替代實現。

QUIC 可以建立更低延遲的連線,並且也像 HTTP/2 一樣,通過僅僅阻塞部分流解決了包裹丟失這個問題,讓連線在不同網路上建立變得更簡單 - 這其實正是 MPTCP 想去解決的問題。

QUIC 現在還只有 Google 的 Chrome 和它後臺伺服器上的實現,雖然有第三方庫 libquic,但這些程式碼仍然很難在其他地方被複用。該協議也被 IETF 通訊工作組引入了草案。

Caddy: 基於 Go 語言開發的 Web Server, 對 HTTP/2 和 HTTPS 有著良好的支援,也開始支援 QUIC 協議 (試驗性)

推薦工具

如果你訪問的站點開啟了 HTTP/2,圖示會亮起,而且點選會進入 chrome 內建的 HTTP/2 監視工具

C 語言實現的 HTTP/2,可以用它除錯 HTTP/2 請求

直接 brew install nghttp2 就可以安裝,安裝好後輸入 nghttp -nv https://nghttp2.org 就可以檢視 h2 請求

image

如果無法解包看一下 sslkeylog.log 檔案有沒有寫入資料,如果沒有資料說明瀏覽器開啟方式不對,得用命令列開啟瀏覽器,這樣才能讓瀏覽器讀取環境變數然後向 sslkeylog 寫入金鑰,另外此方法好像支援谷歌瀏覽器和火狐,對 Safari 無效

如果 sslkeylog.log 有資料,wireshark 還是無法解包,開啟設定的 SSL 選項重新選擇一下檔案試試,如果還是不行也用命令列開啟 Wireshark

一次不行多試幾次

  • h2o: 優化的 HTTP Server,對 HTTP/2 的支援性做的比較好

References

相關文章