深入理解 web 協議:http2

本篇將詳細介紹 http2 協議的方方面面,知識點如下:

一、http2 連接的建立

和許多人的固有印象不同的是 http2 協議本身並沒有規定必須建立在 tls/ssl 之上,其實用普通的 tcp 連接也可以完成 http2 連接的建立。只不過現在爲了安全市面上所有的瀏覽器都僅默認支持基於 tls/ssl 的 http2 協議。簡單來說我們可以把構建在 tcp 連接之上的 http2 協議稱之爲 h2c,而構建在 tls/ssl 協議之上的就可以理解爲是 h2 了。

輸入命令:

tcpdump -i eth0 port 80 and host nghttp2.org -w h2c.pcap &

然後用 curl 訪問基於 tcp 連接,也就是 port 80 端口的 http2 站點(這裏是沒辦法用瀏覽器訪問的,因爲瀏覽器不允許)

curl http://nghttp2.org --http2 -v

其實看日誌也可以大致瞭解一下這個連接建立的過程:

圖片

我們將 tcpdump 出來的 pcap 文件拷貝到本地,然後用 Wireshark 打開以後還原一下整個 http2 連接建立的報文:

首先是 http 1.1 升級到 http2 協議

圖片

然後客戶端還需要發送一個 “魔法幀”:

圖片

最後還需要發送一個設置幀:

圖片

之後,我們來看一下,基於 tls 的 http2 連接是如何建立的,考慮到加密等因素,我們需要提前做一些準備工作。可以在 chrome 中下載這個插件。

圖片

然後打開任意一個網頁只要看到這個閃電的圖標爲藍色就代表這個站點支持 http2;否則不支持。如下圖:

圖片

將 chrome 瀏覽器的 tls/ssl 之類的信息 輸出到一個日誌文件中,需要額外配置系統變量,如圖所示:

圖片

然後將我們的 Wireshark 中 ssl 相關的設置也進行配置。

圖片

這樣瀏覽器在進行 tls 協議交互的時候,相關的加密解密信息都會寫入到這個 log 文件中,我們的 Wireshark 就會用這個 log 文件中的信息來解密出我們的 tls 報文。

有了上述的基礎,我們就可以着手分析基於 tls 連接的 http2 協議了。比如我們訪問 tmall 的站點 https://www.tmall.com/ 然後打開我們的 Wireshark。

圖片

看一下標註的地方可以看出來,是 tls 連接建立以後 然後繼續發送魔法幀和設置幀,才代表 http2 的連接真正建立完畢。我們看一下 tls 報文的 client hello 這個信息:

圖片

其中這個 alpn 協議的信息 就代表客戶端可以接受哪兩種協議。server hello 這個消息 就明確的告知 我們要使用 h2 協議。

圖片

這也是 http2 相比 spdy 協議最重要的一個優點:spdy 協議強依賴 tls/ssl,服務器沒有任何選擇。而 http2 協議則會在客戶端發起請求的時候攜帶 alpn 這個擴展,也就是說客戶端發請求的時候會告訴服務端我支持哪些協議。從而可以讓服務端來選擇,我是否需要走 tls/ssl

二、http2 中幀和流的關係

圖片

簡單來說,http2 就是在應用層上模擬了一下傳輸層 tcp 中 “流” 的概念,從而解決了 http1.x 協議中的隊頭擁塞的問題,在 1.x 協議中,http 協議是一個個消息組成的,同一條 tcp 連接上,前面一個消息的響應沒有回來,後續的消息是不可以發送的。在 http2 中,取消了這個限制,將所謂的 “消息” 定義成“流”,流跟流之間的順序可以是錯亂的,但是流裏面的幀的順序是不可以錯亂的。如圖:

圖片

也就是說在同一條 tcp 連接上,可以同時存在多個 stream 流,這些流 由一個個 frame 幀組成,流跟流之間沒有順序關係,但是每一個流內部的幀是有先後順序的。注意看這張圖中的 135 等數字其實就是 stream id,websocket 中雖然也有幀的概念,但是因爲 websocket 中沒有 stream id,所以 websocket 是沒有多路複用的功能的。http2 因爲有了 stream id 所以就有了多路複用的能力。可以在一條 tcp 連接上存在 n 個流,就意味着服務端可以同時併發處理 n 個請求然後同時將這些請求都響應到同一條 tcp 連接上。當然這種在同一條 tcp 連接上傳送 n 個 stream 的能力也是有限制的,在 http2 連接建立的時候,setting 幀 中會包含這個設置信息。例如下圖 在訪問天貓的站點的時候,瀏覽器攜帶的 setting 幀的消息裏面就標識了 瀏覽器這個 http2 的客戶端可以支持併發最大的流爲 1000。

圖片

當天貓服務器返回這個 setting 幀的響應的時候,就告知了瀏覽器,我能支持的最大併發 stream 爲 128。

圖片

同時 我們也要知道,http2 協議中 流 id 爲單數就代表是客戶端發起的流,偶數代表服務端主動發起的流(可以理解爲服務端主動推送)。

三、 http2 中流量節省的奧祕:HPACK 算法

相比與 http1.x 協議,http2 協議還在流量消耗上做了極大改進。主要分爲三塊:靜態字典,動態字典,和哈夫曼編碼. 可以安裝如下工具探測一下 對流量節省的作用:

apt-get install nghttp2-client

然後可以探測一下一些已經開啓 http2 的站點,基本上節約的流量都是百分之 25 起,如果頻繁訪問的話 會更多:

對於流量消耗來說,其實 http2 相比 http1.x 協議最大的改進就是在 http2 中我們可以對 http 的頭部進行壓縮了,而在以往 http 1.x 協議中,gzip 等是無法對 header 進行壓縮的,尤其對於絕大多數的請求來說,其實 header 的佔比是最大的。

我們首先來了解一下靜態字典,如圖所示:

圖片

這個其實不難理解,無非就是將我們那些常用的 http 頭部,用固定的數字來表示,那當然可以起到節約流量的作用. 這裏要注意的是 有些 value 情況比較複雜的 header,他們的 value 是沒有做靜態字典的。比如 cache-control 這個緩存控制字段,這後面的值因爲太多了就無法用靜態字典來解決,而只能靠霍夫曼編碼。下圖可以表示 HPACK 這種壓縮算法 起到的節約流量的作用:

圖片

例如,我們看下 62 這個 頭部,user-agent 代指瀏覽器,一般我們請求的時候這個頭部信息都是不會變的,所以最終經過 hpack 算法優化以後 後續再傳輸的時候 就只需要傳輸 62 這個數字就可以代表其含義了。

又例如下圖:

圖片

也是一樣的,多個請求連續發送的時候,多數情況下變化的只有 path,其餘頭部信息是不變的,那麼基於此場景,最終傳輸的時候也就只有 path 這一個頭部信息了。

最後我們來看看 hpack 算法中的核心: 哈夫曼編碼。哈弗曼編碼核心思想就是出現頻率較高的用較短的編碼,出現頻率較低的用較長的編碼(http2 協議的前身 spdy 協議採用的是動態的哈夫曼編碼,而 http2 協議則選擇了靜態的哈夫曼編碼)。

圖片

來看幾個例子:

圖片

例如這個 header 幀,注意看這個 method:get 的頭部信息。因爲 method:get 在靜態索引表中的索引值爲 2. 對於這種 key 和 value 都在索引表中的值,我們用一個字節也就是 8 個 bit 來標識,其中第一個 bit 固定爲 1,剩下 7 位就用來表示索引表中的值,這裏 method:get 索引表的值爲 2,所以這個值就是 1000 0010,換算成 16 進制就是 0x82.

圖片

再看一組,key 在索引表中,value 不在索引表中的 header 例子。

圖片

對於 key 在索引表中,value 不在索引表中的情況,固定是 01 開頭的字節,後面 6 個 bit(111010 換算成十進制就是 58)就是靜態索引的值, user-agent 在索引中 index 的值是 58 再加上 01 開頭的 2 個 bit 換算成二進制就是 01111010,16 進制就 7a 了。然後接着看第二個字節,0xd4,0xd4 換算成二進制就是 1 101 0100,其中第一個 bit 代表後面採用的是哈夫曼編碼,後面的 7 個 bit 這個 key-value 的 value 需要幾個字節來表示,這裏是 101 0100 換算成 10 進制就是 84,也就是說這個 user-agent 後面的 value 需要 84 個字節來表示,我們數一下圖中的字節數 16*5 + 第一排 d4 後面的 4 個字節,剛好等於 84 個字節。

最後再看一個 key 和 value 都不在索引表中的例子。

圖片

四、http2 協議中 server push 的能力

前文我們提到過,h2 相比 h1.x 協議提升最大的就是 h2 可以在單條 tcp 連接的基礎上 同時傳輸 n 個 stream。從而避免 h1.x 協議中隊頭擁塞的問題。實際上在大部分前端的頁面中,我們還可以使用 h2 協議的 server push 能力 進一步提高頁面的加載速度。例如通常我們用瀏覽器訪問一個 html 頁面時,只有當 html 頁面返回到瀏覽器,瀏覽器內核解析到這個 html 頁面中有 CSS 或者 JS 之類的資源時,瀏覽器纔會發送對應的 CSS 或者 JS 請求,當 CSS 和 JS 回來以後 瀏覽器纔會進一步渲染,這樣的流程通常會導致瀏覽器處於一段時間內的白屏從而降低用戶體驗。有了 h2 協議以後,當瀏覽器訪問一個 html 頁面到服務器時,服務器就可以主動推送相應的 CSS 和 JS 的內容到瀏覽器,這樣就可以省略瀏覽器之後重新發送 CSS 和 JS 請求的步驟。

有些人對 Server Push 存在一定程度上的誤解,認爲這種技術能夠讓服務器向瀏覽器發送 “通知”,甚至將其與 WebSocket 進行比較。事實並非如此,Server Push 只是省去了瀏覽器發送請求的過程。只有當“如果不推送這個資源,瀏覽器就會請求這個資源” 的時候,瀏覽器纔會使用推送過來的內容。否則如果瀏覽器本身就不會請求某個資源,那麼推送這個資源只會白白消耗帶寬。當然如果與服務器通信的是客戶端而不是瀏覽器,那麼 http2 協議自然就可以完成 push 推送的功能了。所以都使用 http2 協議的情況下,與服務器通信的是客戶端還是瀏覽器 在功能上還是有一定區別的。

圖片

下面爲了演示這個過程,我們寫一段代碼。考慮到瀏覽器訪問 http2 站點必須要建立在 tls 連接之上,我們首先要生成對應的證書和祕鑰。

圖片

然後開啓 http2,在接收到 html 請求的時候主動 push html 中引用的 CSS 文件。

package main
import (
    "fmt"
    "net/http"
    "github.com/labstack/echo"
)
func main() {
    e := echo.New()
    e.Static("/", "html")
    //主要用來驗證是否成功開啓http2環境
    e.GET("/request", func(c echo.Context) error {
        req := c.Request()
        format := `
          <code>
            Protocol: %s<br>
            Host: %s<br>
            Remote Address: %s<br>
            Method: %s<br>
            Path: %s<br>
          </code>
        `
        return c.HTML(http.StatusOK, fmt.Sprintf(format, req.Proto, req.Host, req.RemoteAddr, req.Method, req.URL.Path))
    })
    //在收到html請求的時候 同時主動push html中引用的css文件,不需要等待瀏覽器發起請求
    e.GET("/h2.html", func(c echo.Context) (err error) {
        pusher, ok := c.Response().Writer.(http.Pusher)
        if ok {
            if err = pusher.Push("/app.css", nil); err != nil {
                println("error push")
                return
            }
        }
        return c.File("html/h2.html")
    })
    // 
    e.StartTLS(":1323", "cert.pem", "key.pem")
}

然後 Chrome 訪問這個網頁的時候,看下 network 面板:

圖片

可以看出來這個 CSS 文件 就是我們主動 push 過來的。再看下 Wireshark。

圖片

可以看出來 stream id 爲 13 的 是客戶端發起的請求,因爲 id 是單數的,在這個 stream 中,還存在着 push_promise 幀,這個幀就是由服務器發送給瀏覽器的,看一下他的具體內容。

圖片

可以看出來這個幀就是用來告訴瀏覽器,我主動 push 給你的是哪個資源,這個資源的 stream-id 是 6. 圖中我們也看到了有一個 stream-id 爲 6 的  data 在傳輸了,這個就是服務器主動 push 出來的 CSS 文件。到這裏,一次完整的 server push 就交互完畢了。

但在實際線上應用 server push 的時候 挑戰遠遠比我們這個 demo 中來的複雜。首先就是大部分 cdn 供應商 (除非自建 cdn) 對 server push 的支持比較有限。我們不可能讓每一次資源的請求都直接打到我們的源服務器上,大部分靜態資源都是前置在 cdn 中。其次,對於靜態資源來說,我們還要考慮緩存的影響,如果是瀏覽器自己發出去的靜態資源請求,瀏覽器是可以根據緩存狀態來決定這個資源我是否真的需要去請求,而 server push 是服務器主動發起的,服務器多數情況下是不知道這個資源的緩存是否過期的。當然可以在瀏覽器接收到 push promise 幀以後,查詢自身的緩存狀態然後發起 RST_STREAM 幀,告知服務器這個資源我有緩存,不需要繼續發送了,但是你沒辦法保證這個 RST_STREAM 在到達服務器的時候,服務器主動 push 出去的 data 幀還沒發出去。所以還是會存在一定的帶寬浪費的現象。總體來說,server push 還是一個提高前端用戶體驗相當有效的手段,使用了 server push 以後 瀏覽器的性能指標 idle 指標 一般可以提高 3-5 倍(畢竟瀏覽器不用等待解析 html 以後再去請求 CSS 和 JS 了)。

五、http2 爲什麼要實現流量控制?

很多人不理解,爲什麼 tcp 傳輸層已經實現了流量控制,我們的應用層 http2 還要實現流量控制。下面我們看一張圖。

圖片

在 http2 協議中,因爲我們支持多路複用,也就是說我們可以同時發送多個 stream 在同一條 tcp 連接中,上圖中,每一種顏色就代表一個 stream,可以看到 我們總共有 4 種 stream,每一個 stream 又有 n 個 frame,這個就很危險了,假設在應用層中我們使用了多路複用,就會出現 n 個 frame 同時不停的發送到目標服務器中,此時流量達到頂峯就會觸發 tcp 的擁塞控制,從而將後續的 frame 全部阻塞住,造成服務器響應過慢了。http1.x 中因爲不支持多路複用自然就不存在這個問題。且我們之前多次提到過,一個請求從客戶端到達服務器端要經過很多的代理服務器,這些代理服務器內存大小以及網絡情況都可能不一樣,所以在應用層上做一次流量控制儘量避開觸發 tcp 的流控是十分有必要的。在 http2 協議中的流量控制策略,遵循以下幾個原則:

  1. 客戶端和服務端都有流量控制能力。

  2. 發送端和接收端可以獨立設置流控能力。

  3. 只有 data 幀才需要流控,其他 header 幀或者 push promise 幀等都不需要。

  4. 流控能力只針對 tcp 連接的兩端,中間即使有代理服務器,也不會透傳到源服務器上。

訪問知乎的站點看一下抓包。

圖片

這些標識 window_update 幀的 就是所謂的流控幀了。我們隨意點開一個看一下,就可以看到這個流量控制幀告訴我們的幀大小。

圖片

聰明如你一定能想到,既然 http2 都能做到流控了,那一定也可以來做優先級。比方說在 http1.x 協議中,我們訪問一個 html 頁面,裏面會有 JS 和 CSS 還有圖片等資源,我們同時發送這些請求,但是這些請求並沒有優先級的概念,誰先出去誰先回來都是未知的 (因爲你也不知道這些 CSS 和 JS 請求是不是在同一條 tcp 連接上,既然是分散在不同的 tcp 中,那麼哪個快哪個慢是不確定的),但是從用戶體驗的角度來說,肯定 CSS 的優先級最高,然後是 JS,最後纔是圖片,這樣就可以大大縮小瀏覽器白屏的時間。在 http2 中 實現了這樣的能力。比如我們訪問 sina 的站點,然後抓包就可以看到:

可以看下這個 CSS 幀的的優先級:

圖片

JS 的優先級

圖片

最後是 gif 圖片的優先級 ,可以看出來這個優先級是最低的。

圖片

有了 weight 這個關鍵字來標識優先級,服務器就知道哪些請求需要優先被響應優先被髮送 response,哪些請求可以後一點被髮送。這樣瀏覽器在整體上提供給用戶的體驗就會變的更好。

六、http2 協議遇到的問題

基於 tcp 或者 tcp+tls 的 http2 協議 還是遇到了很多問題,比如:握手時間過長問題,如果是基於 tcp 的 http2 協議,那麼至少要三次握手,如果是 tcp+tls 的 http2 協議,除了 tcp 的握手還要經歷 tls 的多次握手(tls1.3 已經可以做到只有 1 次握手)。每一次握手都需要發送一個報文然後接收到這個報文的 ack 纔可以進行下一次握手,在弱網環境下可以想象的到這個連接建立的效率是極低的。此外,tcp 協議天生的隊頭擁塞 問題也一直在困擾着 http1.x 協議和 http2 協議。我們看一下谷歌 spdy 的宣傳圖, 可以更加精準的理解這個擁塞的本質:

圖片

圖一很好理解,我們多路複用支持下同時發了 3 個 stream,然後經過 tcp/ip 協議 發送到服務器端,然後 tcp 協議把這些數據包再傳給我們的應用層,注意這裏有個條件是,發送包的順序要和接收包的順序一致。上圖中可以看到那些方塊的圖的順序是一致的,但是如果碰到下圖中的情況,比如說這些數據包恰好第一個紅色的數據包傳丟了,那麼後續的數據包即使已經到了服務器的機器裏,也無法立刻將數據傳遞給我們的應用層協議,因爲 tcp 協議規定好了接收的順序要和發送的順序保持一致,既然紅色的數據包丟失了,那麼後續的數據包就只能阻塞在服務器裏,一直等到紅色的數據包經過重新發送以後成功到達服務器了,再將這些數據包傳遞給應用層協議。

tcp 協議除了有上述的一些缺陷以外,還有一個問題就是 tcp 協議的實現者是在操作系統層面,我們任何語言,包括 java,c,c++,go 等等 對外暴露的所謂 socket 編程接口 最終實現者其實都是操作系統自己。要讓操作系統自己升級 tcp 協議的實現是非常非常困難的,況且整個互聯網中那麼多設備想要整體實現 tcp 協議的升級是一件不現實的事情(ipv6 協議升級的過慢也有這方面的原因)。基於上述問題,谷歌就基於 udp 協議封裝了一層 quic 協議(其實很多基於 udp 協議的應用層協議,都是在應用層上部分實現了 tcp 協議的若干功能),來替代 http1.x-http2 中的 tcp 協議。

我們打開 chrome 中的 quic 協議開關:

圖片

然後訪問一下 youtube(國內的 b 站其實也支持)。

圖片

可以看出來已經支持 quic 協議了。爲什麼這個選項在 chrome 瀏覽器中默認是關閉的,其實也很好理解,這個 quic 協議實際上是谷歌自己搞出來的,還沒有被正式納入到 http3 協議中,一切都還在草案中。所以這個選項默認是關閉的。看下 quic 協議相比於原來的 tcp 協議主要做了哪些改進?其實就是將原來隊列傳輸報文改成了無需隊列傳輸,那自然也就不存在隊頭擁塞的問題了。

圖片

此外在 http3 中還提供了 變更端口號或者 ip 地址也可以複用之前連接的能力,個人理解這個協議支持的特性可能更多是爲了物聯網考慮的。物聯網中很多設備的 ip 都可能是一直變化的。能複用之前的連接將會大大提高網絡傳輸的效率。這樣就可以避免目前存在的斷網以後重新連接到網絡需要至少經過 1-3 個 rtt 纔可以繼續傳輸數據的弊端。

圖片

最後要提一下,在極端弱網環境中,http2 的表現有可能不如 http1.x,因爲 http2 下面只有一條 tcp 連接,弱網下,如果丟包率極高,那麼會不斷的觸發 tcp 層面的超時重傳,造成 tcp 報文的積壓,遲遲無法將報文傳遞給上面的應用層,但是 http1.x 中,因爲可以使用多條 tcp 連接,所以在一定程度上,報文積壓的情況不會像 http2 那麼嚴重,這也是我認爲的 http2 協議唯一不如 http1.x 的地方,當然這個鍋是 tcp 的,並不是 http2 本身的。

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