01

總覽與定位

HTTP 是通道上的語法與語義;HTTP/2 一個方法、一個狀態碼都沒改,它換掉的是這些語義在線上怎麼編碼、怎麼共用連線。

HTTP(HyperText Transfer Protocol)是一個無狀態的請求/回應協定:客戶端送出一個方法加一個目標資源加一組標頭(可能還有 body),伺服器回一個狀態碼加一組標頭(可能還有 body)。就這樣,三十年沒變。

座標接 TLS 篇的斷點:通道打通了,但還沒人說話

前一篇的 TLS 篇把一條加密、且能驗證對方身分的通道建了起來,然後停在一個問題上: 通道上要用什麼語法提出請求、怎麼表達結果、怎麼重複利用同一條連線。本篇就是這一層。
銜接點其實已經在 TLS 那一篇留好了:ALPN——客戶端在 ClientHello 送出 h2, http/1.1, 伺服器在 EncryptedExtensions 回覆選中的那一個。所以「這條連線上跑 HTTP/1.1 還是 HTTP/2」, 在 TLS 交握結束的那一刻就已經定案,不需要任何額外往返,也不是先跑 HTTP/1.1 再升級。 TLS 篇那個「邊緣終止 TLS 的元件有沒有開 ALPN h2」的開關,就是本篇後半場的入口。

要讀懂 HTTP 的版本史,只需要抓住一個分界:語義(semantics)與傳輸表示法(wire format)是兩件事。IETF 在 2022 年的改版把這件事寫進了文件結構本身——RFC 9110 只講語義(方法、狀態碼、標頭、快取、條件請求),RFC 9112/9113/9114 分別講 HTTP/1.1、HTTP/2、HTTP/3 怎麼把同一套語義送上線。

HTTP/1.0
1996 · RFC 1945
有了標頭與狀態碼,但一個請求一條連線。每張圖片都要重付一次 TCP 交握。
HTTP/1.1
1997 / 2022 · RFC 9112
語義的定稿版本:持久連線、Host 必填、chunked、快取驗證。並行度=連線數。
HTTP/2
2015 / 2022 · RFC 9113
同一套語義、全新的二進位框架層:單連線多路復用、HPACK 頭部壓縮、per-stream 流量控制。
HTTP/3
2022 · RFC 9114
還是同一套語義,但底下的 TCP 被換掉了。本篇最後會說明為什麼非換不可,細節留給 QUIC 篇。

把這件事講得更白一點:HTTP/2 沒有新增任何一個方法、任何一個狀態碼。你的 GET /users/1 在 HTTP/1.1 與 HTTP/2 上是同一個請求、同一個語義、同一份快取規則;差別只在它被編碼成一串 ASCII 文字,還是一組帶著 Stream ID 的二進位 frame。這也是為什麼本篇要先把語義講完整——語義是穩定的資產,傳輸表示法才是每十年換一次的部分。

層次由誰定義版本間是否改變對應本篇章節
語義:方法、狀態碼、標頭、快取RFC 9110(版本無關)幾乎不變第 2–4 節
連線管理:重用、並行、關閉各版本自訂每版都大改第 5、6 節
訊息編碼:如何切出一則訊息各版本自訂文字 → 二進位 frame第 5、6 節
底層傳輸TCP(1.1/2)、QUIC(3)HTTP/3 才換第 6 節收尾

這張表就是本篇的地圖:上半場(第 2–5 節)處理「語義與 HTTP/1.1 的連線模型」,下半場(第 6 節)處理「HTTP/2 怎麼重寫中間兩列」,而最後一列的鬆動,就是 QUIC 篇的主題。

一句話答法HTTP 是無狀態的請求/回應語義層;HTTP/1.1 與 HTTP/2 共用同一套方法、狀態碼與快取語義,差別只在訊息如何編碼與連線如何共用——所以「升級到 HTTP/2」不會改變任何一行業務語義,只會改變延遲曲線。
02

方法語義:安全、冪等與重試

方法不是「動詞的美學選擇」,而是一份對中間層的承諾:這個請求能不能重試、能不能被快取、能不能被預先抓取。

HTTP 方法上掛著三個正交的性質,決定了瀏覽器、proxy、CDN、重試中介、甚至 TLS 層敢對它做什麼:

  • 安全(Safe):語義上是唯讀的,不預期改變伺服器狀態。安全的方法可以被爬蟲、瀏覽器預先抓取(prefetch)、鏈路預熱隨意觸發。
  • 冪等(Idempotent):同一個請求送一次與連續送 N 次,對伺服器狀態的最終影響相同。TLS 篇談 0-RTT 時只給了這個定義的一半,這裡是完整版——注意它約束的是伺服器狀態,不是回應內容。
  • 可快取(Cacheable):回應在滿足新鮮度條件時可以被存下來重複使用。
方法安全冪等可快取語義重點
GET是是是取得表徵。不該有副作用,帶 body 的 GET 屬未定義行為
HEAD是是是同 GET 但不回 body,用來探測大小/存在性/有效性
OPTIONS是是否查詢能力;CORS preflight 的載體
PUT否是否整份替換目標資源。送十次與送一次結果相同,所以冪等
DELETE否是否刪除。第二次刪已不存在的東西,狀態仍相同
POST否否條件性把 body 交給資源「處理」——語義最寬鬆,也因此最不可重試
PATCH否否(可設計成是)否部分更新。{"count": 5} 冪等,{"op":"increment"} 不冪等

RFC 9110 的原始矩陣。實務上最常被搞混的是最後兩列:PATCH 的冪等性取決於你的 patch 格式怎麼設計,協定不替你保證。

關鍵冪等 ≠ 每次回應都一樣

DELETE /orders/42 第一次回 204 No Content,第二次回 404 Not Found—— 回應不同,但它仍然是冪等的,因為執行完之後「訂單 42 不存在」這個伺服器狀態沒有再變。 同理 PUT 第一次可能回 201 Created、第二次回 200 OK。
這個區分之所以重要,是因為冪等性的用途就是決定「能不能安全地重試」: 客戶端在逾時或連線斷掉時,根本不知道請求有沒有抵達伺服器。若方法是冪等的,重送最壞的情況只是白做一次; 若不是,重送就可能變成兩筆訂單。

把這條線接回 TLS 篇就完整了。TLS 1.3 的 0-RTT early data 可以被攻擊者原封不動重放,伺服器在交握完成前沒有任何新鮮性證據可以分辨。所以那時的結論是「early data 只該放冪等請求」——現在你知道這句話的精確含義了:放進 early data 的請求,必須是被重放一百次也不會多出一筆訂單的那種。而 HTTP 這一層提供的對應訊號是 425 Too Early:

ClientHello + early data ▶ 交握未完成就送出請求 425 Too Early ▶「我不在這個階段處理它」 交握完成後重送

425 是伺服器對「我還不確定這是不是重放」的標準回覆,客戶端必須在交握完成後以正常方式重送。它讓「要不要冒重放風險」這個決定從 TLS 層下放到 HTTP 層:反向代理可以只對 GET/HEAD 放行 early data,對其餘方法一律回 425。這是一個很好的例子——傳輸層的安全折衷,最後要靠應用層的語義來兜底。

陷阱冪等是「語義承諾」,不是「自動實現」

協定說 PUT 冪等,但如果你的 handler 每次被呼叫都往稽核表插一筆、發一封信、扣一次庫存, 那它在事實上就不冪等——你只是騙了中間層。反向的問題更常見:POST /orders 天生不冪等, 但業務上又必須容忍重試(網路斷線、使用者連點、上游 retry 中介)。標準解法是由應用自己補上冪等層: 客戶端產生一個 Idempotency-Key,伺服器以它為鍵記錄「這個請求已處理過,結果是這個」,重複進來就直接回放先前的結果。 支付與金流 API 幾乎清一色採用這個模式。
另外兩個高頻誤用:用 GET 做有副作用的操作(GET /users/1/delete)—— 爬蟲、瀏覽器 prefetch、防毒軟體的連結掃描都會替你按下去;用 POST 做查詢—— 換來的是不可快取、不可安全重試、不能收藏成 URL。

一句話答法方法的安全性決定「能不能被隨意觸發」,冪等性決定「能不能安全重試」——冪等看的是伺服器狀態不是回應內容,所以 DELETE 回 404 仍然冪等;POST 不冪等時就用 Idempotency-Key 由應用層自己補,而 TLS 0-RTT 只放冪等請求、其餘回 425 Too Early,正是這條規則的直接應用。
03

狀態碼與標頭:寫給中間層看的契約

狀態碼不是給前端 if 判斷用的裝飾——CDN、proxy、瀏覽器、重試中介都會照它決定要不要快取、要不要重試、要不要換方法。

五個類別各自有明確的行為語義,而不只是「分類」:

類別語義中間層會做什麼值得記住的成員
1xx中間資訊,尚未完成轉發,並繼續等真正的最終回應100 Continue、101 Switching Protocols、103 Early Hints
2xx成功200/203/204/206 預設可快取200、201、202(已受理但未完成)、204、206
3xx需要進一步動作瀏覽器自動跟隨;304 由快取接管301/308(永久)、302/307(暫時)、304
4xx請求本身有問題不該自動重試(408/429 例外)400、401、403、404、409、422、425、429
5xx伺服器端失敗對冪等方法可退避重試500、502、503、504

101 這一列先記著:它是「把這條 HTTP 連線換成另一個協定」的正式訊號,WebSocket 就是靠它起家——之後的 WebSocket 篇會完整處理,本篇只需知道這個機制存在。

陷阱五個高頻誤用

1. 200 包著錯誤。{"success": false, "error": "..."} 配 200—— 等於告訴 CDN「這是個成功回應,快取它」,也讓所有監控、重試中介、SLO 儀表看到 100% 成功率。
2. 一律用 302 導向。歷史上 301/302 被普遍實作成「重導時把 POST 改寫成 GET 並丟掉 body」。 需要保留方法與 body 時必須用 307(暫時)/308(永久)。
3. 401 與 403 不分。401 是「你還沒證明你是誰」(必須附 WWW-Authenticate,客戶端可重試); 403 是「我知道你是誰,但你不能做這件事」(重試沒有意義)。
4. 限流回 503 而不是 429。429 Too Many Requests 配 Retry-After 才是給客戶端的可行動訊號;503 會被上游解讀成「整個服務掛了」而觸發熔斷。
5. 502/504 混用。502 是上游回了無效回應,504 是上游逾時沒回—— 這一字之差決定你該去看應用日誌還是去看逾時設定與慢查詢。

標頭:Host 與 SNI 的時序對照

HTTP/1.1 相對 1.0 最重要的一個強制規定是 Host 必填。原因很單純:請求行裡只有路徑(GET /index.html),伺服器單看 TCP 連線根本不知道你要的是這台機器上的哪個網站。Host 讓一個 IP、一個 port 上住得下幾千個網域——虛擬主機、反向代理的後端選擇、CDN 的多租戶全靠它。

而 TLS 篇談過的 SNI 做的是「同一件事的更早一步」。兩者放在一起看,時序就清楚了:

面向SNI(TLS 層)Host / :authority(HTTP 層)
出現時機TLS ClientHello,交握最開頭交握完成後的第一個請求裡
可見性明文(路徑上的設備都讀得到)已在 TLS 通道內加密
用途伺服器據此選哪張憑證伺服器據此選哪個虛擬主機/後端
誰能靠它路由L4 負載均衡器(不必解密)L7 反向代理(必須先終止 TLS)
HTTP/2 的形式不變改用 :authority pseudo-header

兩者可以不一致——這正是 domain fronting 的原理,也是一整類路由錯誤與安全繞過的根源。正經的邊緣元件會強制校驗「SNI 與 Host 是否相符」。

標頭:逐跳(hop-by-hop)與端到端

大部分標頭是端到端的:proxy 應該原封不動轉發。但有一小類是逐跳的,只描述「這一段連線」的性質,proxy 必須把它們剝掉再自己重新產生:Connection、Keep-Alive、TE、Trailer、Transfer-Encoding、Upgrade、Proxy-Authorization。

其中 Connection: Upgrade 搭配 101 Switching Protocols,是 HTTP/1.1 內建的「換協定」出口:雙方同意之後,這條 TCP 連線就不再說 HTTP 了。WebSocket 走的正是這條路——細節留給 WebSocket 篇。值得在這裡先記一筆的是:HTTP/2 直接禁止了整組 Connection 系列標頭,因為連線管理已經下沉到框架層,用 frame 而不是用標頭表達。這也是為什麼 WebSocket 在 HTTP/2 上必須改用另一套機制。

最後是訊息邊界。TCP/UDP 篇已經定調過:TCP 只給你位元組串流,不保證訊息邊界,framing 是應用自己的事。HTTP/1.1 的答案有兩種——Content-Length 明確告知長度,或 Transfer-Encoding: chunked 以「長度前綴的區塊 + 一個 0 長度區塊收尾」串流未知長度的內容。看一次原始報文最有感:

GET /api/orders/42 HTTP/1.1        <- 請求行:方法 目標 版本
Host: api.example.com              <- 必填,決定虛擬主機
If-None-Match: "v7-9c1f"           <- 條件請求(第 4 節)
Accept-Encoding: gzip, br
                                   <- 空行 = 標頭結束(CRLF CRLF 是 framing 邊界)

HTTP/1.1 304 Not Modified          <- 狀態行
ETag: "v7-9c1f"                    <- 版本一致,不必回 body
Cache-Control: max-age=60
Connection: keep-alive             <- 逐跳標頭,proxy 會剝掉重寫

整則訊息就是純 ASCII 文字,靠 \r\n 分行、空行分隔標頭與 body。好讀好除錯,代價是解析成本高、無法壓縮標頭、也無法在同一條連線上交錯兩則訊息——這三點正是 HTTP/2 要動刀的地方。

一句話答法狀態碼是給中間層的行為契約(可不可快取、能不能重試、要不要換方法),所以 200 包錯誤、302 當 307 用、429 寫成 503 都是實質 bug;標頭則分端到端與逐跳兩類,Host 是 HTTP 層的虛擬主機選擇器、對應 TLS 層更早發生的 SNI,而 CRLF 空行與 Content-Length/chunked 就是 HTTP/1.1 的 framing 方案。
04

快取與 proxy:最快的請求是沒發出的請求

HTTP 快取只回答兩個問題:這份東西還新鮮嗎(不用問伺服器)?過期了的話,它真的變了嗎(問一句就好)?

這兩個問題對應兩套機制,很多人把它們混成一團:

機制回答的問題成本關鍵標頭
新鮮度 Freshness還在有效期內嗎?零——完全不發請求Cache-Control: max-age、Expires、Age
驗證 Validation過期了,內容真的變了嗎?一次往返,但省下 bodyETag / If-None-Match、Last-Modified / If-Modified-Since

關鍵差異:新鮮度省的是整個 RTT,驗證只省 body。在高延遲鏈路上,一個 304 跟一個 200 的延遲幾乎一樣——所以能靠 max-age 解決的,就別靠 ETag。

Cache-Control 指令實際意思典型用法
max-age=NN 秒內都算新鮮,任何快取都可直接用靜態資源
s-maxage=N只對共用快取(CDN/proxy)生效,覆蓋 max-age「CDN 存 1 小時、瀏覽器只存 1 分鐘」
no-cache可以存,但每次使用前都要先驗證HTML 入口文件
no-store完全不准寫進任何儲存含個資/權杖的回應
private / public只有瀏覽器可存 / 共用快取也可存登入後的個人化頁面用 private
immutable在有效期內連使用者按重新整理都不重新驗證帶雜湊檔名的 JS/CSS
stale-while-revalidate=N過期後 N 秒內先回舊的,背景默默更新用延遲換一點點新鮮度

no-cache 不是「不要快取」——那是 no-store。這組命名是 HTTP 最經典的誤導之一,也是面試高頻題。

ETag 是伺服器給某份表徵的版本識別碼,可以是內容雜湊、版本號或任何不透明字串。客戶端下次帶 If-None-Match: "v7-9c1f" 回來,伺服器比對後若沒變就回 304 Not Modified(無 body)。相對地 Last-Modified 只有秒級精度,同一秒內的兩次修改分不出來,且對「內容相同但重新產生」的檔案會誤判——兩者並存時 ETag 優先。

關鍵Vary:快取鍵不只是 URL

共用快取以「方法 + URL」為鍵,但同一個 URL 可能因為請求標頭不同而回不一樣的內容(壓縮格式、語言、裝置)。 Vary 就是伺服器告訴快取:「把這些標頭也算進快取鍵」。 Vary: Accept-Encoding 是必備的(否則可能把 br 壓縮的內容餵給不支援的客戶端); 但 Vary: User-Agent 幾乎等於關掉快取——User-Agent 的取值近乎無限,命中率會直接歸零。 需要依裝置分流時,正確做法是在邊緣把 UA 正規化成少數幾個值再 Vary。

proxy 與 CDN:快取住在哪一層

Browser
私有快取
→ Forward Proxy
(客戶端側 · 出口管控)
→ CDN Edge
(共用快取 · 地理分散)
→ Reverse Proxy
(LB · TLS 終止)
→ Origin

三種 proxy 的差別在「替誰工作」:forward proxy 站在客戶端這一側(企業出口、爬蟲池),客戶端知道它存在;reverse proxy 站在伺服器這一側(Nginx、Envoy、ALB),客戶端以為它就是伺服器;CDN 本質上是一大群地理分散的 reverse proxy,額外加上共用快取與就近接入。private 與 public 這組指令,決定的正是「中間那三格能不能存你的回應」。

陷阱要快取,就得先終止 TLS

TLS 篇比較過三種終止位置。這裡是它的直接後果:CDN 若不終止 TLS,就讀不到 URL 與 Cache-Control,什麼都快取不了—— passthrough 模式下它只是一個看得到明文 SNI 的 L4 轉發器。所以「用 CDN 加速」與「端到端加密不讓 CDN 看到內容」這兩個需求 在架構上是直接衝突的,必須明確取捨(或改用邊緣運算把敏感邏輯留在自己手上)。
另一個常見事故:把帶 Set-Cookie 或個人化內容的回應標成 public, 然後 CDN 把 A 使用者的頁面回給 B 使用者。凡是有身分的回應,預設就該是 private 或 no-store。

實務上的標準組合非常固定,值得直接背下來:

資源類型建議策略為什麼
帶雜湊檔名的 JS/CSS/字型max-age=31536000, immutableURL 已含內容版本,內容變=URL 變,永遠不需要重新驗證
HTML 入口文件no-cache + ETag必須每次確認,才能讓上面那條「永久快取」安全成立
API 的 GET 回應短 max-age + stale-while-revalidate擋住尖峰重複查詢,同時不讓資料太舊
登入後的個人化內容private, no-store絕不能落進任何共用快取

這套組合叫 cache busting:把版本資訊放進 URL,用一個必須驗證的 HTML 去指向一堆永遠不必驗證的資源。它讓「快取失效」這個分散式系統難題退化成「改一個檔名」。

一句話答法HTTP 快取分新鮮度與驗證兩層:max-age 命中連請求都不發,ETag/304 只省 body 不省 RTT;no-cache 是「用前必驗」而非「不要快取」,Vary 決定快取鍵,而 private/public 決定 CDN 這類共用快取能不能存——想讓 CDN 快取,就必須讓它終止 TLS。
05

連線重用:keep-alive、六條連線與 pipelining 的失敗

HTTP/1.1 的並行不是「一條連線裡並行」,而是「開更多條連線」——這句話就是 HTTP/2 存在的全部理由。

HTTP/1.0 的模型是一請求一連線:每張圖片、每個 JS 檔都要重付一次三向交握(外加 TLS 的一趟交握)。 HTTP/1.1 把持久連線(persistent connection,俗稱 keep-alive)變成預設: 回應送完連線不關,下一個請求直接沿用,除非明確送出 Connection: close。

省下的不只是交握的 RTT。TCP/UDP 篇談壅塞控制時說過,新連線的 cwnd 從很小的初始值開始 slow start,要好幾個 RTT 才長到能吃滿頻寬。一條用過的熱連線,它的壅塞視窗已經長大了——這是連線重用最容易被忽略、但在大檔案傳輸上收益最明顯的一塊。

可是持久連線只解決了「重用」,沒解決「並行」。HTTP/1.1 的訊息語法要求一條連線上同時只能有一個進行中的請求/回應交換:送出請求 → 等回應完整收完 → 才能送下一個。於是:

HTTP/1.1 的並行度 = 同時開著的 TCP 連線數

瀏覽器給出的答案是:對同一個 origin(scheme + host + port)最多開 6 條。 這個數字不是規範規定的(RFC 2616 當年甚至建議 2 條),而是各家瀏覽器多年調校出來的經驗值。為什麼不開更多?

  • 每條連線都要各自付交握成本:TCP 一趟、TLS 一趟,還要各自從頭 slow start。
  • 連線之間互相搶頻寬:同一條瓶頸鏈路上開 30 條連線,總吞吐不會變成 30 倍,只會讓壅塞控制彼此打架、丟包率上升。
  • 伺服器成本是線性的:每條連線一個 file descriptor、一組收發緩衝、一份 TLS 狀態(TCP/UDP 篇談 backpressure 時算過這筆帳)。
  • 對 origin 不公平:瀏覽器不能讓單一分頁把伺服器的連線池吃光。

「6」這個上限,直接催生了整整一個世代的前端最佳實踐——而它們現在幾乎都是反模式:

HTTP/1.1 時代的解法在繞開什麼代價在 HTTP/2 下
Domain Sharding(拆成 img1/img2.example.com)每 origin 6 條的上限多付 DNS 查詢、TCP/TLS 交握,連線互搶頻寬有害——把單一連線拆散,多路復用失效
檔案合併(bundle、CSS sprites)請求數量改一個字就要重下整包,快取粒度變粗多半不必要,細粒度快取更划算
Base64 內嵌(inline 圖片/字型)請求數量體積膨脹約 33%,且無法獨立快取不建議

這三列是同一個模式:當協定的限制無法改變時,工程界就用架構去繞。而當協定終於改了,這些繞法就會反過來變成負擔——升級 HTTP/2 卻忘了拆掉 sharding,是最典型的「升級了卻沒變快」。

pipelining:一個看起來完美、卻全面失敗的答案

HTTP/1.1 其實提供過一個「一條連線裡並行」的方案:pipelining—— 不等回應就把多個請求連續送出,伺服器再按順序把回應一個個送回來。理論上一趟 RTT 就能發完所有請求。它徹底失敗了,原因有四層:

  1. 回應必須按請求的順序回。HTTP/1.1 的訊息沒有任何識別碼,接收端只能靠「順序」把回應對回請求。 於是第一個請求若是一支慢查詢,後面九個明明早就算完,也只能排隊等它——這就是 HoL Blocking, TCP/UDP 篇已經完整定義過的那個現象,在應用層的第一次現身。
  2. 中間設備實作破碎。大量 proxy、防火牆與伺服器不正確處理 pipelined 請求,會亂序、截斷或直接斷線。
  3. 失敗處理是惡夢。連線中斷時,客戶端不知道那批請求裡哪些已被處理——對非冪等方法完全無法安全重送(第 2 節的規則在這裡發威)。
  4. 收益有限。它省的是請求送出的 RTT,卻完全不能改善隊頭那個慢請求造成的阻塞。

結果是所有主流瀏覽器最終預設關閉 pipelining,RFC 9112 也明確不再鼓勵。 這個失敗留下的教訓非常明確:想在一條連線裡並行,就必須讓每則訊息帶上自己的識別碼,並允許回應亂序完成。 這句話,一字不差,就是 HTTP/2 的設計綱領。

$ curl -v --http1.1 https://api.example.com/a https://api.example.com/b

*   Trying 93.184.216.34:443...
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256
* ALPN: server accepted http/1.1        # 版本在 TLS 交握就定了(TLS 篇)
> GET /a HTTP/1.1
> Host: api.example.com
< HTTP/1.1 200 OK
< Content-Length: 1024
* Connection #0 to host api.example.com left intact   # keep-alive:不關
* Re-using existing connection #0                      # 第二個請求沿用,省掉 TCP+TLS
> GET /b HTTP/1.1                                      # 但必須等 /a 收完才送得出去

Re-using existing connection 是持久連線在做事的證據。注意最後一行的註解——重用不等於並行:/b 是在 /a 的回應完整收完之後才送出的。要真正並行,curl 只能再開一條連線。

一句話答法HTTP/1.1 靠持久連線省掉重複交握與 slow start,但一條連線同時只跑一個請求,所以並行度等於連線數、瀏覽器對每個 origin 開 6 條;pipelining 想在單連線裡並行卻因為「回應必須按序返回」而撞上 HoL Blocking、又被中間設備打壞,最終全面棄用——它的失敗直接定義了 HTTP/2 的目標:每則訊息要有識別碼、回應要能亂序完成。
06

HTTP/2:二進位框架、多路復用與它沒解決的那一半

同一套語義,換一種送法:把文字換成 frame、把 6 條連線收成 1 條——然後撞上一堵它自己拆不掉的牆。

1 · 二進位框架層:把訊息切成有標籤的小塊

HTTP/2 在 TLS 與 HTTP 語義之間插入一個新的層:二進位框架層(Binary Framing Layer)。應用看到的還是「請求/回應」,但真正送上線的是一連串固定格式的 frame,每個 frame 前面是 9 個位元組的標頭:

Length (24 bits)
Type (8)
Flags (8)
R (1) + Stream Identifier (31 bits)
Frame Payload(0 … Length 個位元組)

這正是 TCP/UDP 篇說的長度前綴 framing:接收端先讀 9 個位元組,就知道這個 frame 有多長、屬於哪個 stream,不必像 HTTP/1.1 那樣掃描 \r\n\r\n。解析從「字串搜尋」變成「讀固定欄位」,快且不會有歧義。

常用的 frame 類型分兩群:承載訊息的(HEADERS 帶標頭、DATA 帶 body、CONTINUATION 接續過長的標頭)與 管理連線的(SETTINGS 協商參數、WINDOW_UPDATE 流量控制、RST_STREAM 取消單一 stream、 GOAWAY 優雅關閉連線、PING 存活與 RTT 量測)。 注意 RST_STREAM 這個能力在 HTTP/1.1 根本不存在:那時要取消一個進行中的請求,唯一辦法是把整條 TCP 連線砍掉。

2 · stream / message / frame:多路復用的三層結構

Stream
31-bit ID · 雙向 · 可獨立取消
一條 TCP 連線裡的虛擬通道。客戶端發起用奇數 ID、伺服器用偶數,因此不會撞號。一個請求/回應交換佔用一個 stream。
Message
= 一個完整的請求或回應
語義上的單位,對應 RFC 9110 的那套方法與狀態碼。由一個 HEADERS 加零到多個 DATA 組成。
Frame
最小傳輸單位 · 標頭含 Stream ID
因為每個 frame 都標明自己屬於哪個 stream,不同 stream 的 frame 可以任意交錯,接收端照 ID 重組即可。
S1 HEADERS S3 HEADERS S5 HEADERS S3 DATA S1 DATA S5 DATA S3 DATA(END) S1 DATA(END)

同一條 TCP 連線上的位元組順序。三個請求同時在飛,而且 S3 先完成——這在 HTTP/1.1 是不可能的(pipelining 必須按序回)。這就是多路復用:TCP/UDP 篇定義過這個詞,這裡是它在應用層最完整的實現。

用 nghttp -nv 可以直接看到 frame 層的往返:

$ nghttp -nv https://example.com/

[0.021] send SETTINGS frame <length=12, flags=0x00, stream_id=0>
[0.021] send HEADERS  frame <length=41, flags=0x25, stream_id=1>    # 請求 /
        :method: GET  :scheme: https  :authority: example.com       # pseudo-header
[0.093] recv HEADERS  frame <length=97, flags=0x04, stream_id=1>
[0.093] recv DATA     frame <length=1300, flags=0x00, stream_id=1>
[0.094] send HEADERS  frame <length=8,  flags=0x25, stream_id=3>    # 解析出子資源後
[0.094] send HEADERS  frame <length=9,  flags=0x25, stream_id=5>    # 三個請求一口氣送出
[0.094] send HEADERS  frame <length=9,  flags=0x25, stream_id=7>    # 不必等任何回應
[0.158] recv DATA     frame <length=1300, flags=0x00, stream_id=5>  # 5 先回
[0.161] recv DATA     frame <length=1300, flags=0x00, stream_id=3>  # 3 後回 → 亂序完成
[0.162] recv WINDOW_UPDATE frame <length=4, flags=0x00, stream_id=0> # 連線層歸還額度

注意兩個細節:(1) 三個 HEADERS 在同一毫秒送出,stream_id 全是奇數(客戶端發起);後續標頭只剩 8–9 位元組,因為 HPACK 已把重複欄位換成索引。(2) stream 5 的資料比 stream 3 早回——回應順序與請求順序脫鉤了,這件事在 HTTP/1.1 做不到。

HTTP/2 的並行度 = SETTINGS_MAX_CONCURRENT_STREAMS(常見預設 100 ~ 250)

從「6 條連線」跳到「一條連線上百個 stream」,帶來的連鎖收益比想像中多:連線數從 6 降到 1, 交握成本只付一次、壅塞視窗只養一次;伺服器的連線數與記憶體壓力大幅下降; 而第 5 節那三個時代性 workaround(sharding/合併/內嵌)全部失去意義。

3 · HPACK:因為標頭真的太浪費了

做一次算術就懂為什麼要壓標頭。一個現代網頁載入 100 個資源,每個請求都要重送一遍 Cookie、User-Agent、Accept、Accept-Language、Referer—— 這些欄位在同一次載入中幾乎一模一樣,加起來常有 800 位元組到 1.5 KB。100 個請求就是近百 KB 的純重複, 而且 HTTP/1.1 的 Content-Encoding 只壓 body,壓不到標頭。

SPDY 曾經直接拿 gzip 壓標頭,結果被 CRIME 攻擊打穿——攻擊者透過觀察壓縮後的長度變化,可以逐位元組猜出 Cookie。 (TLS 篇提過 TLS 1.3 直接刪掉壓縮功能,正是同一個教訓。) HPACK(RFC 7541)是重新設計的答案,三個機制疊在一起:

機制作法效果
靜態表預先定義 61 個最常見的標頭項目(:method: GET、accept-encoding: gzip, deflate…)常見標頭壓成一個位元組的索引
動態表連線層級、收發雙方同步維護的表;第一次送完整內容並雙方一起寫入,之後只送索引Cookie、User-Agent 這種長而不變的欄位只送一次
Huffman 編碼對必須送出的字串值做靜態 Huffman 編碼URL 路徑等新值再省 20–30%

實測上 HPACK 常能把標頭壓到原本的一到兩成。它也保留了防禦選項:敏感欄位(例如帶權杖的自訂標頭)可以標記為不進動態表、且禁止中間層索引,避免重演 CRIME 那種壓縮側信道。

陷阱動態表是「連線狀態」,這件事有後座力

動態表要求收發雙方對「第 N 號索引是什麼」有完全一致的認知,因此 HPACK 隱含了標頭區塊必須嚴格按序處理的假設—— 這在 TCP 上不成問題(TCP 本來就保證順序),但一旦底層換成允許亂序交付的傳輸協定就會直接崩潰。 HTTP/3 因此必須把 HPACK 改成 QPACK,用額外的機制處理「索引還沒同步就先收到引用」的情況。細節留給 QUIC 篇,這裡只需記住: 頭部壓縮的設計,被底層傳輸的順序保證綁住了。
營運面也有一坑:SETTINGS_HEADER_TABLE_SIZE 是每條連線的記憶體。在一台扛數萬條連線的 gateway 上調大它, 乘出來的數字很可觀。

4 · 流量控制:HTTP/2 有自己的一層,而且與 TCP 是兩件事

把所有 stream 塞進一條 TCP 連線,會冒出一個 HTTP/1.1 沒有的問題: 一個慢速消費的 stream,會把整條連線的緩衝吃光——想像瀏覽器同時下載一支影片和一份 JSON, 影片的 DATA frame 如果無限制地灌進來,JSON 就沒有緩衝空間可用了。

所以 HTTP/2 自建了一套流量控制,形式與 TCP/UDP 篇的滑動視窗一樣(接收端宣告可接收多少位元組,發送端不得超過), 但它是credit-based:接收端用 WINDOW_UPDATE frame 主動歸還額度,且分兩個層級——per-stream 與 per-connection。 和 TCP 的 rwnd 疊在一起就是這樣:

層控制單位誰在調節預設值調不好會怎樣
TCP 流量控制整條連線的位元組核心(rwnd / socket 緩衝)由核心自動調校吞吐撞上 BDP 上限
HTTP/2 connection 視窗這條 HTTP/2 連線的所有 DATAHTTP/2 實作65,535 位元組整條連線被限速
HTTP/2 stream 視窗單一 stream 的 DATAHTTP/2 實作65,535 位元組單一大檔傳輸被限速

注意兩件事:(1) 只有 DATA frame 受流量控制,HEADERS 與控制類 frame 不受。 (2) 那個 64 KB 的預設值是 2015 年訂的,在今天的長肥管線上小得可笑——TCP/UDP 篇算過 BDP: 100 Mbps × 100 ms 的鏈路,光是把管線填滿就要 1.25 MB。這就是為什麼 gRPC 大量傳輸的第一個調優動作,永遠是把視窗調大。

換個角度看,這整套機制就是 TCP/UDP 篇那條 backpressure 因果鏈在應用層的重演: 接收端來不及消費 → 不歸還額度 → 發送端被迫停下來。差別在於 HTTP/2 讓這件事可以精確到單一 stream, 而不是像 TCP 那樣只能一刀切整條連線。

5 · 優先序與 server push:兩個被現實否決的設計

多路復用帶來一個新問題:一百個 stream 同時要頻寬,先送誰?HTTP/2 原本的答案是一棵 優先序依賴樹(PRIORITY frame,可宣告「stream A 依賴 stream B」並附權重)。 它太複雜了:伺服器實作參差、瀏覽器送出的樹各不相同、中間 proxy 常直接忽略。 RFC 9113 已將整套依賴樹標為 deprecated,取而代之的是 RFC 9218 的 Priority 標頭—— 只有 u(緊急度 0–7)與 i(是否可漸進呈現)兩個參數,簡單到大家真的做得對。

優先序失效的後果在第 7 節的 demo 裡看得見:沒有優先序時,伺服器傾向平分頻寬,於是所有資源同時緩慢地下載、同時完成—— 總完成時間可能不錯,但「第一個能用的資源」出現得比 HTTP/1.1 還晚。這正是 HTTP/2 早期評測時常見的爭議。

Server Push 則是更著名的失敗。設計意圖很合理:伺服器回 HTML 時就順手把 CSS 與 JS 用 PUSH_PROMISE 推過去,省掉客戶端解析完 HTML 再來要的那一趟 RTT。實際上它輸在三件事:

  • 伺服器不知道客戶端的快取裡有什麼。回頭客早就存好了那些檔案,推過去的每個位元組都是純浪費, 而且是在最寶貴的連線初期浪費。(曾有 Cache Digest 提案想解決,未能標準化。)
  • 它與優先序打架。被推送的資源會跟客戶端真正想要的資源搶頻寬,反而拖慢關鍵路徑。
  • 幾乎沒人用對。大規模量測顯示多數部署的效果是負的。Chrome 在 106 版(2022)移除支援,主流伺服器隨後跟進。

取代它的是 103 Early Hints:伺服器在真正的回應還沒算完之前,先回一則 1xx 告訴瀏覽器「你等一下會需要這幾個資源,可以先去 preload」。差別在於誰做決定—— push 是伺服器強塞,Early Hints 是伺服器建議、客戶端查完自己的快取再決定要不要抓。後者贏了。

關鍵gRPC:把 HTTP/2 當成傳輸層來用

gRPC 不是「跑在 HTTP 上的 RPC」,它是直接把 HTTP/2 的 stream 當成 RPC 的基礎設施: 一個 RPC 呼叫佔一個 stream,因此天生支援四種模式——unary、server streaming、client streaming、bidirectional streaming; metadata 走 HEADERS;而最終的錯誤碼 grpc-status 走 trailer(訊息結束後才送的標頭), 這樣才能在串流跑完之後才回報結果——HTTP/1.1 對 trailer 的支援太差,所以 gRPC 從一開始就沒有 HTTP/1.1 版本。 連帶的硬性要求是:ALPN 必須協商到 h2(TLS 篇那張表的最後一列)。 這也解釋了為什麼瀏覽器不能直接講 gRPC——瀏覽器的 fetch API 給不了對 HTTP/2 frame 的精細控制, 只能靠 gRPC-Web 加一層 proxy 轉換。

6 · 關鍵轉折:解掉的是應用層 HoL,沒解掉的是 TCP 層 HoL

把前面幾節收攏起來:HTTP/2 確實把 第 5 節那個 pipelining 的 HoL Blocking解決得乾乾淨淨。 每個 frame 都帶 Stream ID,回應可以亂序完成,一支慢查詢再也卡不住其他請求。應用層的 HoL Blocking,結案。

但是往下看一層,問題還在原地。TCP/UDP 篇說得很清楚:TCP 對上層提供的是一條「不掉、不亂」的有序位元組串流。 「不亂」這個保證是有成本的——當某個 segment 遺失,接收端核心即使已經收到了它後面的所有資料, 也必須把它們扣在緩衝裡,不能交給應用,直到遺失的那一段被重傳補齊為止。TCP 沒有辦法「跳過」一個洞。

S1 DATA S3 DATA(遺失) S5 DATA S1 DATA S5 DATA ↑ 這四個 frame 都已抵達,卻全部交不上去

S3 的一個封包遺失,後面所有已經安全抵達的位元組——包括完全無關的 S1 與 S5——都被扣在 TCP 接收緩衝裡等它。HTTP/2 的多路復用在傳輸層是隱形的:TCP 只看得到一條位元組串流,看不到裡面有幾個 stream。

陷阱在高丟包鏈路上,HTTP/2 可能比 HTTP/1.1 慢

這是反直覺但真實的結論。HTTP/1.1 的 6 條連線是六條彼此獨立的 TCP 位元組串流—— 其中一條掉包停等,另外五條照跑不誤。HTTP/2 把所有東西收攏進一條連線,等於把所有雞蛋放進同一個籃子: 任何一個封包遺失,全部 stream 一起停等。
所以在乾淨的機房網路裡 HTTP/2 幾乎全面獲勝,但在丟包率高的行動網路、擁擠的公共 Wi-Fi 上, 多路復用的收益會被 TCP 的 HoL Blocking 吃掉一大塊,甚至轉為負值。這不是實作問題,是架構問題—— HTTP/2 在自己的層級上已經做對了每一件事。

問題出在哪,答案已經很清楚:不在 HTTP/2,而在它腳下那個「單一有序位元組串流」的抽象。 只要傳輸層還是 TCP,多路復用就永遠只能做到一半——上層分得再開,到了下層還是被壓回一條必須按序交付的隊伍。

一句話答法HTTP/2 用二進位框架層把訊息切成帶 Stream ID 的 frame,於是同一條連線上的請求可以交錯傳輸、亂序完成,並靠 HPACK 消掉重複標頭、靠 per-stream 流量控制避免單一 stream 吃光緩衝;它徹底解決了應用層的 HoL Blocking,卻解不掉 TCP 層的——一個封包遺失,所有 stream 一起停等,因為 TCP 看不見裡面有幾條 stream。
07

互動 · HTTP/1.1 vs HTTP/2 載入瀑布圖

同一批資源、同一條網路,只換連線模型:看「排隊」與「交握」這兩筆帳到底吃掉多少時間。

下面的模擬器把同一次頁面載入丟給兩種協定並排跑。流程固定:先抓 HTML 文件,解析出 N 個子資源, 然後 HTTP/1.1 把它們分派給最多 6 條連線(每條新連線都要先付 TCP + TLS 交握), HTTP/2 則在同一條既有連線上開 N 個 stream 一次全部送出。 兩邊共用同一條時間軸,所以長度可以直接比較。

兩個切換鈕分別對應本篇的兩個結論:網路情境驗證「HTTP/2 的收益與 RTT 成正比」, 連線策略則讓你親眼看到 domain sharding 當年為什麼有效、以及它為什麼在 HTTP/2 時代變成負擔。

互動 · HTTP/1.1 vs HTTP/2 載入瀑布圖 拉動資源數量、切換情境,看兩條時間軸的差距
排隊等連線 TCP + TLS 交握 請求飛行 + 伺服器處理 內容傳輸
拉動滑桿或切換情境即可重算;按「重新載入」重播動畫。
HTTP/1.1 · 6 連線—
HTTP/2 · 單連線多路復用—
連線與交握成本
HTTP/1.1 連線數—
HTTP/1.1 交握次數—
HTTP/2 交握次數1
被排隊擋住的請求—
結果對比
HTTP/1.1 總耗時—
HTTP/2 總耗時—
加速比—
標頭位元組(估算)—

四個值得停下來比對的畫面:(1) 保持 12 個資源,把 RTT 從 20ms 切到 150ms—— HTTP/1.1 的階梯整個被拉長,加速比從約 1.3 倍跳到約 1.6 倍:省下的每一格都是 RTT,所以延遲越高、收益越大。 (2) 把資源數拉到 24,看 HTTP/1.1 那側出現明顯的四層階梯——每一階就是一批 6 個請求在排隊等連線空出來。 (3) 打開 domain sharding:階梯確實變少了(這就是它當年有效的原因),但橘色的交握段暴增到 12 條—— 你是用交握成本換排隊成本,而 HTTP/2 兩筆都不用付。 (4) 看 HTTP/2 那側:所有綠色傳輸段幾乎同時開始、同時結束——這正是第 6 節說的「沒有優先序時伺服器平分頻寬」, 總時間漂亮,但「第一個可用資源」出現得不早。這也是 Priority 標頭想修的問題。

說明這個模型刻意不注入丟包

本 demo 假設網路是乾淨的,只比較連線數與排隊這一個變因,所以你看到的是 HTTP/2 的最佳情況。 真實世界還有另一個變因會反過來咬 HTTP/2:封包遺失。那正是第 6 節最後談的 TCP HoL Blocking,也是 QUIC 篇互動 demo 的主題—— 在那裡我們才會打開丟包開關,看單連線多路復用的另一面。此外本模型也簡化了頻寬爭用(sharding 的實際收益會比這裡顯示的更小)。

一句話答法HTTP/1.1 的時間軸是「六格一批」的階梯,時間花在等連線與重複交握;HTTP/2 一次把所有請求送出去,省下的正是這兩筆——而且 RTT 越高、資源越多,省得越多,這也是為什麼行動端與跨區服務升級 HTTP/2 的收益最明顯。
08

面試折衷與實務選型

HTTP 的面試題很少問「GET 是什麼」,而是問「你這個 API 為什麼能重試」「這份資源為什麼快取得住」「升級 HTTP/2 之後為什麼沒變快」。

決策點選項Trade-off
API 的寫入方法PUT(整份替換)vs POST + Idempotency-KeyPUT 天生冪等、中間層敢重試,但要求客戶端持有完整資源;POST 語義自由卻得自己實作冪等層與結果快取
快取策略版本化 URL + 長 max-age vs 短 max-age + ETag前者命中率最高、完全不發請求,但要求建置流程產生雜湊檔名;後者省事卻每次都付一個 RTT
驗證器ETag vs Last-ModifiedETag 精確但要伺服器算雜湊或維護版本;Last-Modified 便宜卻只有秒級精度、且對重新產生的檔案會誤判
重試策略只重試冪等方法 vs 全部重試全部重試會製造重複訂單;只重試冪等方法則要接受非冪等請求的失敗必須上拋——這就是 Idempotency-Key 的存在理由
連線協定HTTP/1.1 vs HTTP/2HTTP/2 在多資源、高 RTT 下大勝;但在丟包鏈路上會被 TCP HoL 吃掉收益,而且單連線也意味著 L4 負載均衡的分流粒度變粗
負載均衡層L4(讀 SNI)vs L7(終止 TLS 讀 Host/:authority)L4 不解密、成本低,但看不到路徑、無法做 HTTP/2 的 per-stream 分流與快取;L7 功能全開,代價是憑證管理與內網段明文
後端到後端REST over HTTP/2 vs gRPCgRPC 有 schema、雙向串流與 trailer 狀態碼,但強制 h2、瀏覽器不可直達、除錯工具鏈較重;REST 通用、可快取、人人看得懂
HTTP/2 調優預設 64KB 視窗 vs 調大調大能吃滿高 BDP 鏈路,但緩衝記憶體 × 連線數會線性膨脹——這是吞吐與記憶體的直接交換

共同結構:語義層的每個決策都在買「中間層願意替你做什麼」——冪等性買重試,快取標頭買 CDN,狀態碼買正確的錯誤處理。傳輸層的決策則清一色在延遲、吞吐與維運複雜度之間挑兩個。

面試高頻追問怎麼答

Q:PUT 和 POST 差在哪? 答:不是「更新 vs 新增」,而是冪等 vs 不冪等——PUT 是整份替換,送十次結果相同,所以中間層敢自動重試;POST 語義由伺服器定義,重試可能產生重複副作用。
Q:DELETE 第二次回 404,還算冪等嗎? 答:算。冪等約束的是伺服器狀態,不是回應內容——刪完之後「那筆資料不存在」這件事沒有再改變。
Q:no-cache 和 no-store 差在哪? 答:no-cache 可以存,但每次使用前都要向伺服器驗證(多半換到一個 304);no-store 是完全不准落地,用於含敏感資料的回應。
Q:為什麼瀏覽器對同一個網域只開 6 條連線? 答:因為 HTTP/1.1 一條連線同時只能跑一個請求,並行度=連線數;但連線再多會各自付交握與 slow start、互相搶頻寬、加重伺服器負擔,6 是經驗折衷。
Q:HTTP/2 為什麼比 HTTP/1.1 快? 答:三件事——單連線多路復用消掉請求排隊與重複交握、HPACK 消掉重複標頭、二進位 framing 讓解析更便宜。收益與 RTT 及資源數成正比。
Q:升級了 HTTP/2 卻沒變快,可能是什麼原因? 答:先確認 ALPN 真的協商到 h2(邊緣沒開就一路 HTTP/1.1);再檢查是不是還留著 domain sharding 或超大 bundle 這些 1.1 時代的 workaround;最後看網路品質——丟包高的鏈路上 TCP HoL 會吃掉多路復用的收益。
Q:HTTP/2 有多路復用,為什麼還會塞住? 答:它解的是應用層 HoL,TCP 層的還在。所有 stream 都在同一條有序位元組串流上,一個封包遺失就全部一起等。

把本篇收攏成一句:HTTP 的語義三十年沒變,變的一直是「怎麼把這些語義送過去」。 方法與狀態碼是給中間層的契約,快取標頭決定誰能替你省掉一趟往返, 而連線模型從「一請求一連線」走到「六條連線」再走到「一條連線上百個 stream」,每一步都在追同一件事:把請求之間不必要的等待砍掉。

但砍到最後,剩下一個砍不動的。HTTP/2 把六條連線收攏成一條,代價是所有 stream 都躺在同一條 TCP 位元組串流上——丟一個封包,全部一起等。 問題不出在 HTTP/2,而出在它腳下那個「有序位元組串流」的抽象:只要傳輸層仍然是 TCP,多路復用就永遠只做到一半。 接下來的 QUIC 篇處理這件事,而且用的是最徹底的辦法——不要 TCP 了。

還有另一件 HTTP 做不到的事,比 HoL Blocking 更根本: 從 HTTP/1.1 到 HTTP/2,每一個位元組的流動都始於客戶端的一次請求。 server push 是唯一一次打破它的嘗試,而它失敗了——因為它推的仍然是「客戶端接下來會請求的東西」, 而不是「伺服器現在想說的話」。當伺服器需要主動開口——推播、行情跳動、聊天訊息、協作游標—— 請求/回應這個模型就到頂了。WebSocket 篇會從第 3 節提過的那個 101 Switching Protocols 出發,把這條連線變成真正雙向的。

一句話答法選型時語義層買的是「中間層願意替你做什麼」(冪等買重試、快取標頭買 CDN、狀態碼買正確的錯誤處理),傳輸層買的是延遲;HTTP/2 用單連線多路復用買到最大一筆延遲收益,但它把所有 stream 綁在同一條 TCP 串流上,也因此把 HoL Blocking 從應用層推到了傳輸層——那是下一代協定要動刀的地方。