博客 / 詳情

返回

HTTP - HTTP/2 知識點

引言

在《圖解HTTP》的讀書筆記《圖解HTTP》- HTTP協議歷史發展(重點) 當中介紹了一部分關於HTTP/2的內容,但是內容比較簡短沒有過多深入,本文對於HTTP/2 協議做一個更深入的介紹。

概覽

HTTP1.X 有兩個主要的缺點:安全不足性能不高

所謂安全不足,是指HTTP1.X 大部分時候使用了明文傳輸,所以很多時候黑客可以通過抓包報文的方式對於網絡數據進行監控和嘗試破解,為了安全傳輸數據,HTTP通常和TLS組合實現網絡安全連接。

性能不高則指的是HTTP在請求傳輸中會傳輸大量的重複字段,Body的數據可以通過GZIP進行壓縮。這達到了可以勉強接收傳輸效率,但是Header頭部字段依舊非常臃腫和低效,並且HTTP1.X 後續也沒有效的頭部壓縮手段,HTTP/2 借用了哈夫曼編碼對於Header進行高效壓縮,提高傳輸效率。

除了上面的問題,HTTP1.X中最大的問題是隊頭阻塞,HTTP1.X中瀏覽器對於同一域名的併發連接訪問此時是有限的,所以常常會導致只有個位數的連接可以正常工作,後續的連接都會被阻塞。

HTTP/2 解決隊頭阻塞是以 HTTP1.X 管道化的為基礎拓展,它使用了二進制流和幀概念解決應用層隊頭阻塞。應用層的阻塞被解決便是實現流併發傳輸

為了控制資源的資源的獲取順序,HTTP在併發傳輸的基礎上實現請求優先級以及流量控制,流的流量控制是考慮接收方是否具備接收能力。

在發送方存在WINDOWS流量窗口,而接收方可以通過一個叫做WINDOW_UPDATE幀限制發送方的傳輸長度。

要理解HTTP/2的細節需要有一個宏觀的概念:為了提高效率,HTTP/2整體都在向着TCP協議貼近

以上就是對於HTTP/2升級的模糊理解,HTTP/2 的改進從整體上分為下面幾個部分:

  • 兼容HTTP1.X
  • 應用層隊頭阻塞解決
  • 併發傳輸
  • 多路複用
  • 二進制幀
  • 服務器推送
  • HPACK/頭部壓縮
  • 請求優先級
  • 補充

    • 連接前言
    • 流和管道化關係
    • 請求頭字段約束

思維導圖

https://www.mubucm.com/doc/3kTM1b8PGV5

兼容HTTP1.X

HTTP和TLS協議一樣揹着巨大的歷史包袱,所以不能在結構上做出過多的改動,HTTP/2為了進行推廣也必須要進行前後兼容,而兼容HTTP1.X 則引導出下面三個點:

  • HTTP協議頭平滑過渡
  • 應用層協議改變
  • 基本傳輸格式的保證

HTTP協議頭平滑過渡

所謂的平滑過渡指的是協議頭的識別依然是 HTTP開頭,不管是HTTP1 還是 HTTP/2,甚至是HTTP3,都將會沿用http開頭的協議名進行向後兼容。

應用層協議改變

HTTP/2只改變了應用層並沒有改變TCP層和IP層,所以數據依然是通過TCP報文的方式進行傳輸,經過TCP握手和HTTP握手完成。

基本傳輸格式的保證

HTTP1.X中的請求報文格式如下,結合來説請求報文可以總結為下面的格式:

  • 請求行
  • 請求首部字段和其他字段
  • 空行
  • 請求負載

HTTP請求報文結構

HTTP 雖然把內部的格式大變樣,但是請求報文的結構總體是沒有變的。

推廣安全

HTTP/2是“事實上的安全協議”,HTTP/2雖然並沒有強制使用SSL安全傳輸,但是許多主流瀏覽器已經不支持非HTTPS進行HTTP2 請求,同時可以發現很多實現了HTTP/2的網站基本都是都是具備HTTPS安全傳輸條件的。

因為HTTP/2要比TLS1.3早出幾年,HTTP/2推廣加密版本的 HTTP/2 在安全方面做了強化,要求下層的通信協議必須是TLS1.2 以上,並且此時TLS1.2很多加密算法已經被證實存在安全隱患(比如DES、RC4、CBC、SHA-1不可用),所以使用HTTP/2被要求保證前向安全,更像是TLS1.25

因為TLS1.3要比HTTP/2要晚幾年才出台,而HTTP/2出現的時候TLS很多加密套件早已經沒法使用了,所以HTTP/2使用的TLS1.2加密套件是帶橢圓曲線函數的TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256

HTTP/2協議還對加密和不加密的報文進行劃分,HTTP/2 定義了字符串標識標識明文和非明文傳輸,“h2”表示加密的 HTTP/2,“h2c”表示明文的 HTTP/2,這個c表示"clear text"

協議棧變化

從HTTP1.X 到HTTPS以及HTTP/2的協議棧變化圖可以看到整個應用層協議雖然結構上沒有過多調整,但是內容出現了天翻地覆的變化,實現細節也更加複雜。

雖然HTTP/2的“語法”複雜了很多,但是“語義”本身是沒有變化的,用HTTP1.X 的一些思路抽象理解HTTP/2的結構定義是適用的。

二進制幀(Stream)

二進制幀是HTTP/2的“語法”變動,HTTP/2的傳輸格式由明文轉為了二進制格式, 屬於向 TCP/IP 協議“靠攏”,可以通過位運算提高效率。

二進制的格式雖然對於人閲讀理解不是很友好,但是對於機器來説卻剛好相反,實際上二進制傳輸反倒要比明文傳輸省事多了,因為二進制只有0和1絕對不會造成解析上的歧義,也不會因為編碼問題需要額外轉譯。

二進制幀保留Header+Body傳輸結構,但是打散了內部的傳輸格式,把內容拆分為二進制幀的格式,HTTP/2把報文的基本傳輸單位叫做,而幀分為兩個大類 HEADERS(首部)DATA(消息負載),一個消息劃分為兩類幀傳輸同時採用二進制編碼。

這種做法類似Chunked化整數據的方式,把Header+body等同的幀數據,同時內部通過類型判斷進行標記。

這裏可以舉個簡單例子,比如常見的狀態碼 200,用文本數據傳輸需要3個字節(二進制:00110010 00110000 00110000),而用HTTP/2的二進制只需要1個字節(10001000)

HTTP/2.0二進制幀

二進制分幀結構

HTTP/2的數據傳輸基本單位(最小單位)是幀,幀結構如下:

二進制分幀結構

注意這裏的單位是bit不是byte,頭部實際上佔用的字節數非常少,一共加起來也就9個字節大小。其中3個字節的長度表示長度,幀長度後面表示幀類型,HTTP/2定義了多大10種類型幀,主要分為數據幀控制幀

幀類型後面接着標誌位,標誌位用於攜帶一些控制信息,比如下面:

  • END_HEADERS:表示頭數據結束標誌,相當於 HTTP1.X 裏頭後的空行“\r\n”。
  • END_Stream:表示單方向數據發送結束,後續不會再有數據幀。
  • PRIORITY:表示流的優先級。

最後是31位的流標識符以及1個最高位保留不用的數據,流標識符的最大值是 2^31,大約是 21 億大小,此標誌位的主要作用是標識該 Frame 屬於哪個 Stream,亂序傳輸中根據這部分亂序的幀流標識符號找到相同的Stream Id 進行傳輸。

RFC 文檔定義:
Streams are identified with an unsigned 31-bit integer. Streams initiated by a client MUST use odd-numbered stream identifiers; those initiated by the server MUST use even-numbered stream identifiers.

最後是幀數據,這部分為了提高利用效率使用了HPACK算法壓縮的頭部和實際數據。

實際上SPDY早期的方案也是使用GZIP壓縮,只不過CRIME壓縮漏洞攻擊之後才專門研究出HPACK算法它防止壓縮漏洞攻擊。

流與多路複用

核心概念:

  • 流是二進制幀的雙向傳輸序列
  • 一個 HTTP/2 的流就等同於一個 HTTP/1 裏的“請求 - 應答”。
  • HTTPP2的流特點

    • 一個TCP複用多個“請求響應”,支持併發傳輸
    • 流和流之間獨立,但是內部通過StreamId保證順序。
    • 流可以進行請求優先級設置
    • 流ID不允許重複
    • 0號流是用於流量控制的控制幀
      ....

理解多路複用我們需要先了解二進制幀,因為流的概念在HTTP/2中其實是 不存在的,HTTP/2討論的流是基於二進制幀的數據傳輸形式的考量。流是二進制幀的雙向傳輸序列

我們這裏再複習一遍二進制幀的結構,裏面的流標識符就是流ID。

二進制分幀結構

通過抓包可以看到HTTPS2很多時候會出現流被拆分的情況,比如下面的Headers就傳輸了3個流,把這些幀進行編號並且排隊之後進行傳輸就轉化為流傳輸:

一個 HTTP/2 的流就等同於一個 HTTP/1 裏的“請求 - 應答”,而在HTTP1裏面,它表示一次報文的“請求響應”,所以HTTP1和HTTP/2在這一點上概念是一樣的。

不過按照TCP/IP 的五層傳輸模型來看,其實TCP的連接概念也是虛擬的,它需要依賴IP運輸和MAC地址尋址,但是從功能上來説它們都是實實在在的完成傳輸動作,所以不需要糾結流虛擬還是不虛擬的概念,我們直接把他當成實際存在的更容易好理解。

HTTP/2 的流主要有下面的特點:

  1. HTTP/2遵循一個TCP上覆用多個“請求 - 應答”,意味着一個 HTTP/2 連接上可以同時發出多個流傳輸數據,並且流可以併發傳輸實現“多路複用”;
  2. 客户端和服務器都可以創建流,並且互不干擾;
  3. HTTP/2支持服務端推送,流可以從客户端或者服務端出發;
  4. 流內部的幀是有嚴格順序的,但是流之間互相獨立;
  5. 流可以設置優先級,讓服務器優先處理特定資源,比如先傳 HTML/CSS,後傳圖片,優化用户體驗;
  6. 流 ID 不能重用,只能順序遞增,客户端發起的 Stream ID 是奇數,服務器端發起的 Stream ID 是偶數;
  7. 在流上發送“RST_STREAM”幀可以隨時終止流,取消流的接收或發送;
  8. 第 0 號流比較特殊,它不能關閉,也不能發送數據幀,只能發送控制幀,用於流量控制。

從上面特點那中我們還可以發現一些細節。

默認長連接

比如第一條可以推理出HTTP/2遵循的請求跑在一個TCP連接上,而多個請求的併發傳輸跑在一個TCP連接的前提是連接有相對長時間佔用,也就是説HTTP/2 在一個連接上使用多個流收發數據本身默認就會是長連接,所以永遠不需要“Connection”頭字段(keepalive 或 close)。

RST_STREAM幀的常見應用是大文件中斷重傳,在 HTTP/1 裏只能斷開 TCP 連接重新“三次握手”進行請求重連,這樣處理的成本很高,而在 HTTP/2 裏就可以簡單地發送一個“RST_STREAM”中斷流即可進行暫停,此時長連接會繼續保持

流標識符不是無限的,如果ID遞增到耗盡,此時可以發送控制幀“GOAWAY”,真正關閉 TCP 連接

因為流雙向傳輸,HTTP/2使用了奇數和偶數劃分請求來源方向,奇數為客户端發送的幀,而偶數為服務端發送的幀,客户端在一個連接最多發出2^30請求,大約為10億個。

流狀態轉化

既然RST_STREAM幀可以改變整個流的傳輸狀態,那麼意味着HTTP/2的流是存在狀態幀的概念的,翻閲RFC文檔果然發現了狀態機的圖,從下面的可以看到比較複雜。我們重點關注四個狀態:

  • idle
  • open
  • half closed
  • closed

    是不是感覺有點熟悉?沒錯這和TCP層的連接握手狀態其實是有不少相似性的,從這裏也可以看出HTTP/2的整個理念是貼近TCP協議層。
           +--------+
                      send PP |        | recv PP
                     ,--------|  idle  |--------.
                    /         |        |         \
                   v          +--------+          v
            +----------+          |           +----------+
            |          |          | send H /  |          |
     ,------| reserved |          | recv H    | reserved |------.
     |      | (local)  |          |           | (remote) |      |
     |      +----------+          v           +----------+      |
     |          |             +--------+             |          |
     |          |     recv ES |        | send ES     |          |
     |   send H |     ,-------|  open  |-------.     | recv H   |
     |          |    /        |        |        \    |          |
     |          v   v         +--------+         v   v          |
     |      +----------+          |           +----------+      |
     |      |   half   |          |           |   half   |      |
     |      |  closed  |          | send R /  |  closed  |      |
     |      | (remote) |          | recv R    | (local)  |      |
     |      +----------+          |           +----------+      |
     |           |                |                 |           |
     |           | send ES /      |       recv ES / |           |
     |           | send R /       v        send R / |           |
     |           | recv R     +--------+   recv R   |           |
     | send R /  `----------->|        |<-----------'  send R / |
     | recv R                 | closed |               recv R   |
     `----------------------->|        |<----------------------'
                              +--------+
    
        send:   endpoint sends this frame
        recv:   endpoint receives this frame
    
        H:  HEADERS frame (with implied CONTINUATIONs)
        PP: PUSH_PROMISE frame (with implied CONTINUATIONs)
        ES: END_STREAM flag
        R:  RST_STREAM frame
    
Note that this diagram shows stream state transitions and the frames
and flags that affect those transitions only. In this regard,
CONTINUATION frames do not result in state transitions; they are
effectively part of the HEADERS or PUSH_PROMISE that they follow.

有關流狀態轉化的細節都在RFC的文檔中,鏈接如下:
:https://datatracker.ietf.org/doc/html/rfc7540#section-5.1,上面的圖理解起來比較吃力,我們先看一個極簡風格的圖:

當連接沒有開始的時候,所有流都是空閒狀態,此時的狀態可以理解為“不存在待分配”。客户端發送 HEADERS幀之後,流就會進入"open"狀態,此時雙端都可以收發數據,發送數據之後客户端發送一個帶“END_STREAM”標誌位的幀,流就進入了“半關閉”狀態。響應數據也需要發送 END_STREAM 幀,表示自己已經接收完所有數據,此時也進入到“半關閉”狀態。如果請求流ID耗盡,此時就可以發送一個 GOAWAY 完全斷開TCP連接,重新建立TCP握手。

以上就是一個簡單的流交互過程。

idel:Sending or receiving a HEADERS frame causes the stream to become "open".
END_STREAM flag causes the stream state to become "half-closed
  (local)"; an endpoint receiving an END_STREAM flag causes the
  stream state to become "half-closed (remote)".

併發傳輸

併發傳輸是依靠流的多路複用完成的,根據上面的內容我們知道Stream 可以並行在一個TCP連接上,每一個Stream就是一次請求響應,HTTP/2在併發傳輸中設置了下面幾個概念:

  • Stream
  • Message
  • Frame

這三者的關係如下

我們根據結合圖以及之前所學,對於這幾個概念做出如下定義:

Connection 連接:1 個 TCP 連接,包含 1 個或者多個 stream。所有通信都在一個 TCP 連接上完成,此連接可以承載任意數量的雙向數據流。

Stream 數據流:一個雙向通信的數據流,包含 1 條或者多條 Message。每個數據流都有一個唯一的標識符和可選的優先級信息,用於承載雙向消息。

Message 消息:對應 HTTP/1.1 中的請求 request 或者響應 response,包含 1 條或者多條 Frame。

Frame 數據幀:最小通信單位,以二進制壓縮格式存放內容。來自不同數據流的幀可以交錯發送,然後再根據每個幀頭的數據流標識符重新組裝。

HTTP1.1 由 Start Line + header + body 組成,HTTP2轉變為HEADER frame + 若干個 DATA frame 組成。

在HTTP2中,消息允許客户端或者服務器以Stream為基礎進行亂序發送,內部被拆分為獨立的幀。

客户端併發傳輸

服務端併發傳輸

客户端和服務器雙方都可以建立 Stream,HTTP2允許服務端主動推送資源給客户端,但是HTTP2頁規定 客户端建立的 Stream 必須是奇數號,而服務器建立的 Stream 必須是偶數號。

以第二個圖為例,可以看到有三個流在進行並行傳輸, 1 為奇數,代表了客户端推送的資源, 2和4位偶數,代表了服務器端推送的資源。

最後我們小結一波:

  • 所有通信都通過一個 TCP 連接執行,該連接可以攜帶任意數量的雙向流。
  • 每個流都有一個唯一標識符和可選的優先級信息,用於攜帶雙向消息。
  • 每條消息都是一個邏輯 HTTP 消息(請求或響應),它由一個或多個幀組成。
  • 幀是承載特定類型數據的最小通信單位,例如 HTTP 標頭、消息負載等。 來自不同流的幀可以被交叉傳輸,然後通過每個幀頭中的流標識符重新組合
  • 併發傳輸指的是多個流可以同時的跑在一個連接上。

應用層隊頭阻塞解決

先説一下結論:HTTP2 解決了應用層的的隊頭阻塞,但沒有解決TCP隊頭阻塞問題,我們可以認為HTTP2的隊頭阻塞很像是把管道化的概念實現的更好。

首先是HTTP1.X的隊頭阻塞問題,HTTP1在瀏覽器中的同一域名的併發連接數有限,如果連接數超過上限,排在後面的連接就需要等待前面的資源加載完成。

過去常常出現的瀏覽器空白並且一直“轉圈”就是因為這個問題。

各大服務網站的解決方式是使用資源分割的方式,配合多域名和主機進行多個IP避開瀏覽器單個域名的限制,同時結合CDN加速請求。但是這樣做需要分片多個TCP請求,TCP的連接請求的資源消耗比較大。

前面內容我們知道了,HTTP 2 通過改寫HTTP數據交互方式為二進制,使用二進制幀的結構實現了應用層的多路複用,所有的二進制幀可以組成流並行可以跑在一個TCP連接上面,每個Stream都有一個唯一的StreamId,通過每個幀上設置ID(流標識符)在雙方向上完成組裝來還原報文,接收方需要根據ID的順序拼接出完整的報文。

應用層上的隊頭阻塞是解決了,為什麼説沒有解決TCP隊頭阻塞?

我們需要明確HTTP本身是不具備數據傳輸能力的,雖然HTTP2識別數據和響應數據的方式變了,但是運載數據的還是TCP協議,而TCP協議實際上根本不認識什麼HTTP數據,也不知道什麼流,它只負責保證數據的安全傳輸。

在一個可靠的網絡中,併發傳輸和配合沒什麼問題,HTTP和TCP互相不認識對方也不打緊,但是問題就出在現代社會的網絡環境是移動和固定網絡頻繁切換的,網絡不暢事情時有發生。

在不穩定的網絡傳輸中很有可能出現TCP數據傳輸阻塞問題,假設A網站要給B用户一個CSS文件,HTTP知道他要被拆分為三個獨立資源的包,按照ID連起來拼成完整的數據。此時如果數據包1和3都傳輸過去了,但是2在傳輸過程突然出現丟包,此時接收方組裝的時候發現ID不連續,這時候是不能夠把1後面的數據包3傳出去的,TCP的處理方式是 將數據包3保存在其接收緩衝區(receive buffer)中,直到它接收到數據包2的重傳副本然後重新拼出完整的文件,然後才能給瀏覽器(這至少需要往返服務器一次)。

在HTTP1.X中如果出現上面TCP隊頭阻塞情況,可以通過直接丟棄原有的TCP開新的TCP連接解決問題,雖然開銷很大但是至少可以確保傳輸在正常進行。

而HTTP2在這種情況下就開倒車了,因為HTTP2的理念是一個TCP連接,所以只能通過等待TCP連接重傳來解決丟包的問題,這種情況下整個TCP連接都要阻塞,如果是大文件傳輸,這種體驗會更加糟糕。

結論:
TCP 協議本身的缺陷加上HTTP2一個TCP連接設計,HTTP2的TCP層隊頭阻塞問題十分顯著。HTTP1.X在解決TCP隊頭阻塞雖然笨,但是實際體驗要比HTTP2好得多。

以上這就是TCP的隊頭阻塞問題。順帶提一句HTTP3 通過了QUIC協議替換掉TCP協議,徹底實現了無隊頭阻塞的HTTP連接。

Header壓縮(Header Compression)

HTTP1.X的頭部壓縮可以總結出下面幾個缺點:

  • ASCII 編碼明文傳輸雖然容易閲讀,但是傳輸效率低。
  • 大量重複的請求和響應頭部字段消耗無用網絡傳輸帶寬。
  • 請求負載可以使用GZIP壓縮但是請求頭部字段缺乏有效的壓縮手段。

綜上所述HTTP/2為什麼要引入頭部壓縮?主要的原因是HTTP1.X 中所有的內容都是明文傳輸的,而很多情況下對於輪詢請求和頻繁調用的接口,經常需要傳輸重複請求頭部,而隨着網絡傳輸報文越來越複雜,累贅的請求頭部優化亟待優化。

頭部壓縮可以帶來多少效率提升,官方的答案是至少50%,重複字段越多優化越發明顯,具體可以看Patrick McManus對於頭部壓縮的性能提升的倡導討論:# In Defense of Header Compresson

HTTP/2 頭部壓縮是基於HPACK算法實現的,主要通過三個技術點實現:

  • 靜態表 :內部預定義了61個Header的K/V 數值
  • 動態表 :利用動態表存儲不在靜態表的字段,從62開始進行索引,主要存儲一些動態變化的請求頭部。
  • 哈夫曼(霍夫曼、赫夫曼)編碼:一種高效數據壓縮的數據結構,被廣泛應用在計算機的各個領域。

理解這幾個概念作為初學可以簡單理解設計思路是借用了DNS查表的方式,在HTTP連接的雙端構建緩存表,對於傳輸重複字段採用緩存到表裏面的方式進行替代。

靜態表

靜態表包含了一下基本不會出現變化的字段,靜態表設計固定61個字段,這些字段都是請求中高頻出現的字段,比如請求方法,資源路徑,請求狀態等等。

那麼這個Index是什麼意思?這個類似於數組定位,index標識索引,在傳輸的過程中固定的字段用固定的索引標識和傳輸,header name 標識請求頭的名稱,而Header Value則表示內容。

下面的內容來摘自小林的博客,我們來看一下靜態表是如何存儲請求頭部字段的。

注意Value字段是動態變化的,Value設置之前都需要進行哈夫曼編碼,編碼之後通常具備50%左右的字節佔用減少,比如高亮部分是 server 頭部字段,只用了 8 個字節來表示 server 頭部數據。

RFC中規定,如果頭部字段屬於靜態表範圍並且 如果Value 是變化的,那麼它的 HTTP/2 頭部前 2 位固定為 01

通過抓包瞭解server在HTTP的格式:

server: nghttpx\r\n
哈夫曼編碼之後:
server: 01110110

算上冒號空格和末尾的\r\n,共佔用了 17 字節,而使用了靜態表和 Huffman 編碼,可以將它壓縮成 8 字節,壓縮率大概 47 %

上面的 server的值是如何定義的,首先通過index找到 server字段的序列號為54,二進制為110110,同時它的Value是變化的,所以是01開頭,最後組成01110110

接着是Value部分,根據上文RFC哈夫曼編碼的規則,首個比特位是用來標記是否哈夫曼編碼的,所以跳過字節首位,後面的7位才是真正用於標識Value的長度,10000110,它的首位比特位為 1 就代表 Value 字符串是經過 Huffman 編碼的,經過 Huffman 編碼的 Value 長度為 6。

整個進化結果就是,字符串 nghttpx 轉為二進制之後,然後經過 Huffman 編碼後壓縮成了 6 個字節。 哈夫曼的核心思想就是把高頻出現的“單詞”用盡可能最短的編碼進行存儲,比如 nghttpx 對應的哈夫曼編碼表如下:

一共是六個字節的數據,從二進制通過查表的結果如下:

server 頭部的二進制數據對應的靜態頭部格式如下:

注意\r\n是不需要二進制編碼的。01 表示變化的靜態表字段。

動態表

靜態表包含了固定字段但是值不一定固定的表,而動態表則用存儲靜態表中不存在的字段,動態表從索引號62開始,編碼的時候會隨時進行更新。

比如第一次發送user-agent字段,值經過哈夫曼編碼之後傳輸給接收方,雙方共同存儲值到各自的動態表上,下一次如果需要同樣的user-agent字段只需要發送序列號index即可,因為雙方都把值存儲在各自對應的index索引當中。

所以哪怕字段越來越多,只要經過了哈夫曼編碼存儲以及通過索引號能找到對應的參數,就可以有效減少重複數據的傳輸。

哈夫曼編碼

哈夫曼編碼是一種用於無損數據壓縮的熵編碼(權編碼)算法。由美國計算機科學家大衞·霍夫曼(David Albert Huffman)在1952年發明。 霍夫曼在1952年提出了最優二叉樹的構造方法,也就是構造最優二元前綴編碼的方法,所以最優二叉樹也別叫做霍夫曼樹,對應最優二元前綴碼也叫做霍夫曼編碼。

哈夫曼編碼對於初學者來説不是特別好理解,這部分內容放到了[[哈夫曼編碼]]中進行討論。

概念不好理解,初學建議多去找找視頻教程對比學習

Header 壓縮問題

這部分實際上指的是HTTP3 對於HTTPS的Header壓縮優化,既然是優化,我們反向思考就可以知道問題了,主要是下面三點:

  • 請求接收端的處理能力有限,Header 壓縮不能設置過於極限,緩存表如果佔用超過一定的佔比就會釋放掉整個連接重新請求。(空間換時間不可避問題)
  • 靜態表容量不夠,HTTP3 升級到91個。
  • HTTP/2的動態表存在時序性問題,編碼重傳會造成網絡擁堵。

緩存表限制:瀏覽器的內存以及客户端以及服務端的內存都是有限的,尤其是動態表的不確定因素很大,HTTP標準設計要求防止動態表過度膨脹佔用內存導致客户端崩潰,並且在超過一定長度過後會自動釋放HTTP/2請求。

保守設置:壓縮表的設置有點過於保守了,所以HTTP3 對於這個表進行進一步擴展。

時序性問題:時序性問題是在傳輸的時候如果出現丟包,此時一端的動態表做了改動但是另一端是沒改變的,同樣需要把編碼重傳,這也意味着整個請求都會阻塞掉。

時序性問題

請求優先級

在開頭介紹過,因為HTTP/2實現了應用層的多路複用,但是因為雙向接收能力不對等問題,在使用多個Stream的時候容易單向請求阻塞問題。

這個問題是因為管道連接的設計思想帶來的,在起草協議之前,SPDY中通過設置優先級的方式讓重要請求優先處理解決這個問題,比如頁面的內容應該先進行展示,之後再加載CSS文件美化以及加載腳本互動等等,實際減少用户在等待過程中關閉頁面的機率,也有更好的上網體驗。

為此HTTP2設計允許每個流都可以配置單獨的權重和依賴關係:

  • 可以為每個流分配一個介於 1 和 256 之間的整數權重。
  • 可以為每個流提供對另一個流的顯式依賴關係。

可以通過流依賴和權重值可以通過構建請求“優先級樹”來更好的接收響應信息,反過來説,服務端也可以以此權重值和流依賴來實現控制CPU、內存、或者其他資源處理順序的目的,在為響應的過程中為各種分配帶寬,以獲得更好的用户體驗。

權重值越小,優先級越高

HTTP/2 中的流依賴項是通過引用另一個流的唯一標識符作為其父級來進行聲明的。如果沒有標識,則認為是root stream,聲明流依賴項設計表示應在其依賴項之前儘可能為父級分配資源,舉例來説就是在上面的響應中,先交付並且處理D,然後才進行C的處理。

共享同一父級的流(換句話説,同級流)應按其權重的比例分配資源。例如如果流 A 的權重為 12,同級 B 的權重為 4,每個流應接收資源比例計算如下:

  1. 首先把所有的權重值相加, 4+12 = 16。
  2. 計算A和B在權重值中所佔據的比例:4 / 16,12 / 16。
  3. 按照比例計算,流A 獲得3/4的可用資源,流B獲得1/4的可用資源。
  4. D依賴root stream,而C依賴D,所以D可以獲得全部的資源分配,然後再輪到C分配。
  5. 流D先於C獲得資源的全部分配,C應在A和B之前獲得資源的全部分配,剩下的再分配給A和B,同時流 B 應接收分配給流 A 的資源之後剩下的 1 / 4。
  6. 按照同樣的道理,流D應在E和C之前獲得資源的全部分配,E和C應該在A和B之前獲得相等的分配,A 和 B 應根據其權重獲得比例分配,流B接收分配給A 3/4 的最後1/4。

流依賴和權重值簡潔易懂的實現一種權重分配的表達語言,通過這些表達語言來強化瀏覽器性能,比如用户看的見的CSS、JS腳本、HTML頁面優先暫時,第一時間告知網站在積極響應而提高用户體驗。

HTTP/2 協議允許客户端隨時更新這些首選項從而進一步優化瀏覽器,換句話説我們可以隨時更改依賴關係並重新分配權重,以響應用户交互和其他信號。

注意⚠️:流依賴關係和權重表示傳輸首選項而不是強制要求,因此實際上哪怕指定了請求優先級也並不能不保證一定按照特定的處理或傳輸順序。也就是説客户端不能強制使用流優先級要求服務器按特定順序處理流。
所以可以認為優先級的設置更像是“期望”,雙端期望對方按照自己想要的結果處理。比如期望瀏覽器獲取較高優先級的資源之前,阻止服務器在較低優先級的資源上進行處理。

小結

  • 請求優先級關鍵設計來源於一個有趣的“語言模型”:

    • 1 和 256 之間的整數權重
    • 樹狀流和流之間依賴關係
  • 流依賴關係和權重表示傳輸首選項而不是強制要求
  • 請求優先級不能規定行為,而是期望

流量控制

HTTP/2的流量控制是依靠幀結構實現的,通過關鍵字段WINDOW_UPDATE幀來提供流量控制,根據結構體定義,這個幀固定為4個字節的長度:

WINDOW_UPDATE Frame {
  Length (24) = 0x04,
  Type (8) = 0x08,

  Unused Flags (8),

  Reserved (1),
  Stream Identifier (31),

  Reserved (1),
  Window Size Increment (31),
}

對於流量控制,存在下面幾個顯著特徵:

  • 流控制僅適用於被識別為受流量控制的幀(DATA 幀),同時流量的控制存在方向概念,由數據的雙端負責流量控制,可以設置每一個流窗口的大小。
  • 流量控制需要受到各種代理服務器限制,並不完全靠譜,比如如果IP的一跳中存在代理,則代理和雙端都有流控,所以特別注意這並非端到端的控制;
  • 基於信用基礎公佈每個流在每個連接上接收了多少字節,WINDOW_UPDATE 框架沒有定義任何標誌;換句話説只定義了幾個基本的幀字段格式定義,怎麼發送接收和控制完全由實現方決定,保證流控的自由度。
  • WINDOW_UPDATE 可以對已設置了 END_STREAM 標誌的幀進行發送,表示接收方這時候有可能進入了半關閉或者已經關閉的狀態接收到WINDOW_UPDATE幀,但是接收者不能視作錯誤對待;
  • 接收者必須將接收到流控制窗口增量為 0 的 WINDOW_UPDATE 幀視為PROTOCOL_ERROR類型的流錯誤 ;
  • 對於連接與所有新開啓的流而言,流控窗口大小默認都是 65535,且最大值為 2^32;
  • 流控無法禁用
  • 流控既可以作用於 stream 也可以作用於 connection。

瞭解流量控制的注意事項,我們看看它是如何實現的?

流量控制窗口 (Flow Control Window)

每個發送端會存在一個叫做流量窗口的東西,裏面簡單保存了整數值,標識發送端允許傳輸的,當流量窗口沒有可用空間時,可以發送帶有END_STREAM 的幀標記。

但是發送端的流量窗口沒有多大意義,這有點類似把井水裝到一個桶裏面,主要的限制不是井裏有多少水,而是看桶可以裝多少水,所以為了確保網絡正常傳輸,發送端傳輸長度不能超過超出接收端廣播的流量控制窗口大小的可用空間長度。

WINDOW_UPDATE 幀

前面多次提到的 WINDOW_UPDATE幀有什麼用?主要作用是給接收端告知自己的接收能力,如果提供這個幀,那麼發送方不管有多強能力,都需要按照提供的長度限制進行數據發送。

WINDOW_UPDATE幀要麼單獨作用於 stream,要麼單獨作用於 connection(streamid 為 0 時,表示作用於 connection,無接收能力)

我們根據流量窗口和WINDOW_UPDATE幀瞭解基本算法流程如下:

  1. 發送方提供流量窗口初始值,初始值是SETTING 幀,這個幀的參數設置十分關鍵,比如 SETTINGS_INITIAL_WINDOW_SIZE表示窗口初始大小,默認初始值和最大值均為 65535
SETTINGS_INITIAL_WINDOW_SIZE (0x4): Indicates the sender's initial
  window size (in octets) for stream-level flow control.  The
  initial value is 2^16-1 (65,535) octets.
  1. 發送端每發送一個DATA幀,就把window流量窗口的值遞減,遞減量為這個幀的大小,如果流量窗口大小小於DATA幀,則必須對於流進行拆分,直到小於windows流量窗口為止,而流量窗口遞減到0的時候,不能發送任何幀。
  2. 接收端通過 WINDOW_UPDATE 幀,告知發送方自己的負載能力。

SETTING 幀

本節最後我們再補充一下SETTING 幀的選項含義:

  • SETTINGS_HEADER_TABLE_SIZE:HPACK(header壓縮算法) header表的最大長度,默認值 4096
  • SETTINGS_ENABLE_PUSH:客户端發向服務端的配置,若設置為 true,客户端將允許服務端推送響應,默認值 true
  • SETTINGS_MAX_CONCURRENT_STREAMS:同時打開的 stream 最大數量,通常意味着同一時刻能夠同時響應的請求數量,默認無限
  • SETTINGS_INITIAL_WINDOW_SIZE:流控的初始窗口大小,默認值 65535
  • SETTINGS_MAX_FRAME_SIZE:對端能夠接收幀的最大長度,默認值16384
  • SETTINGS_MAX_HEADER_LIST_SIZE:對端能夠接收的 header 列表最大長度,默認不限制

題外話:httpcore5 的 BUG

httpcore5 過去的版本存在流控的BUG,但是這個問題很快被發現並且被修復。

因為涉及流控觸發BUG的概率還是挺大的,也是比較嚴重的BUG,BUG修復可以看這個 COMMIT,想看具體分析可以看參考文章的第一篇。下面為個人閲讀文章之後分析思路。

我們以 URL https://www.sysgeek.cn/ 為例,通過在本地做代碼 debug 發現,最終拋異常的原因在於接收到 WINDOW_UPDATE 幀後,更新後窗口大小值大於 2^32 - 1導致拋異常:

首先根據 commit log,修復者自己也進行了説明。

The connection flow-control window can only be changed using
> WINDOW_UPDATE frames.

我們接着對照 RFC 的文檔定義:

意思是説connection 窗口大小僅在接收到 WINDOW_UPDATE 後才可能修改這個規則被違背的。

把代碼扒出來看一下改了什麼:

private void applyRemoteSettings(final H2Config config) throws H2ConnectionException {
    
    remoteConfig = config;
    
    hPackEncoder.setMaxTableSize(remoteConfig.getHeaderTableSize());
    
    final int delta = remoteConfig.getInitialWindowSize() - initOutputWinSize;
    
    initOutputWinSize = remoteConfig.getInitialWindowSize();
    
      
    
    if (delta != 0) {
        // 關鍵BUG修復
        updateOutputWindow(0, connOutputWindow, delta);
    
    if (!streamMap.isEmpty()) {
    
        for (final Iterator<Map.Entry<Integer, H2Stream>> it = streamMap.entrySet().iterator(); it.hasNext(); ) {
            
            final Map.Entry<Integer, H2Stream> entry = it.next();
            
            final H2Stream stream = entry.getValue();
            
            try {
            
            updateOutputWindow(stream.getId(), stream.getOutputWindow(), delta);
            
            } catch (final ArithmeticException ex) {
            
            throw new H2ConnectionException(H2Error.FLOW_CONTROL_ERROR, ex.getMessage());
        
        }
    
    }
    
    }
    
    }

}

delta 是對方告知的 WINDOW_UPDATE 大小,問題出在接收 SETTINGS 指令之後,初始化的窗口大小被修改了,原本的6555被改成更大的值,這個值超過了流量窗口的默認值和最大值的上限,但是流量窗口的大小必須是WINDOW_UPDATE幀傳輸之後才允許更改,發送方擅自修改並且發送了超過接收方能力的流量,被檢查出異常流量而在代碼中拋出異常。

這個很好理解,就好像井水不管桶有多大,就一個勁的往裏面灌水,這肯定是有問題的。

服務器推送

概括:

  • 管道化改良
  • 偶數幀數為起始
  • 依靠PUSH_PROMISE幀傳輸頭部信息
  • 通過幀中的 Promised Stream ID 字段告知偶數號

服務器推送的RFC定義:RFC 7540: Hypertext Transfer Protocol Version 2 (HTTP/2) (rfc-editor.org)

服務器推送是為了彌補HTTP這個半雙工協議的短板,雖然HTTP1.X 嘗試使用管道流實現服務端推送,但是管道流存在各種缺陷所以HTTP1.X並沒有實現服務端推送的功能。

注意在上面提到的二進制幀數據傳輸中中,客户端發起的請求必須使用的是奇數號 Stream,服務器主動的推送請求使用的是偶數號 Stream,所以如果是服務端推送通常是從偶數開始。

服務端推送資源需要依靠PUSH_PROMISE幀傳輸頭部信息,並且需要通過幀中的 Promised Stream ID 字段告知客户端自己要發送的偶數號。

需要服務端推送存在諸多限制,從整體上看服務端推送的話語權基本是在客户端這邊,下面簡單列舉幾點:

  • 客户端可以設置 SETTINGS_MAX_CONCURRENT_STREAMS=0 或者重置PUSH_PROMISE拒絕服務端推送。
  • 客户端可以通過SETTINGS_MAX_CONCURRENT_STREAMS設置服務端推送的響應。
  • PUSH_PROMISE幀只能通過服務端發起,使用客户端推送是“不合法“的,服務端有權拒絕。

補充

連接前言

這個連接前言算是比較偏門的點,也常常容易被忽略。如果能看懂下面的內容,那麼基本就知道怎麼會回事了。

   In HTTP/2, each endpoint is required to send a connection preface as
   a final confirmation of the protocol in use and to establish the
   initial settings for the HTTP/2 connection.  The client and server
   each send a different connection preface.

   The client connection preface starts with a sequence of 24 octets,
   which in hex notation is:

     0x505249202a20485454502f322e300d0a0d0a534d0d0a0d0a

   **That is, the connection preface starts with the string "PRI *
   HTTP/2.0\r\n\r\nSM\r\n\r\n").**  This sequence MUST be followed by a
   SETTINGS frame ([Section 6.5](https://datatracker.ietf.org/doc/html/rfc7540#section-6.5)), which MAY be empty.  The client sends
   the client connection preface immediately upon receipt of a 101
   (Switching Protocols) response (indicating a successful upgrade) or
   as the first application data octets of a TLS connection.  If
   starting an HTTP/2 connection with prior knowledge of server support
   for the protocol, the client connection preface is sent upon
   connection establishment.

連接前言的關鍵點如下:

  • “連接前言”是標準的 HTTP/1 請求報文,使用純文本的 ASCII 碼格式,請求方法是特別註冊的一個關鍵字“PRI”,全文只有 24 個字節。
  • 如果客户端在建立連接的時候使用 PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n,並且通過 SETTINGS 幀告知服務端自己期望HTTPS2 連接,服務端就知道客户端需要的是TLS的HTTP/2連接。

為什麼是這樣的規則,以及為什麼是傳輸這樣一串奇怪的字符無需糾結,這是HTTP/2標準制定者指定的規矩,所以就不要問“為什麼會是這樣”了。

其實把這一串咒語拼起來還是有含義的,PRISM,2013年斯諾登的“稜角計劃”,這算是在致敬?

流和管道化關係

HTTP/2的流是對於HTTP1.X的管道化的完善以及改進,所以在流中可以看到不少管道化的概念。而HTTP/2 要比管道化更加完善合理,所以管道化的概念在HTTP/2之後就被流取代而消失了。

請求頭字段約束

因為HTTP1.X對於頭字段寫法很隨意,所以HTTP/2設置所有的頭字段必須首字母小寫。

 Just as in HTTP/1.x, header field names are strings of ASCII
   characters that are compared in a case-insensitive fashion.  However,
   header field names MUST be converted to lowercase prior to their
   encoding in HTTP/2

就像在 HTTP/1.x 中一樣,標頭字段名稱是 ASCII 字符串
    以不區分大小寫的方式比較的字符。 然而,
    標頭字段名稱必須在其之前轉換為小寫
    HTTP/2 中的編碼

總結

我們按照重點排序,來從整體上看一下HTTP2的知識點,為此我總結了幾個關鍵字:

重塑:不是指完全重造,而是借用HTTP協議的基本架構,從內部進行重新調整。

兼容:HTTP協議揹負巨大的歷史包袱,所有的改動如果無法向後兼容,那麼就是失敗的升級,也不會受到廣泛認可。所以HTTP2整體結構沿用HTTP1.X,加入連接前言這種和TLS握手類似的“咒語”完成新協議的啓用。

狀態:Header壓縮的HACK技術加入之後,HTTP似乎不再像是以前那樣的無狀態協議,它的動態表和靜態表都是實際存在的,每個HTTP2的連接都會出現狀態維護,所以雖然本身外部實現不需要關注這些細節,實際上HTTP2 內部確實加了狀態這個概念。

貼合TCP:HTTP2的很多細節不難看出是為了更好的和TCP協調,比如二進制數據。

管道化延伸:管道化在HTTP1.X中非常雞肋,而HTTP2則把管道化的理念改進為流的概念進行數據傳輸,並且依靠流實現併發傳輸。

寫到最後

來來回回改了很多次,自認為把HTTP2主要的知識點普及了,更多細節需要深入RFC文檔,不過不是專攻網絡編程方向的個人也就點到為止了。

參考文章

  • # HTTP/2 的流控實現
  • # HTTP/2協議解析
  • 3.6 HTTP/2 牛逼在哪? | 小林coding (xiaolincoding.com)
  • # 霍夫曼編碼 - 維基百科
  • Introduction to HTTP/2 (web.dev)
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.