標籤:

從 HTTP 0.9 到 QUIC

1989 年 World Wide Web 誕生之後,HTTP 和 HTML 迅速成為主導世界的應用層協議。在今天,幾乎任何場景的應用都或多或少地使用 HTTP(就像 JavaScript 一樣)。HTTP 本身也不僅僅用於網頁、瀏覽器,各式各樣的 API,移動應用同樣使用這個原本為 HTML 設計的協議。80 和 443 埠成了網路上最重要的埠。

在近 30 年的歷史中,HTTP 協議本身有比較大的發展,同時,還有一些重大的變動也在醞釀之中。這些演化使得這個協議的表現力更強,性能更好,更能滿足日新月異的應用需求。這裡就來回顧和展望一下 HTTP 的歷史和未來。

HTTP 0.9

歷史上第一個有記載的 HTTP 版本是 0.9(The HTTP Protocol As Implemented In W3),它誕生在 1991 年。這個協議被設計用於從伺服器獲取 HTML 文檔。

telnet example.com 80GET /<html>...??

整個協議的請求只有1行:`GET` 加文檔路徑。`GET` 無需多解釋,是 HTTP 至今都一直保留的 "method",是 HTTP 的動詞。1991 年的 HTTP 僅支持 `GET` 這唯一的動詞。之後的路徑是文檔在伺服器的位置(邏輯位置),即實際要獲取的內容。請求以換行結束。伺服器收到請求之後,就會返回對應的 HTML 文檔內容。輸出完畢後,關閉連接。

當時的 HTTP 協議是一個非常簡單的文本協議。直到今天,我們熟悉的 memcached 和 redis 還是使用類似風格的協議。使用者甚至可以直接在 `telnet` 中與伺服器交互。

HTTP 1.0

1996 年,一個更加完整,更加接近我們目前對 HTTP 認知的版本,HTTP 1.0(Hypertext Transfer Protocol -- HTTP/1.0) 發布了。這個版本中已經包含了很多我們如今耳熟能詳的概念:

  • HTTP 響應狀態碼:在響應的第一行,首先返回狀態碼和說明文本。相當於在 HTTP 0.9 基礎上增加了返回類型的支持。
  • HTTP 頭:除了首行的動詞和路徑之外,請求和響應都支持一系列的「頭」。這些「頭」以鍵值對的形式出現,為當時和日後 HTTP 的各種周邊設置提供了載體。
  • HTTP 方法:增加了 HEAD 和 POST 等方法。

這個時代的請求和響應已經接近現代的 HTTP 了:

telnet example.com 80GET / HTTP/1.0User-Agent: HappyBrowserAccept: */*

HTTP/1.0 200 OKContent-Type: text/htmlServer: HappyServer<h1>It works</h1>??

HTTP 1.1

隨著互聯網的迅速發展,人們對 HTTP 協議有了更高的要求。1999 年,現在最常見的 [HTTP 1.1](Hypertext Transfer Protocol -- HTTP/1.1) 版本誕生了。從此之後,這個 HTTP 協議一直服務至今。並且,在後來的十多年裡,這個協議還不斷更新和細化,最終在 2014 年形成了 5 個 RFC:

  • RFC 7230 - Hypertext Transfer Protocol (HTTP/1.1): Message Syntax and Routing 協議框架和連接管理
  • RFC 7231 - Hypertext Transfer Protocol (HTTP/1.1): Semantics and Content 動詞、狀態碼和頭定義
  • RFC 7232 - Hypertext Transfer Protocol (HTTP/1.1): Conditional Requests 條件請求
  • RFC 7233 - Hypertext Transfer Protocol (HTTP/1.1): Range Requests 斷點續傳相關
  • RFC 7234 - Hypertext Transfer Protocol (HTTP/1.1): Caching 緩存相關
  • RFC 7235 - Hypertext Transfer Protocol (HTTP/1.1): Authentication 認證相關

HTTP 1.1 協議已經相對龐大,不過要選擇其中對 HTTP 1.0 最大的改進,非連接管理莫屬。 HTTP 1.0 僅僅介紹了該協議可以使用在 TCP/IP 之上,但是沒有進一步地介紹。實際使用的方式仍然是請求結束後就將連接斷開,這樣我們需要為每一個 HTTP 請求都重新創建 TCP 連接。然而,每一個新的 TCP 連接在創建時需要經歷[握手](Transmission Control Protocol)和[慢啟動](TCP congestion control)的機制,客戶端在使用新的連接發送 HTTP 請求時,都要經歷可觀的延遲。隨著網頁內容的豐富,交互增加,使用新連接的代價是相當大的。

HTTP 1.1 針對這個問題,對連接管理有了明確的說明(RFC 7230 - Hypertext Transfer Protocol (HTTP/1.1): Message Syntax and Routing),默認即使用持久連接的機制。在 HTTP 頭中,包含了一個 `Connection` 欄位,對是否保持、重用連接進行說明。當 `Connection: keep-alive`時,連接將被保持,之後客戶端可以繼續在這個 TCP 連接上發送新的請求。當客戶端確實需要關閉連接時,發送的請求要明確說明`Connection: close`,伺服器端在處理完成後就會關閉連接。

一個典型的請求與響應:

telnet example.com 80GET / HTTP/1.1User-Agent: HappyBrowserAccept: */*

HTTP/1.1 200 OKContent-Type: text/htmlContent-Length: 16Server: HappyServerConnection: keep-alive<h1>It works</h1>

之後 TCP 連接得以保持,直到:

GET / HTTP/1.1User-Agent: HappyBrowserAccept: */*Connection: close

HTTP/1.1 200 OKContent-Type: text/htmlContent-Length: 16Server: HappyServerConnection: close<h1>It works</h1>??

通過持久連接的機制,同一個 TCP 連接可以傳輸多次的 HTTP 請求、響應,他的使用率已經得到了一定的提高。不過,由於 HTTP 協議採用請求-響應的模型,在一個 TCP 連接上,同一個時刻只能有一個請求,請求發送後,客戶端必須等待返回。這種同步、阻塞的方式限制了連接的吞吐量。當頁面上有大量元素時,這樣的等待會造成頁面內容的順序載入。因此,為了提高吞吐量,客戶端(瀏覽器)通常會打開多個連接,很多現代瀏覽器會對每個域名至多打開6個連接 (html - Max parallel http connections in a browser?),並且重用它們。然而即使是6個連接,在載入包含大量外部資源的頁面時仍然會捉襟見肘。為此,還出現了切分域名(Sharding Dominant Domains)等優化方法。但終歸來說,各種方式仍然會面臨創建 TCP 連接所帶來的開銷、延遲。

HTTP Pipelining(HTTP pipelining) 機制允許客戶端在響應返回前直接發送下一個請求,以 Firefox 為例,一旦啟用了 HTTP Pipelining,至多可以同時發送 32 個請求,之後只需要等待這些請求次第返回即可。然而這種特性絕非沒有代價。Pipelining 機制對順序有嚴格的要求。如果響應返回的順序與請求的順序不一致,就要求客戶端在更高層面增加順序判斷的機制,否則就將引起混亂。而在日常的瀏覽器場景下,是無從增加這種機制的,這就對伺服器的實現提出了要求:單一連接上的請求必須順序處理,順序返回。對順序的要求就導致了機制上的出現排隊的可能。如果一個請求的處理時間較慢,那麼後續所有的請求都會被拖慢,即使後續的請求很「便宜」。

對順序的要求是 HTTP 1.1 無法迴避的。因此,後續的改變就需要大刀闊斧了。

SPDY & HTTP/2

從 2009 年開始,Google 開始設計和開發一個能夠解決上述問題的新協議:SPDY。在 SPDY 的基礎上,2015 年,HTTP/2(RFC 7540 - Hypertext Transfer Protocol Version 2 (HTTP/2))終於誕生了。針對 HTTP 1.1 已有的順序問題,HTTP/2 給出了相應的回答:多路復用。

HTTP/2 仍然使用持久連接,客戶端與伺服器需要長時間保持一個連接。但由於增加了多路復用的機制,因此這裡也僅僅需要一個連接即可,這樣就避免了 TCP 連接反覆創建的開銷。多路復用機制是在純的 TCP 抽象在再抽象出一層 stream 的概念。請求-響應全部搭載在 stream 之上,在同一個 stream 上仍然保持原先的請求-響應模型。但是兩端之間可以創建多組 stream,這樣就避免了排隊等待的問題。而且由於抽象的 stream 幾乎沒有創建代價,在用戶體驗和實際性能上,都比原先創建新的 TCP 連接要快很多。伺服器在處理多個 stream 的請求時,也無需受限於順序,這樣就可以充分使用多個線程並行處理,且無需做任何等待。

表面上 HTTP/2 解決了之前所有的問題,然而事實並不盡然。由於今天 HTTP/2 下層的傳輸協議仍然是 TCP,而 TCP 本身是要求順序的,對順序的要求就會帶來排隊的問題。HTTP/2 層面的多路復用可以說解決了應用層的順序問題:比如伺服器可以用多線程並行處理同一個 TCP 連接上的請求。但是在傳輸層,順序問題仍然會限制吞吐量,一旦伺服器將結果寫到了連接上,這個順序就將被保證。儘管上文說 stream 之間可以互不干擾,不過在傳輸層 TCP 仍然會「老老實實」地按照伺服器端寫出的順序交付給客戶端。可惜客戶端並不完全在乎這個順序,客戶端只需要保證在同一個 stream 之內有序即可。如果傳輸中出現了丟包的情況,操作系統必須等到之前的數據重傳完成後才會交付到上層的應用程序。在這部分等待的數據中,很可能就包含其他 stream 的完整數據包,而它們原本可以提前交付給應用層。

這就是一個多路復用的應用層協議運行在一個非多路復用的傳輸層協議上產生的問題。

QUIC

QUIC(draft-tsvwg-quic-protocol-02) 並非是一個應用層協議。它誕生於 2012 年,目的就是在 SPDY 之後解決傳輸層上積累的問題:

  • 傳輸層的多路復用
  • TCP+TLS 連接創建的代價過大

QUIC 的一大特點是基於 UDP,對於這個長期幾乎被應用層遺忘的協議來說,是一件令人興奮的事情。但是 QUIC 選用 UDP 並非意味著它對完整性、順序傳輸等機制地放棄,而僅僅是因為 UDP 是 IP 的一個很薄的包裝。現在的網路已經不允許人們隨意發明一個新的傳輸層協議了,各種網路設備對數據包的傳輸層協議都有要求。QUIC 在 UDP 上實現了一個多路復用的 TCP,它幾乎包含了 TCP 所有的主要功能。

與 HTTP/2 + TLS + TCP 相比,QUIC 承擔了 HTTP/2 中的整個 stream 管理部分、TLS 安全連接部分和 TCP 的重傳、順序、流控等機制。針對上一節描述的問題,QUIC 可以將 TCP 對順序的要求進一步細化到 stream 上。在 QUIC 中,重傳的粒度被提高到 stream 的層面,這樣使得上層的抽象與下層的實現達成了一致。QUIC 重新將 HTTP/2 中較複雜的流管理(在應用層顯得不倫不類)移到了本該存在傳輸協議中,也簡化了 HTTP/2 協議,使這個應用層協議可以專註於 HTTP 的語義本身。

另一方面,目前使用的 TCP + TLS 握手環節存在數據交互過多的問題。儘管 HTTP/2 已經可以實現對一個地址只創建一個連接的機制,但是創建連接仍然需要反覆的握手,是一個亟待優化的環節。針對這個問題,Google 推動了一個 TCP 的改進: [TCP Fast Open](TCP Fast Open)。Fast Open 允許 TCP 客戶端在握手的第一個 SYN 包中攜帶應用層的第一個數據包,握手完成時伺服器端可以直接處理這個數據包。與之思路類似的是 TLS 1.3 的 0-RTT 機制,允許客戶端在 TLS 握手的 `Client HELLO` 環節就帶上應用層數據,伺服器端回復 `Server HELLO` 時就可以直接返回應用層的結果。這兩個改進目前還沒有完全推廣到業界,不過 QUIC 已經吸收了 TCP Fast Open 的機制,並且將在未來直接支持 TLS 1.3

總結

看 HTTP 協議從過去到未來的發展歷程,如果要總結一下的話:

  • 由於創建連接代價較大,儘可能提高連接使用率:持久連接,Pipelining 機制,多路復用機制
  • 減小創建連接的代價:減少客戶端伺服器端交互次數,持久連接,TCP FastOpen, TLS 1.3 0-RTT
  • 保持應用層與傳輸層的抽象、實現一致:持久連接,stream 管理

推薦閱讀:

QPS 和並發:如何衡量伺服器端性能
BaaS 服務的興起減少了後端的工作量,這意味著未來大批後台程序員要失業么?
Google 收購的 Firebase 相比 Parse、LeanCloud 怎麼樣?

TAG:LeanCloud |