同樣的字句,更快的遞送
在前幾篇裡,你把 HTTP 學成「一個請求與一個回應,由純文字構成」:一個方法、一條路徑、一些標頭、一個狀態碼、一個內文。這裡有第一件要牢牢記住的事,因為它常讓人意外:那套文法——方法、狀態碼、標頭的意義——在 HTTP/1.1、HTTP/2 與 HTTP/3 之間幾乎不變。GET 還是 GET;404 還是 404。在各版本之間改變的,不是你「說什麼」,而是這些訊息「如何被打包、如何被一條連線載運」。所以整個故事談的是遞送,而不是詞彙。
若意義都一樣,何必費事?因為一個現代網頁不是一個檔案,而是數十、數百個——HTML,接著樣式表、腳本、圖片、字型。每一個都是一次獨立的 HTTP 請求,而一個請求送出去、回應再回來所花的時間,就是一個 來回時間(RTT)。在一條橫越大陸的連線上,這個 RTT 可能是 80 毫秒;走衛星鏈路就更久。載入一個網頁可能意味著非常多次這種小小的來回,所以你能多聰明地把它們「重疊」起來,就決定了網頁是瞬間蹦出來、還是慢吞吞地爬。正是這份壓力,推動了三個世代的設計。
HTTP/1.1:一條車道,與排隊的代價
HTTP/1.1 相較最初版本的重大改進,是 持久連線:瀏覽器不再為每一個檔案都開一條全新的 TCP 連線、付一次三向交握,而是讓一條連線保持開啟、一個接一個請求地重複使用。光是這點,每個物件就省下一次交握,是巨大的勝利。但它把一個更尖銳的問題擺在了眼前。在一條 HTTP/1.1 連線上,請求被嚴格地按順序、一次一個地服務:你送出一個請求,等它完整的回應,才送下一個。這就是單一一條結帳通道。
現在想像隊伍最前面卡著一個慢吞吞的回應:一張需要花點時間生成的大圖。排在它後面的每一個請求都得等,連那些本來瞬間就能完成的小請求也不例外。這就是 HTTP 層級的 隊頭阻塞:隊伍最前頭一個慢項目,拖住了它後面所有人,就像結帳隊伍裡一個推著滿車的顧客凍結了整條隊伍。瀏覽器用一個粗糙的把戲繞過它——對同一台伺服器開大約六條平行連線、把請求分散到上面——但那是六次交握、六份記憶體,而且也才六條車道。真正的解法,必須來自協定本身。
HTTP/2:一條路上的許多車道
HTTP/2 保留同一條連線,卻打破了「一次一個」的規則。它的核心想法是 多工(multiplexing):許多請求與回應在同一條連線上、同時交錯進行。要做到這點,HTTP/2 不再把每則訊息當成一整塊連續的文字送出,而是把每個請求與回應切成一個個帶標籤的小片段,叫做訊框(frame),每個訊框都帶著一個串流識別碼,說明它屬於哪個請求。不同請求的訊框於是可以在線路上混在一起傳,接收方再依標籤把它們分回各自獨立的串流。
HTTP/1.1 (one connection): requests served in order
[---- request A ----][-- B --][------ C ------] <- C waits for A and B
HTTP/2 (one connection): frames interleaved, labelled by stream
[A1][B1][A2][C1][B2][A3][C2][B3]... <- A, B, C make progress together
| | | |
stream 1 (A) stream 3 (C)
stream 1 (B is stream 5), etc.有了多工,那張慢吞吞的大圖不再凍結隊伍:它的訊框只是在大家之間輪流取得自己的份,而那些又小又快的回應可以在空檔間穿插著串流回來。HTTP/2 也用標頭壓縮(一套叫 HPACK 的機制)修掉了上一節那種標頭浪費——它記住已經送過的標頭,往回參照它們,而不再重複整段文字。結果是:一條 HTTP/2 連線就能完成過去需要六條 HTTP/1.1 連線的工作,開銷更少,也不必手動拼湊。
HTTP/3:底下換一條新路
HTTP/3 本質上是一句簡單的話:HTTP/3 就是把 HTTP/2 的想法,載運在一個叫 QUIC 的全新傳輸協定上,而不是 TCP。QUIC 跑在 UDP 之上——UDP 本身只會遞送一個個彼此獨立、不排序的封包——然後在使用者空間裡,重新打造出 TCP 曾給我們的可靠性、排序與壅塞控制,但帶著一個決定性的改變:QUIC 懂串流。因為 QUIC 知道「這個訊框屬於串流 1」、「那個訊框屬於串流 3」,一個在串流 1 上遺失的封包,就只會卡住串流 1。串流 3 已經抵達的資料會立刻被遞送。那個糾纏 HTTP/2 的傳輸層隊頭阻塞,就此消失。
為什麼把這一切建在 UDP 上,而不去修 TCP?有兩個誠實的理由。第一,TCP 被烤進了作業系統核心、也烤進路徑上無數的中間盒——路由器、防火牆、NAT 裝置——其中許多會檢查或竄改任何「看起來不像它們預期的那種 TCP」的東西。要改 TCP 本身,得花上十年才能在那套凍結的基礎設施上佈署完成。相對地,UDP 是一層又薄、又被普遍放行的包裝,所以 QUIC 可以躲在 UDP 封包裡、在軟體中演進,更新得跟一個 app 更新一樣快。這裡的 UDP 是一個刻意選擇的地基,不是任何人放棄了可靠性的徵兆。
第二,QUIC 從一開始就把加密摺疊了進來。回想一下,TCP 本身什麼都不加密;安全來自疊在上面的 TLS,而那要多花幾趟來回去建立——先是 TCP 交握,然後是 TLS 交握。QUIC 把連線建立與 TLS 合併成單一一次交握,所以一條全新的連線常常能在一趟來回內就開始送資料,而一條連到你最近才談過話的伺服器的連線,有時能在最開頭那個封包裡就送資料(稱為零來回,0-RTT)。在 QUIC 裡,加密不是一個可選的附加品,而是強制且內建的。這正是為什麼 HTTP/3 在實務上永遠是加密的。
把這段旅程從頭走到尾
把這三個版本看成「對同一個逐步深入的問題的回答」會很有幫助。每個版本都解決了前一個最大的痛點,然後暴露出問題的下一層。把它當成一個序列走一遍,整段弧線就會卡到定位。
- HTTP/1.0:每一個檔案都開一條新的 TCP 連線(與交握)。痛點:每個網頁要付荒謬數量的交握。
- HTTP/1.1:重複使用一條持久連線,但嚴格按順序服務請求。痛點:一個慢回應卡住排在它後面的一切(HTTP 層級的隊頭阻塞)。
- HTTP/2:藉由交錯帶標籤的訊框,在一條連線上多工出許多串流,並壓縮標頭。痛點:它仍乘坐單一一條 TCP 串流,所以一個遺失封包卡住所有串流(隊頭阻塞往下移進了 TCP)。
- HTTP/3:把同樣那些被多工的串流跑在 QUIC(在 UDP 上),它懂串流,所以一個遺失封包只卡住它自己的串流;並把 TLS 摺進建立過程以減少來回。阻塞終於在每一層都消失了。
兩個誠實的但書,能讓這段故事不淪為童話。第一,這一切都打不過物理:HTTP/3 砍掉來回、移除卡頓,卻無法讓訊號跑得比光快,所以一個送往遙遠伺服器的請求,仍要付出任何協定版本都抹不掉的真實延遲。更高的吞吐量與更聰明的多工,並不會縮短底層的距離。第二,「比較新」並不會自動對每個人都「比較快」——在一個乾淨、低遺失的網路上,HTTP/2 與 HTTP/3 表現相近,而 HTTP/3 的優勢最明顯之處,是在像行動網路那種會遺失封包或高延遲的鏈路上,也正是 TCP 那種按序卡頓最傷人的地方。知道一個工具在什麼時候勝出,是理解它的一部分。