深入理解 Web 協議 (三):HTTP 2

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

  • HTTP 2 連接的建立

  • HTTP 2 中幀和流的關係

  • HTTP 2 中流量節省的奧祕:HPACK 算法

  • HTTP 2 協議中 Server Push 的能力

  • HTTP 2 爲什麼要實現流量控制?

  • HTTP 2 協議遇到的問題

一、HTTP 2 連接的建立

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

輸入命令:

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

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

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

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

 

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

首先是 HTTP 1.1 升級到 HTTP 2 協議

 

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

 

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

 

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

 

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

 

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

 

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

 

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

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



 

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



 



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

 

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

二、HTTP 2 中幀和流的關係

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

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

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

 

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

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

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

apt-get install nghttp2-client

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

 

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

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

 

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

 

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

又例如下圖:

 

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

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

 

來看幾個例子:

 

例如這個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 都不在索引表中的例子。

 

四、HTTP 2 協議中 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只是省去了瀏覽器發送請求的過程。只有當“如果不推送這個資源,瀏覽器就會請求這個資源”的時候,瀏覽器纔會使用推送過來的內容。否則如果瀏覽器本身就不會請求某個資源,那麼推送這個資源只會白白消耗帶寬。當然如果與服務器通信的是客戶端而不是瀏覽器,那麼HTTP 2協議自然就可以完成 push推送的功能了。所以都使用HTTP 2協議的情況下,與服務器通信的是客戶端還是瀏覽器 在功能上還是有一定區別的。

 

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

 

然後開啓HTTP 2,在接收到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了)。

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

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

 

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

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

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

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

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

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

 

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

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

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

JS的優先級

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

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

六、HTTP 2 協議遇到的問題

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

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

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

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

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

 

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

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

 

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

更多閱讀:

作者:vivo 互聯網-WuYue

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章