當我談 HTTP 時,我談些什麼?

又拍雲發表於2020-07-15

當我們開啟網站時也許不會去留意網站前面的HTTP是怎麼來的。但是它毫無疑問在網路中有著舉足輕重的地位。本文從起源到發展,詳說HTTP從1到3的演變。

說在前面

本文不致力於講完 HTTP 的全部內容,事實上短短的篇幅也不可能講完。本文也無意於深挖 HTTP 中的某一點,這是像 《HTTP 權威指南》或者是 RFC 協議做的事。

本文目標是幫助讀者理清 HTTP 的演化過程,說說 HTTP 變化的那些事。

HTTP 的起源

HTTP 最初是 Tim BernersLee 1989 年在歐洲核子研究組織(CERN)所發起的。Tim BernersLee 提出了一種能讓遠隔兩地的研究者們共享知識的設想。這個設想的基本理念是:藉助多文件之間相互關聯形成的超文字(HyperText),連成可相互參閱的 WWW(World Wide Web,全球資訊網)。用於傳輸的超文字傳輸協議(HyperText Transfer Protocol),即 HTTP 由此誕生。

WWW 這一名稱,是 Web 瀏覽器當年用來瀏覽超文字的客戶端應用程式時的名稱。現在則用來表示這一系列的集合,也可簡稱為 Web。

HTTP 本身是一個簡單的請求-響應協議,它通常執行在 TCP 之上。從整個網路模型來看,HTTP 是應用層的一個協議。在 OSI 七層模型中,HTTP 位於最上層。它並不涉及資料包的傳輸,只是規定了客戶端和伺服器之間的通訊格式。定了客戶端可能傳送給伺服器什麼樣的訊息以及得到什麼樣的響應。請求和響應訊息的頭以 ASCII 碼形式給出。

HTTP 採用 BS 架構,也就是瀏覽器到伺服器的架構,客戶端通過瀏覽器傳送 HTTP 請求給伺服器,伺服器經過解析響應客戶端的請求。就是這個簡單實用的模型,使得 HTTP 這個基於 TCP/IP 的協議迅速推廣。

HTTP/0.9 到 HTTP/1.1

HTTP 的演化並不是一蹴而就的。當年 HTTP 的出現主要是為了解決文字傳輸的難題。由於協議本身非常簡單,於是在此基礎上設想了很多應用方法並投入了實際使用。現在 HTTP 已經超出了 Web 這個框架的侷限,被運用到了各種場景裡。

HTTP/0.9

HTTP 協議最早的一個版本是 1990 年釋出的 HTTP/0.9。

前面說到,HTTP 於 1989 年問世。那時的 HTTP 並沒有作為正式的標準被建立。這時的 HTTP 其實含有 HTTP/1.0 之前版本的意思,因此被稱為 HTTP/0.9。這個版本只有一個命令:GET。通過 GET 可以獲取伺服器的資源,比如請求伺服器根目錄下的 index.html 檔案。這個版本的協議規定,伺服器只能回應 HTML 格式的字串,不能回應其它格式,也就是說影像、視訊等多媒體資源,在 HTTP/0.9 這個版本上是無法進行傳輸的。

HTTP/1.0

HTTP 正式作為標準被公佈是在 1996 年的 5 月,版本被命名為 HTTP/1.0,並記載於 RFC1945 [https://www.ietf.org/rfc/rfc1945.txt]。雖說是初期標準,但該協議標準至今仍被廣泛使用在伺服器端。

HTTP/1.0 版本釋出,增加了 POST 命令和 HEAD 命令,豐富了瀏覽器與伺服器的互動手段。這個版本的 HTTP 協議可以傳送任何格式的內容,包括傳輸文字、影像、視訊、檔案等,這為網際網路的大發展奠定了基礎。

HTTP/1.0 除了增加了請求方法以及對傳送檔案的支援之外,還增加了格式的改變。除了資料部分,每次通訊都必須包括頭資訊(HTTP header),用來描述一些後設資料。另外還增加了狀態碼、多字符集支援、多部分傳送(multi-part type)、許可權(authorization)、快取(cache)、內容編碼(content encoding)等等。

HTTP/1.1

HTTP/1.0 版也並不是完美的,它的主要缺點是,每一次建立 TCP 連線只能傳送一個請求。傳送資料完畢,連線就關閉,如果還要請求其他資源,就必須再新建一個連線。如果多次請求,勢必就會對伺服器產生較大的資源效能損耗。

1997 年 1 月公佈的 HTTP/1.1 是目前主流的 HTTP 協議版本。當初的標準是 RFC2068,之後釋出的修訂版 RFC2616 就是當前的最新版本。

其中最著名的是 1999 年 6 月公佈的 RFC 2616 [https://tools.ietf.org/html/rfc2616],定義了 HTTP 協議中現今廣泛使用的一個版本——HTTP/1.1。

這個版本最大的變化就是將持久化連線加入了 HTTP 標準,即 TCP 連線預設不關閉,可以被多個請求複用。此外,HTTP/1.1 版還新增了許多方法,例如:PUT、PATCH、HEAD、OPTIONS、DELETE。得到進一步完善的HTTP/1.1 版本,一直沿用至今。

HTTP 協議簡單介紹

請求

客戶端傳送一個 HTTP 請求到伺服器,請求訊息包括以下格式:

請求行(request line)、請求頭部(header)、空行和請求資料四個部分組成。

Get 請求例子

1 > GET / HTTP/1.1
2 > Host: www.baidu.com
3 > User-Agent: curl/7.52.1
4 > Accept: /

第一部分:請求行,用來說明請求型別,要訪問的資源以及所使用的 HTTP 版本。

第二部分:請求頭部,緊接著請求行(即第一行)之後的部分,用來說明伺服器要使用的附加資訊

從第二行起為請求頭部,HOST 將指出請求的目的地。User-Agent,伺服器端和客戶端指令碼都能訪問它,它是瀏覽器型別檢測邏輯的重要基礎。該資訊由你的瀏覽器來定義,並且在每個請求中自動傳送等等。

第三部分:空行,請求頭部後面的空行是必須的

即使第四部分的請求資料為空,也必須有空行。

第四部分:請求資料也叫主體,可以新增任意的其他資料。

這個例子的請求資料為空。

響應訊息

一般情況下,伺服器接收並處理客戶端發過來的請求後,會返回一個 HTTP 的響應訊息。

HTTP 響應也由四個部分組成,分別是:狀態行、訊息報頭、空行和響應正文。

例子

1 < HTTP/1.1 200 OK
2 < Accept-Ranges: bytes
3 < Cache-Control: private, no-cache, no-store, proxy-revalidate, no-transform
4 < Connection: keep-alive
5 < Content-Length: 2381
6 < Content-Type: text/html
7 < Date: Thu, 11 Jun 2020 16:04:33 GMT
8 < Etag: "588604c8-94d"
9 < Last-Modified: Mon, 23 Jan 2017 13:27:36 GMT
10 < Pragma: no-cache
11 < Server: bfe/1.0.8.18
12 < Set-Cookie: BDORZ=27315; max-age=86400; domain=.baidu.com; path=/
13 <
14 < !DOCTYPE html>
15

<meta HTTP-equiv=content-type content=text/html;charset=utf-8><meta HTTP-equiv=X-UA-Compatible content=IE=Edge>...
16

第一部分:狀態行,由 HTTP 協議版本號、狀態碼、狀態訊息三部分組成。

第一行為狀態行,(HTTP/1.1)表明 HTTP 版本為 1.1 版本,狀態碼為 200,狀態訊息為(ok)

第二部分:訊息報頭,用來說明客戶端要使用的一些附加資訊

第二行和第三行為訊息報頭。

Date:生成響應的日期和時間;Content-Type:指定了 MIME 型別的 HTML(text/html),編碼型別是 UTF-8

第三部分:空行,訊息報頭後面的空行是必須的

第四部分:響應正文,伺服器返回給客戶端的文字資訊。

空行後面的 HTML 部分為響應正文。

狀態碼

狀態程式碼有三位數字組成,第一個數字定義了響應的類別,共分五種類別:

  • 1xx:指示資訊–表示請求已接收,繼續處理

  • 2xx:成功–表示請求已被成功接收、理解、接受

  • 3xx:重定向–要完成請求必須進行更進一步的操作

  • 4xx:客戶端錯誤–請求有語法錯誤或請求無法實現

  • 5xx:伺服器端錯誤–伺服器未能實現合法的請求

安全性與 HTTPS

HTTP 的誕生是為了解決資訊傳遞和共享的問題,並沒有考慮到網際網路高速發展後面臨的安全問題。

一般來說 HTTP 從 TCP 三次握手後,便開始了資料傳輸。由於 HTTP 本身以明文形式來傳輸資料,並不具備任何資料加密、身份校驗的機制。同時下層協議並不對資料安全性、保密性提供保證。所以在網路傳輸的過程中,任意節點的第三方都可以隨意劫持流量、篡改資料或竊取資訊。

HTTP 無法確保資料的保密性、完整性和真實性,已經不能適應現代網際網路應用的安全需求。

隨著 Web 的日益壯大,HTTP 的使用呈鉅額增長趨勢,對資訊保安的需求也愈來愈迫切,SSL(Secure SocketsLayer ,安全套接層)應運而生。

當對於安全需求,首先想到的就是對資訊進行加密。SSL ,安全套接層,顧名思義是在 TCP 上提供的安全套接字層。其位於應用層和傳輸層之間,應用層資料不再直接傳遞給傳輸層而是傳遞給 SSL 層,SSL 層對從應用層收到的資料進行加密,利用資料加密、身份驗證和訊息完整性驗證機制,為網路上資料的傳輸提供安全性保證。HTTPS 便是指 Hyper Text Transfer Protocol over SecureSocket Layer。

談到具體實施上,業內通常採用的一般有對稱加密和非對稱加密。採用何種方式進行加密?如何判斷伺服器未被篡改?如何傳遞加密金鑰?帶著這樣的問題,我們來看看 HTTPS 的工作流程。

1、客戶端發起 HTTPS 請求

這個沒什麼好說的,就是使用者在瀏覽器裡輸入一個 HTTPS 網址,然後連線到 server 的 443 埠。

2、服務端的配置

採用 HTTPS 協議的伺服器必須要有一套數字證書,可以自己製作,也可以向組織申請,區別就是自己頒發的證書需要客戶端驗證通過,才可以繼續訪問,而使用受信任的公司申請的證書則不會彈出提示頁面(Let‘s Encrypt 就是個不錯的選擇,免費的 SSL 證書)。

這套證書其實就是一對公鑰和私鑰,如果對公鑰和私鑰不太理解,可以想象成一把鑰匙和一個鎖頭,只是全世界只有你一個人有這把鑰匙,你可以把鎖頭給別人,別人可以用這個鎖把重要的東西鎖起來,然後發給你,因為只有你一個人有這把鑰匙,所以只有你才能看到被這把鎖鎖起來的東西。

3、傳送證書

這個證書其實就是公鑰,只是包含了很多資訊,如證書的頒發機構,過期時間等等。

4、客戶端解析證書

這部分工作是有客戶端的 TLS 來完成的,首先會驗證公鑰是否有效,比如頒發機構,過期時間等等,如果發現異常,則會彈出一個警告框,提示證書存在問題。

如果證書沒有問題,那麼就生成一個隨機值,然後用證書對該隨機值進行加密,就好像上面說的,把隨機值用鎖頭鎖起來,這樣除非有鑰匙,不然看不到被鎖住的內容。

5、傳送加密資訊

這部分傳送的是用證書加密後的隨機值,目的就是讓服務端得到這個隨機值,以後客戶端和服務端的通訊就可以通過這個隨機值來進行加密解密了。

6、服務段解密資訊

服務端用私鑰解密後,得到了客戶端傳過來的隨機值(私鑰),然後把內容通過該值進行對稱加密,所謂對稱加密就是,將資訊和私鑰通過某種演算法混合在一起,這樣除非知道私鑰,不然無法獲取內容,而正好客戶端和服務端都知道這個私鑰,所以只要加密演算法夠彪悍,私鑰夠複雜,資料就夠安全。

7、傳輸加密後的資訊

這部分資訊是服務段用私鑰加密後的資訊,可以在客戶端被還原。

8、客戶端解密資訊

客戶端用之前生成的私鑰解密服務段傳過來的資訊,於是獲取瞭解密後的內容,整個過程第三方即使監聽到了資料,也束手無策。

簡單說完了 HTTPS 的工作流程。讓我們再將注意力放在 SSL 的演化上。

1994年,Netscape 建立了 SSL 協議的原始規範並逐步釋出協議改進版本。1995 年釋出 SSL 2.0。1996年,Netscape 和 Paul Kocher 共同設計釋出 SSL 3.0 協議,獲得網際網路廣泛認可和支援。因特網工程任務組(IETF)接手負責該協議,並將其重新命名為 TLS(傳輸層安全)協議。

我們看到,SSL 2.0 規範是在 1995 年左右釋出的,而 SSL 3.0 是在 1996 年 11 月釋出的。有趣的是,SSL 3.0 是在 RFC 6101 [https://tools.ietf.org/html/rfc6101] 中描述的,該 RFC 於 2011 年 8 月釋出。它位於歷史類別中,該類別通常是被考慮和被丟棄的文件想法,或者是在決定記錄它們時已經具有歷史意義的協議(根據 IETF [https://www.ietf.org/about/groups/iesg/statements/] 說明)。在這種情況下,有一個描述 SSL 3.0 的 IETF 文件是很有必要的,因為在其可以被用作規範參考。

再來看看,SSL 是如何激發 TLS 的發展的。後者在 1996 年 11 月以 draft-ietf-tls-protocol-00 [https://tools.ietf.org/html/draft-ietf-tls-protocol-00] 宣告開始。它經歷了六個草案版本,並於 1999 年初作為 RFC 2246 [https://tools.ietf.org/html/rfc2246] - TLS 1.0 正式釋出。

在 1995 和 1999 年間,SSL 和 TLS 協議用於保護網際網路上的 HTTP 通訊。這作為事實上的標準執行良好。直到 1998 年 1 月,隨著 I-D draft-ietf-tls-HTTPs-00 [https://tools.ietf.org/html/draft-ietf-tls-HTTPs-00] 的釋出,HTTPS 的正式標準化過程才開始。該工作於 2000 年 5 月以 RFC 2616 - HTTP 上的 TLS 的釋出結束。

TLS 在 2000 到 2007 年間繼續發展,標準化為 TLS 1.1 和 1.2。直至七年後,TLS 的下一個版本開始進行,該版本在 2014 年四月被採納為 draft-ietf-tls-tls13-00 [https://tools.ietf.org/html/draft-ietf-tls-tls13-00],並在 28 份草稿後,於 2018 年八月出了完成版本 RFC 8446 [https://tools.ietf.org/html/rfc8446] - TLS 1.3。

改進與 HTTP2

回到 HTTP 本身。在很長一段時間裡,HTTP/1.1 已經足夠好了(確實是,現在仍應用最為廣泛),但是,Web 不斷變化的需求使得我們需要一個更好更合適的協議。

HTTP/1.1 自從 1997 年釋出以來,我們已經使用 HTTP/1.x 相當長一段時間了。但隨著網際網路近十年爆炸式的發展,從當初網頁內容以文字為主,到現在以富媒體(如圖片、聲音、視訊)為主,而且對頁面內容實時性高要求的應用越來越多(比如聊天、視訊直播),所以當時協議規定的某些特性,已經逐漸無法滿足現代網路的需求了。

如果你有仔細觀察,那些最流行的網站首頁所需要下載資源的話,會發現一個非常明顯的趨勢。近年來載入網站首頁需要下載的資料量在逐漸增加,並已經超過了 2100K。但在這裡我們更關心的是:平均每個頁面為了完成顯示與渲染所需要下載的資源數也已經超過了 100 個。

基於此,在 2010 年到 2015 年,谷歌通過實踐一個實驗性的 SPDY 協議,證明了一個在客戶端和伺服器端交換資料的另類方式。其收集了瀏覽器和伺服器端的開發者的焦點問題,明確了響應數量的增加和解決複雜的資料傳輸。在啟動 SPDY 這個專案時預設的目標是:

  • 頁面載入時間 (PLT) 減少 50%。

  • 無需網站作者修改任何內容。

  • 將部署複雜性降至最低,無需變更網路基礎設施。

  • 與開源社群合作開發這個新協議。

  • 收集真實效能資料,驗證這個實驗性協議是否有效。為了達到降低目標,減少頁面載入時間的目標,SPDY 引入了一個新的二進位制分幀資料層,以實現多向請求和響應、優先次序、最小化及消除不必要的網路延遲,目的是更有效地利用底層 TCP 連線。

HTTP/1.1 有兩個主要的缺點:安全不足和效能不高,由於揹負著 HTTP/1.x 龐大的歷史包袱,所以協議的修改,相容性是首要考慮的目標,否則就會破壞網際網路上無數現有的資產。

而如上圖所示,SPDY 位於 HTTP 之下,TCP 和 SSL 之上,這樣可以輕鬆相容老版本的 HTTP 協議同時可以使用已有的 SSL 功能。

SPDY 協議在 Chrome 瀏覽器上證明可行以後,就被當作 HTTP/2 的基礎,主要特性都在 HTTP/2 之中得到繼承。

於是時間來到 2015 年,HTTP/2.0 問世。

HTTP/2 相比 HTTP/1.1 的修改並不會破壞現有程式的工作,但是新的程式可以藉由新特性得到更好的速度。

HTTP/2 保留了 HTTP/1.1 的大部分語義,例如請求方法、狀態碼、乃至 URI 和絕大多數 HTTP 頭部欄位一致。而 HTTP/2 採用了新的方法來編碼、傳輸客戶端和伺服器間的資料。

來看看 HTTP/2 的具體特點:

  • 二進位制分幀層:在應用層與傳輸層之間增加一個二進位制分幀層,以此達到在不改動 HTTP 的語義,HTTP 方法、狀態碼、URI 及首部欄位的情況下,突破 HTTP/1.1 的效能限制,改進傳輸效能,實現低延遲和高吞吐量。在二進位制分幀層上,HTTP/2.0 會將所有傳輸的資訊分割為更小的訊息和幀,並對它們採用二進位制格式的編碼,其中 HTTP1.x 的首部資訊會被封裝到 Headers 幀,而我們的 request body 則封裝到 Data 幀裡面。

  • 多路複用:對於 HTTP/1.x,即使開啟了長連線,請求的傳送也是序列傳送的,在頻寬足夠的情況下,對頻寬的利用率不夠,HTTP/2.0 採用了多路複用的方式,可以並行傳送多個請求,提高對頻寬的利用率。

  • 資料流優先順序:由於請求可以併發傳送了,那麼如果出現了瀏覽器在等待關鍵的 CSS 或者 JS 檔案完成對頁面的渲染時,伺服器卻在專注的傳送圖片資源的情況怎麼辦呢?HTTP/2.0 對資料流可以設定優先值,這個優先值決定了客戶端和服務端處理不同的流採用不同的優先順序策略。

  • 服務端推送:在 HTTP/2.0 中,伺服器可以向客戶傳送請求之外的內容,比如正在請求一個頁面時,伺服器會把頁面相關的 logo,CSS 等檔案直接推送到客戶端,而不會等到請求來的時候再傳送,因為伺服器認為客戶端會用到這些東西。這相當於在一個 HTML 文件內集合了所有的資源。

  • 頭部壓縮:使用首部表來跟蹤和儲存之前傳送的鍵值對,對於相同的內容,不會再每次請求和響應時傳送。

  • HTTP/2.0 支援明文 HTTP 傳輸,而 SPDY 強制使用 HTTPS。

  • HTTP/2.0 訊息頭的壓縮演算法採用 HPACK,而非 SPDY 採用的 DEFLATE。

QUIC 和 HTTP3

雖然 HTTP/2 提高了網頁的效能,但是並不代表它已經是完美的了,HTTP/3 就是為了解決 HTTP/2 所存在的一些問題而被推出來的。隨著時間的演進,越來越多的流量都往手機端移動,手機的網路環境會遇到的問題像是封包丟失機率較高、較長的 Round Trip Time (RTT)和連線遷移等問題,都讓主要是為了有線網路設計的HTTP/TCP協議遇到貧頸。

我們可以看兩個典型的問題。

第一握手帶來的消耗。HTTP/2 使用 TCP 協議來傳輸的,而如果使用 HTTPS 的話,還需要使用 TLS 協議進行安全傳輸,而使用 TLS 也需要一個握手過程,這樣就需要有兩個握手延遲過程:

  • 在建立 TCP 連線的時候,需要和伺服器進行三次握手來確認連線成功,也就是說需要在消耗完 1.5 個 RTT 之後才能進行資料傳輸。

  • 進行 TLS 連線,TLS 有兩個版本——TLS 1.2 和 TLS 1.3,每個版本建立連線所花的時間不同,大致是需要1~2個 RTT。

總之,在傳輸資料之前,我們需要花掉 3~4 個 RTT。

第二,TCP 的隊頭阻塞並沒有得到徹底解決。我們知道,為了實現多路複用,在 HTTP/2 中多個請求是跑在一個 TCP 管道中的。但當出現了丟包時,HTTP/2 的表現反倒不如 HTTP/1.X 了。因為 TCP 為了保證可靠傳輸,有個特別的丟包重傳機制,丟失的包必須要等待重新傳輸確認,HTTP/2 出現丟包時,整個 TCP 都要開始等待重傳,那麼就會阻塞該 TCP 連線中的所有請求。而對於 HTTP/1.1 來說,可以開啟多個 TCP 連線,出現這種情況反到只會影響其中一個連線,剩餘的 TCP 連線還可以正常傳輸資料。

至此,我們很容易就會想到,為什麼不直接去修改 TCP 協議?其實這已經是一件不可能完成的任務了。因為 TCP 存在的時間實在太長,已經充斥在各種裝置中,並且這個協議是由作業系統實現的,更新起來非常麻煩,不具備顯示操作性。

HTTP/3 乘著 QUIC 來了。

HTTP3 是基於 QUIC 的協議,如上圖。先說 QUIC,QUIC 協議是 Google 提出的一套開源協議,它基於 UDP 來實現,直接競爭對手是 TCP 協議。QUIC 協議的效能非常好,甚至在某些場景下可以實現 0-RTT 的加密通訊。

在 Google 關於 QUIC [https://docs.google.com/document/d/1gY9-YNDNAB1eip-RTPbqphgySwSNSDHLq9D5Bty4FSU/edit] 的檔案中提到,與 HTTP/2 相比,QUIC 主要具有下列優勢:

  • Reduce connection establishment latency (減少連線建立時間)

  • Improved congestion control (改進擁塞控制)

  • Multiplexing without head-of-line blocking (沒有隊頭阻塞的多路複用)

  • Forward error correction (修復之前的錯誤)

  • Connection migration(支援網路遷移)

多路複用,避免隊頭阻塞

這句話說起來很容易,但理解起來並不那麼顯然,要想理解 QUIC 協議到底做了什麼以及這麼做的必要性,我想還是從最基礎的 HTTP/1.0 聊起比較合適。

Pipiline

根據谷歌的調查, 現在請求一個網頁,平均涉及到 80 個資源,30 多個域名。考慮最原始的情況,每請求一個資源都需要建立一次 TCP 請求,顯然不可接受。HTTP 協議規定了一個欄位 Connection,不過預設的值是 close,也就是不開啟。

早在 1999 年提出的 HTTP 1.1 [https://www.ietf.org/rfc/rfc2616.txt] 協議 中就把 Connection 的預設值改成了Keep-Alive,這樣同一個域名下的多個 HTTP 請求就可以複用同一個 TCP 連線。這種做法被稱為 HTTP Pipeline,優點是顯著的減少了建立連線的次數,也就是大幅度減少了 RTT。

以上面的資料為例,如果 80 個資源都要走一次 HTTP 1.0,那麼需要建立 80 個 TCP 連線,握手 80 次,也就是 80 個 RTT。如果採用了 HTTP 1.1 的 Pipeline,只需要建立 30 個 TCP 連線,也就是 30 個 RTT,提高了 62.5% 的效率。

Pipeline 解決了 TCP 連線浪費的問題,但它自己還存在一些不足之處,也就是所有管道模型都難以避免的隊頭阻塞問題。

隊頭阻塞

我們再舉個簡單而且直觀的例子,假設載入一個 HTML 一共要請求 10 個資源,那麼請求的總時間是每一個資源請求時間的總和。最直觀的體驗就是,網速越快請求時間越短。然而如果某一個資源的請求被阻塞了(比如 SQL 語句執行非常慢)。但對於客戶端來說所有後續的請求都會因此而被阻塞。

隊頭阻塞(Head of line blocking,下文簡稱 HOC)說的是當有多個序列請求執行時,如果第一個請求不執行完,後續的請求也無法執行。比如上圖中,如果第四個資源的傳輸花了很久,後面的資源都得等著,平白浪費了很多時間,頻寬資源沒有得到充分利用。

因此,HTTP 協議允許客戶端發起多個並行請求,比如在筆者的機器上最多支援六個併發請求。併發請求主要是用於解決 HOC 問題,當有三個併發請求時,情況會變成這樣:

可見雖然第四個資源的請求被阻塞了,但是其他的資源請求並不一定會被阻塞,這樣總的來說網路的平均利用率得到了提升。

支援併發請求是解決 HOC 問題的一種方案,這句話沒有錯。但是我們要理解到:“併發請求並非是直接解決了 HOC 的問題,而是儘可能減少 HOC 造成的影響“,以上圖為例,HOC 的問題依然存在,只是不會太浪費頻寬而已。

有讀者可能會好奇,為什麼不多搞幾個併發的 HTTP 請求呢?剛剛說過筆者的電腦最多支援 6 個併發請求,谷歌曾經做過實驗,把 6 改成 10,然後嘗試訪問了三千多個網頁,發現平均訪問時間竟然還增加了 5% 左右。這是因為一次請求涉及的域名有限,再多的併發 HTTP 請求並不能顯著提高頻寬利用率,反而會消耗效能。

SPDY 的做法

有沒有辦法解決隊頭阻塞呢?

答案是肯定的。SPDY 協議的做法很值得借鑑,它採用了多路複用(Multiplexing)技術,允許多個 HTTP 請求共享同一個 TCP 連線。我們假設每個資源被分為多個包傳遞,在 HTTP 1.1 中只有前面一個資源的所有資料包傳輸完畢後,後面資源的包才能開始傳遞(HOC 問題),而 SPDY 並不這麼要求,大家可以一起傳輸。

這麼做的代價是資料會略微有一些冗餘,每一個資源的資料包都要帶上標記,用來指明自己屬於哪個資源,這樣客戶端最後才能把他們正確的拼接起來。不同的標記可以理解為圖中不同的顏色,每一個小方格可以理解為資源的某一個包。

TCP 視窗

是不是覺得 SPDY 的多路複用已經夠厲害了,解決了隊頭阻塞問題?很遺憾的是,並沒有,而且我可以很肯定的說,只要你還在用 TCP 連結,HOC 就是逃不掉的噩夢,不信我們來看看 TCP 的實現細節。

我們知道 TCP 協議會保證資料的可達性,如果發生了丟包或者錯包,資料就會被重傳。於是問題來了,如果一個包丟了,那麼後面的包就得停下來等這個包重新傳輸,也就是發生了隊頭阻塞。當然 TCP 協議的設計者們也不傻,他們發明了滑動視窗的概念:

這樣的好處是在第一個資料包(1-1000) 發出後,不必等到 ACK 返回就可以立刻傳送第二個資料包。可以看出圖中的 TCP 視窗大小是 4,所以第四個包傳送後就會開始等待,直到第一個包的 ACK 返回。這樣視窗可以向後滑動一位,第五個包被髮送。

如果第一、二、三個的包都丟失了也沒有關係,當傳送方收到第四個包時,它可以確信一定是前三個 ACK 丟了而不是資料包丟了,否則不會收到 4001 的 ACK,所以傳送方可以大膽的把視窗向後滑動四位。

滑動視窗的概念大幅度提高了 TCP 傳輸資料時抗干擾的能力,一般丟失一兩個 ACK 根本沒關係。但如果是傳送的包丟失,或者出錯,視窗就無法向前滑動,出現了隊頭阻塞的現象。

QUIC 是如何做的

QUIC 協議基於 UDP 實現,我們知道 UDP 協議只負責傳送資料,並不保證資料可達性。這一方面為 QUIC 的多路複用提供了基礎,另一方面也要求 QUIC 協議自己保證資料可達性。

SPDY 為各個資料包做好標記,指明他們屬於哪個 HTTP 請求,至於這些包能不能到達客戶端,SPDY 並不關心,因為資料可達性由 TCP 協議保證。既然客戶端一定能收到包,那就只要排序、拼接就行了。QUIC 協議採用了多路複用的思想,但同時還得自己保證資料的可達性。

TCP 協議的丟包重傳並不是一個好想法,因為一旦有了前後順序,隊頭阻塞問題將不可避免。而無序的資料傳送給接受者以後,如何保證不丟包,不錯包呢?這看起來是個不可能完成的任務,不過如果把要求降低成:最多丟一個包,或者錯一個包。事情就簡單多了,作業系統中有一種儲存方式叫 RAID 5,採用的是異或運算加上資料冗餘的方式來保證前向糾錯(FEC: Forward Error Correcting)。QUIC 協議也是採用這樣的思想,這裡不再贅述。

利用冗餘資料的思想,QUIC 協議基本上避免了重發資料的情況。當然 QUIC 協議還是支援重傳的,比如某些非常重要的資料或者丟失兩個包的情況。

少 RTT,請求更快速

前面說到,一次 HTTPS 請求,它的基本流程是三次 TCP 握手外加四次 SSL/TLS 握手。也就是需要三個 RTT。但是 QUIC 在某些場景下,甚至能夠做到 0RTT。

首先介紹下什麼是 0RTT。所謂的 0RTT 就是通訊雙方發起通訊連線時,第一個資料包便可以攜帶有效的業務資料。而我們知道,這個使用傳統的TCP是完全不可能的,除非你使能了 TCP 快速開啟特性,而這個很難,因為幾乎沒人願意為了這個收益去對作業系統的網路協議棧大動手腳。未使能 TCP 快速開啟特性的TCP傳輸第一筆資料前,至少要等1個RTT。

我們這裡再說說 HTTP2。對於 HTTP2 來說,本來需要一個額外的 RTT 來進行協商,判斷客戶端與伺服器是不是都支援 HTTP2,不過好在它可以和 SSL 握手的請求合併。這也導致了一個現象,就是大多數主流瀏覽器僅支援 HTTPS2 而不單獨支援 HTTP2。因為 HTTP2 需要一個額外的 RTT,HTTPS2 需要兩個額外的 RTT,僅僅是增加一個 RTT 就能獲得資料安全性,還是很划算的。

TCP 快速開啟

何謂 TCP 快速開啟,即客戶端可以在傳送第一個 SYN 握手包時攜帶資料,但是 TCP 協議的實現者不允許將把這個資料包上傳給應用層。這主要是為了防止 TCP 泛洪攻擊 [https://tools.ietf.org/html/rfc4987]。

因為如果 SYN 握手的包能被傳輸到應用層,那麼現有的防護措施都無法防禦泛洪攻擊,而且服務端也會因為這些攻擊而耗盡記憶體和 CPU。

當然 TCP 快速開啟並不是完全不可行的。人們設計了 TFO (TCP Fast Open),這是對 TCP 的擴充,不僅可以在傳送 SYN 時攜帶資料,還可以保證安全性。

TFO 設計了一個 Cookie,它在第一次握手時由 server 生成,Cookie 主要是用來標識客戶端的身份,以及儲存上次會話的配置資訊。因此在後續重新建立 TCP 連線時,客戶端會攜帶 SYN + Cookie + 請求資料,然後不等 ACK 返回就直接開始傳送資料。

服務端收到 SYN 後會驗證 Cookie 是否有效,如果無效則會退回到三次握手的步驟,如下圖所示:

同時,為了安全起見,服務端為每個埠記錄了一個值 PendingFastOpenRequests,用來表示有多少請求利用了 TFO,如果超過預設上限就不再接受。

關於 TFO 的優化,可以總結出三點內容:

  • TFO 設計的 Cookie 思想和 SSL 恢復握手時的 Session Ticket 很像,都是由服務端生成一段 Cookie 交給客戶端儲存,從而避免後續的握手,有利於快速恢復。

  • 第一次請求絕對不會觸發 TFO,因為伺服器會在接收到 SYN 請求後把 Cookie 和 ACK 一起返回。後續客戶端如果要重新連線,才有可能使用這個 Cookie 進行 TFO

  • TFO 並不考慮在 TCP 層過濾重複請求,以前也有類似的提案想要做過濾,但因為無法保證安全性而被拒絕。所以 TFO 僅僅是避免了泛洪攻擊(類似於 backlog),但客戶端接收到的,和 SYN 包一起發來的資料,依然有可能重複。不過也只有可能是 SYN 資料重複,所以 TFO 並不處理這種情況,要求服務端程式自行解決。這也就是說,不僅僅要作業系統的支援,更要求應用程式(比如 MySQL)也支援 TFO。

TFO 使得 TCP 協議有可能變成 0-RTT,核心思想和 Session Ticket 的概念類似: 將當前會話的上下文快取在客戶端。如果以後需要恢復對話,只需要將快取發給伺服器校驗,而不必花費一個 RTT 去等待。

結合 TFO 和 Session Ticket 技術,一個本來需要花費 3 個 RTT 才能完成的請求可以被優化到一個 RTT。如果使用 QUIC 協議,我們甚至可以更進一步,將 Session Ticket 也放到 TFO 中一起傳送,這樣就實現了 0-RTT 的對話恢復。

QUIC 是怎麼做的

讓我們看看 QUIC 是怎麼做的。

首先宣告一點,如果一對使用 QUIC 進行加密通訊的雙方此前從來沒有通訊過,那麼 0-RTT 是不可能的,即便是 QUIC 也是不可能的。

QUIC 握手的過程需要一次資料互動,0-RTT 時延即可完成握手過程中的金鑰協商,比 TLS 相比效率提高了 5 倍,且具有更高的安全性。在握手過程中使用 Diffie-Hellman 演算法協商初始金鑰,初始金鑰依賴於伺服器儲存的一組配置引數,該引數會週期性的更新。初始金鑰協商成功後,伺服器會提供一個臨時隨機數,雙方根據這個數再生成會話金鑰。

具體握手過程如下:

(1) 客戶端判斷本地是否已有伺服器的全部配置引數,如果有則直接跳轉到(5),否則繼續

(2) 客戶端向伺服器傳送 inchoate client hello(CHLO) 訊息,請求伺服器傳輸配置引數

(3) 伺服器收到 CHLO,回覆 rejection(REJ) 訊息,其中包含伺服器的部分配置引數

(4) 客戶端收到 REJ,提取並儲存伺服器配置引數,跳回到(1)

(5) 客戶端向伺服器傳送 full client hello 訊息,開始正式握手,訊息中包括客戶端選擇的公開數。此時客戶端根據獲取的伺服器配置引數和自己選擇的公開數,可以計算出初始金鑰。

(6) 伺服器收到 full client hello,如果不同意連線就回復 REJ,同(3);如果同意連線,根據客戶端的公開數計算出初始金鑰,回覆 server hello(SHLO)訊息,SHLO 用初始金鑰加密,並且其中包含伺服器選擇的一個臨時公開數。

(7) 客戶端收到伺服器的回覆,如果是 REJ 則情況同(4);如果是 SHLO,則嘗試用初始金鑰解密,提取出臨時公開數

(8) 客戶端和伺服器根據臨時公開數和初始金鑰,各自基於 SHA-256 演算法推匯出會話金鑰

(9) 雙方更換為使用會話金鑰通訊,初始金鑰此時已無用,QUIC 握手過程完畢。之後會話金鑰更新的流程與以上過程類似,只是資料包中的某些欄位略有不同。

寫在最後

想起有一個名言:計算機領域沒有什麼問題是加一層解決不了的,如果有,就再加一層。網路模型本來就是層層累加,到了 Web 得以快速生動的展現給人們以豐富的內容。從 HTTP 的演變過程中,我們可以看到中間又累加了若干層。不知道以後,又會是怎麼樣呢?

大家會發現,筆者在文中不止一次提到了演變這個詞。是的,這是來自達爾文進化論中的理論。在筆者看來,“物競天擇,適者生存”的演變理論和計算機領域的技術變化是很類似的,只不過在這裡,不是天擇,而是人擇。由市場,由使用者來選擇。不知道接下來,作為選擇者的我們,又將怎樣主導技術的走向?

推薦閱讀

QUIC/HTTP3 協議簡析

聊聊 WebSocket,還有 HTTP

相關文章