HTTP 2-0 爲什麼這麼設計

HTTP 1.0 是 1996 年發佈的,奠定了 web 的基礎。時隔三年,1999 年又發佈了 HTTP 1.1,對功能上做了擴充。之後又時隔十六年,2015 年發佈了 HTTP 2.0。

同學們肯定會覺得,隔了這麼長時間,而且還從版本號還從 1 到了 2,那肯定有很多的新功能。其實不是的,HTTP 2.0 沒有沒有功能上的新增,只是優化了性能。

爲什麼要這麼大的版本升級來優化性能,HTTP 1.1 的性能很差麼?

那我們就來看下 HTTP 1.1 有什麼問題:

HTTP 1.1 的問題

我們知道,HTTP 的下層協議是 TCP,需要經歷三次握手才能建立連接。而 HTTP 1.0 的時候一次請求和響應結束就會斷開鏈接,這樣下次請求又要重新三次握手來建立連接。

爲了減少這種建立 TCP 鏈接的消耗,HTTP 1.1 支持了 keep-alive,只要請求或響應頭帶上 Connection: keep-alive,就可以告訴對方先不要斷開鏈接,我之後還要用這個鏈接發消息。當需要斷開的時候,再指定 Connection: close 的 header。

這樣就可以用同一個 TCP 鏈接進行多次 HTTP 請求響應了:

但這樣雖然減少了鏈接的建立,在性能上卻有問題,下次請求得等上一個請求返回響應才能發出。

這個問題有個名字,叫做隊頭阻塞,很容易理解,因爲多個請求要排隊嘛,隊前面的卡住了,那後面的也就執行不了了。

怎麼解決這個問題呢?

HTTP 1.1 提出了管道的概念,就是多個請求可以並行發送,返回響應後再依次處理。

也就是這樣:

其實這樣能部分解決問題,但是返回的響應依然要依次處理,解決不了隊頭阻塞的問題。

所以說管道化是比較雞肋的一個功能,現在絕大多數瀏覽器都默認關閉了,甚至都不支持。

那還能怎麼解決這個隊頭阻塞的問題呢?

開多個隊不就行了。

瀏覽器一般會同一個域名建立 6-8 個 TCP 鏈接,也就是 6-8 個隊,如果一個隊發生隊頭阻塞了,那就放到其他的隊裏。

這樣就緩解了隊頭阻塞問題。

我們寫的網頁想盡快的打開就要利用這一點,比如把靜態資源部署在不同的域名下。這樣每個域名都能併發 6-8 個下載請求,網頁打開的速度自然就會快很多。

這種優化手段叫做 “域名分片”,CDN 一般都支持這個。

除了隊頭阻塞的問題,HTTP 1.1 還有沒有別的問題?

有,比如 header 部分太大了。

不知道大家有沒有感覺,就算你內容只傳輸幾個字符,也得帶上一大堆 header:

而且這些 header 還都是文本的,這樣佔據的空間就格外的大。

比如,如果是二進制,表示 true 和 false 直接 1 位就行了,而文本的那就得經過編碼,“true” 就佔了 4 個字節,也就是 32 位。那就是 32 倍的差距呀!

所以呢,HTTP 1.1 的時候,我們就要儘量避免一些小請求,因爲就算請求的內容很少,也會帶上一大段 header。特別是有 cookie 的情況,問題格外明顯。

因此,我們的網頁就要做打包,也就是需要打包工具把模塊合併成多個 chunk 來加載。需要把小圖片合併成大圖片,通過調整 background:position 來使用。需要把一些 css、圖片等內聯。而且靜態資源的域名也要禁止攜帶 cookie。

這些都是爲了減少請求次數來達到提高加載性能的目的。

而且 HTTP 的底層是 TCP,其實是可以雙向傳輸數據的,現在卻只能通過請求 --- 響應這種一問一答的方式,並沒有充分利用起 TCP 的能力。

聊了這麼多,不知道大家是否有優化它的衝動了。

也就是因爲這些問題,HTTP 2.0 出現了,做了很多性能優化,基本解決了上面那些問題。

那 HTTP2 都做了哪些優化呢?

HTTP 2.0 的優化

先不着急看 HTTP 2.0 是怎麼優化的,就上面那些問題來說,如果讓我們解決,我們會怎麼解決?

比如隊頭阻塞的問題,也就是第二個響應要等第一個響應處理完之後才能處理。怎麼解決?

這個很容易解決呀,每個請求、響應都加上一個 ID,然後每個響應和通過 ID 來找到它對應的請求。各回各家,自然就不用阻塞的等待了。

再比如說 header 過大這個問題,怎麼解決?

文本傳輸太佔空間,換成二進制的是不是會好很多。

還有,每次傳輸都有很多相同的 header,能不能建立一張表,傳的時候只傳輸下標就行了。

還有,body 可以壓縮,那 header 是不是可以壓縮。

這樣處理之後,應該會好很多。

那沒有充分利用 TCP 的能力,只支持請求 -- 響應的方式呢?

那就支持服務端主動推送呀,但是客戶端可以選擇接收或者不接收。

上面是我們對這些問題的解決方案的思考,我們再來看看 HTTP2 是怎麼解決這些問題的:

HTTP2 確實是通過 ID 把請求和響應關聯起來了,它把這個概念叫做流 stream。

而且我們之前說了 header 需要單獨的優化嘛,所以把 header 和 body 部分分開來傳送,叫做不同的幀 frame。

每個幀都是這樣的格式:

payload 部分是傳輸的內容這沒啥可說的。

header 部分最開始是長度,然後是這個幀的類型,有這樣幾種類型:

這幾種幀裏面 HEADERS 和 DATA 幀沒啥可說的。

SETTING 幀是配置信息,先告訴對方我這裏支持什麼,幀大小設置爲多大等。

幀大小是有個上限的,如果幀太大了,可以分成多個,這時候幀類型就是 CONTINUATION(繼續)。也很容易理解。

HTTP2 確實是支持服務端推送的,這時候幀類型也是單獨的,叫做 PUSH_PROMISE。

流是用來傳輸請求響應或者服務端推送的,那傳輸完畢的時候就可以發送 END_STREAM 幀來表示傳輸完了,然後再傳輸 RST_STREAM 來結束當前流。

幀的類型講完了,我們繼續往後看,後面還有個 flags 標誌位,這個在不同的幀類型裏會放不同的內容:

比如 header 幀會在 flags 中設置優先級,這樣高優先級的流就可以更早的被處理。

HTTP 1.1 的時候都是排隊處理的,沒什麼優先級可言,而 HTTP 2.0 通過流的方式實現了請求的併發,那自然就可以控制優先級了。

後面還有個 R,這個現在還沒啥用,是一個保留的位。

再後面的流標識符就是 stream id 了,關聯同一個流的多個幀用的。

幀的格式講完了,大家是不是有點暈暈的。確實,幀還是有很多種的。這些幀之間發送順序也不同,不同的幀會在不同狀態下發送,也會改變流的狀態。

我們來看下流的狀態機,也就是流收到什麼幀會進入什麼狀態,並且在什麼狀態下會發送什麼幀:

(看不明白可以先往後看)

剛開始,流是 idle 狀態,也就是空閒。

收到或發送 HEADERS 幀以後會進入 open 狀態。

oepn 狀態下可以發送或接收多次 DATA 幀。

之後發送或接收 END_STREAM 幀進入 half_closed 狀態。

half_closed 狀態下收到或者發送 RST_STREAM 幀就關閉流。

這個流程很容易理解,就是先發送 HEADER,再發送 DATA,之後告訴對方結束,也就是 END_STREAM,然後關閉 RST_STREAM。

但是 HTTP2 還可以服務端推送呀,所以還有另一條狀態轉換流程。

流剛開始是 idle 狀態。

接收到 PUSH_PROMISE 幀,也就是服務端推送過來的數據,變爲 reserved 狀態。

reserved 狀態可以再發送或接收 header,之後進入 half_closed 狀態。

後面的流程是一樣的,也是 END_STREAM 和 RST_STREAM。

這個流程是 HTTP2 特有的,也就是先推送數據,再發送 headers,然後結束流。

這就是 http2 發送一次請求、響應,或者一次服務端推送的流程,都是封裝在一個個流裏面的。

流和流之間可以併發,還可以設置優先級,這樣自然就沒有了隊頭阻塞的問題,這個特性叫做多路複用。也就是複用同一個鏈接,建立起多條通路(流)的意思。

而且傳輸的 header 幀也是經過處理的,就像我們前面說的,會用二進制的方式表示,用做壓縮,而且壓縮算法是專門設計的,叫做 HPACK:

兩端會維護一個索引表,通過下標來標識 header,這樣傳輸量就少了不少:

首先,header 裏其實不止有 header,還有一行  GET xxx/xxx 的請求行,和 200 xxx 的響應行,爲了統一處理,就換成了 :host :path 等 header 來表示。

這樣發送的時候只需要發送下標就行:

比如 :method: get 就只需要發送個 2: get。

這個編碼也是根據頻率高低來設置的,頻率高的用小編碼,這種方式叫做哈夫曼編碼。

這樣就實現了 header 的壓縮。

至此, HTTP2.0 的主要特性就講完了,也就是多路複用服務端推送頭部壓縮二進制傳輸

最主要的特性是多路複用,也就是流和幀,流在什麼狀態下發送什麼幀。其他的特性是圍繞這個來設計的。

回過頭來看一下 HTTP1.1 的問題是否都得到了解決:

隊頭阻塞:通過流的來標識請求、響應,同一個流的分爲多個幀來傳輸,多個流之間可以併發,不會相互阻塞。

header 太大:通過二進制的形式,加上 HPACK 的壓縮算法,使得 header 減小了很多。

沒有充分利用 TCP 的特性:支持了服務端推送。

這樣看來,HTTP2.0 確實解決了 HTTP 1.1 的問題。

看起來,HTTP 2.0 已經很完美了?

其實不是的,雖然 HTTP 層面沒有了隊頭阻塞問題,多個請求響應可以並行處理。但是同一個流的多個幀還是有隊頭阻塞問題,以爲你 TCP 層面會保證順序處理,丟失了會重傳,這就導致了上一個幀沒收到的話,下一個幀是處理不了的。

這個問題是 TCP 的可靠傳輸的特性帶來的,所以想徹底解決隊頭阻塞問題,只能把 HTTP 的底層傳輸協議換掉了。

這就是 HTTP3 做的事情了,它的傳輸層協議換成了 UDP。當然,現在 HTTP3 還不是很成熟,我們先重點關注 HTTP2 即可。

總結

1996 年發佈 HTTP 1.0,1999 年 HTTP 1.1,2015 年 HTTP 2.0。

1.1 和 2 之間間隔了 16 年,確實改變了很多,但只是性能方面的。

1.1 的問題是第二個請求要等第一個響應之後才能發出,就算用了管道化,多個響應之間依然也會阻塞,這就是 “隊頭阻塞” 問題。

而且 header 部分太大了,還是純文本的,可能比 body 部分傳的都多。

針對 1.1 的隊頭阻塞問題,我們會做域名分片,針對 header 過大的問題,我們會減少請求次數,也就是打包分 chunk、資源內聯、雪碧圖、靜態資源請求禁止 cookie 等優化策略。

HTTP 2.0 解決了 1.1 的這些問題,通過多路複用,也就是請求和響應在一個流裏,通過同一個流 id 來關聯多個幀的方式來傳輸數據。多個流可以併發。

我們看了幀的格式,有長度、類型、stream id、falgs 還有 payload 等部分。

幀的類型還是挺多的,有 HEADRS、DATA、SETTINGS、PUSH_PROMISE、END_STREAM、EST_STREAM、等。

這些幀類型之間也不是毫無關聯的,流在不同的狀態下會發送、接收不同的幀,而且發送、接收不同的幀也會進入不同的狀態。

理解 HTTP2.0 的 stream 就要理解這樣的一個狀態流轉流程。

此外,HTTP 2.0 通過單獨設計的 HPACK 算法對 header 做了壓縮,也支持服務端推送。而且內容是通過二進制傳輸的,解決了 HTTP 1.1 的問題。

但是 HTTP 2.0 的底層是 TCP,它的可靠傳輸的特性使得同一個流內的多個幀依然是順序傳輸的,依然有隊頭阻塞問題。也是因爲 HTP 3 把底層協議換成 UDP。

雖然還是有一些問題,但 HTTP 2.0 已經基本上把 HTTP 1.1 的各方面性能不好的點都優化到了極致,是很有意義的一次版本升級。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/YZaxf9GBvh_rT8hRx1XPbA