總覽與定位
HTTP 是通道上的語法與語義;HTTP/2 一個方法、一個狀態碼都沒改,它換掉的是這些語義在線上怎麼編碼、怎麼共用連線。
HTTP(HyperText Transfer Protocol)是一個無狀態的請求/回應協定:客戶端送出一個方法加一個目標資源加一組標頭(可能還有 body),伺服器回一個狀態碼加一組標頭(可能還有 body)。就這樣,三十年沒變。
前一篇的 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 怎麼把同一套語義送上線。
Host 必填、chunked、快取驗證。並行度=連線數。把這件事講得更白一點: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 方法上掛著三個正交的性質,決定了瀏覽器、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:
425 是伺服器對「我還不確定這是不是重放」的標準回覆,客戶端必須在交握完成後以正常方式重送。它讓「要不要冒重放風險」這個決定從 TLS 層下放到 HTTP 層:反向代理可以只對 GET/HEAD 放行 early data,對其餘方法一律回 425。這是一個很好的例子——傳輸層的安全折衷,最後要靠應用層的語義來兜底。
協定說 PUT 冪等,但如果你的 handler 每次被呼叫都往稽核表插一筆、發一封信、扣一次庫存,
那它在事實上就不冪等——你只是騙了中間層。反向的問題更常見:POST /orders 天生不冪等,
但業務上又必須容忍重試(網路斷線、使用者連點、上游 retry 中介)。標準解法是由應用自己補上冪等層:
客戶端產生一個 Idempotency-Key,伺服器以它為鍵記錄「這個請求已處理過,結果是這個」,重複進來就直接回放先前的結果。
支付與金流 API 幾乎清一色採用這個模式。
另外兩個高頻誤用:用 GET 做有副作用的操作(GET /users/1/delete)——
爬蟲、瀏覽器 prefetch、防毒軟體的連結掃描都會替你按下去;用 POST 做查詢——
換來的是不可快取、不可安全重試、不能收藏成 URL。
狀態碼與標頭:寫給中間層看的契約
狀態碼不是給前端 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 要動刀的地方。
快取與 proxy:最快的請求是沒發出的請求
HTTP 快取只回答兩個問題:這份東西還新鮮嗎(不用問伺服器)?過期了的話,它真的變了嗎(問一句就好)?
這兩個問題對應兩套機制,很多人把它們混成一團:
| 機制 | 回答的問題 | 成本 | 關鍵標頭 |
|---|---|---|---|
| 新鮮度 Freshness | 還在有效期內嗎? | 零——完全不發請求 | Cache-Control: max-age、Expires、Age |
| 驗證 Validation | 過期了,內容真的變了嗎? | 一次往返,但省下 body | ETag / If-None-Match、Last-Modified / If-Modified-Since |
關鍵差異:新鮮度省的是整個 RTT,驗證只省 body。在高延遲鏈路上,一個 304 跟一個 200 的延遲幾乎一樣——所以能靠 max-age 解決的,就別靠 ETag。
Cache-Control 指令 | 實際意思 | 典型用法 |
|---|---|---|
max-age=N | N 秒內都算新鮮,任何快取都可直接用 | 靜態資源 |
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 優先。
共用快取以「方法 + URL」為鍵,但同一個 URL 可能因為請求標頭不同而回不一樣的內容(壓縮格式、語言、裝置)。
Vary 就是伺服器告訴快取:「把這些標頭也算進快取鍵」。
Vary: Accept-Encoding 是必備的(否則可能把 br 壓縮的內容餵給不支援的客戶端);
但 Vary: User-Agent 幾乎等於關掉快取——User-Agent 的取值近乎無限,命中率會直接歸零。
需要依裝置分流時,正確做法是在邊緣把 UA 正規化成少數幾個值再 Vary。
proxy 與 CDN:快取住在哪一層
私有快取 → Forward Proxy
(客戶端側 · 出口管控) → CDN Edge
(共用快取 · 地理分散) → Reverse Proxy
(LB · TLS 終止) → Origin
三種 proxy 的差別在「替誰工作」:forward proxy 站在客戶端這一側(企業出口、爬蟲池),客戶端知道它存在;reverse proxy 站在伺服器這一側(Nginx、Envoy、ALB),客戶端以為它就是伺服器;CDN 本質上是一大群地理分散的 reverse proxy,額外加上共用快取與就近接入。private 與 public 這組指令,決定的正是「中間那三格能不能存你的回應」。
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, immutable | URL 已含內容版本,內容變=URL 變,永遠不需要重新驗證 |
| HTML 入口文件 | no-cache + ETag | 必須每次確認,才能讓上面那條「永久快取」安全成立 |
| API 的 GET 回應 | 短 max-age + stale-while-revalidate | 擋住尖峰重複查詢,同時不讓資料太舊 |
| 登入後的個人化內容 | private, no-store | 絕不能落進任何共用快取 |
這套組合叫 cache busting:把版本資訊放進 URL,用一個必須驗證的 HTML 去指向一堆永遠不必驗證的資源。它讓「快取失效」這個分散式系統難題退化成「改一個檔名」。
連線重用: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 的訊息語法要求一條連線上同時只能有一個進行中的請求/回應交換:送出請求 → 等回應完整收完 → 才能送下一個。於是:
瀏覽器給出的答案是:對同一個 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 就能發完所有請求。它徹底失敗了,原因有四層:
- 回應必須按請求的順序回。HTTP/1.1 的訊息沒有任何識別碼,接收端只能靠「順序」把回應對回請求。 於是第一個請求若是一支慢查詢,後面九個明明早就算完,也只能排隊等它——這就是 HoL Blocking, TCP/UDP 篇已經完整定義過的那個現象,在應用層的第一次現身。
- 中間設備實作破碎。大量 proxy、防火牆與伺服器不正確處理 pipelined 請求,會亂序、截斷或直接斷線。
- 失敗處理是惡夢。連線中斷時,客戶端不知道那批請求裡哪些已被處理——對非冪等方法完全無法安全重送(第 2 節的規則在這裡發威)。
- 收益有限。它省的是請求送出的 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/2:二進位框架、多路復用與它沒解決的那一半
同一套語義,換一種送法:把文字換成 frame、把 6 條連線收成 1 條——然後撞上一堵它自己拆不掉的牆。
1 · 二進位框架層:把訊息切成有標籤的小塊
HTTP/2 在 TLS 與 HTTP 語義之間插入一個新的層:二進位框架層(Binary Framing Layer)。應用看到的還是「請求/回應」,但真正送上線的是一連串固定格式的 frame,每個 frame 前面是 9 個位元組的標頭:
這正是 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:多路復用的三層結構
HEADERS 加零到多個 DATA 組成。同一條 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 做不到。
從「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 連線的所有 DATA | HTTP/2 實作 | 65,535 位元組 | 整條連線被限速 |
| HTTP/2 stream 視窗 | 單一 stream 的 DATA | HTTP/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 上的 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 沒有辦法「跳過」一個洞。
S3 的一個封包遺失,後面所有已經安全抵達的位元組——包括完全無關的 S1 與 S5——都被扣在 TCP 接收緩衝裡等它。HTTP/2 的多路復用在傳輸層是隱形的:TCP 只看得到一條位元組串流,看不到裡面有幾個 stream。
這是反直覺但真實的結論。HTTP/1.1 的 6 條連線是六條彼此獨立的 TCP 位元組串流——
其中一條掉包停等,另外五條照跑不誤。HTTP/2 把所有東西收攏進一條連線,等於把所有雞蛋放進同一個籃子:
任何一個封包遺失,全部 stream 一起停等。
所以在乾淨的機房網路裡 HTTP/2 幾乎全面獲勝,但在丟包率高的行動網路、擁擠的公共 Wi-Fi 上,
多路復用的收益會被 TCP 的 HoL Blocking 吃掉一大塊,甚至轉為負值。這不是實作問題,是架構問題——
HTTP/2 在自己的層級上已經做對了每一件事。
問題出在哪,答案已經很清楚:不在 HTTP/2,而在它腳下那個「單一有序位元組串流」的抽象。 只要傳輸層還是 TCP,多路復用就永遠只能做到一半——上層分得再開,到了下層還是被壓回一條必須按序交付的隊伍。
互動 · 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 時代變成負擔。
四個值得停下來比對的畫面:(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 的面試題很少問「GET 是什麼」,而是問「你這個 API 為什麼能重試」「這份資源為什麼快取得住」「升級 HTTP/2 之後為什麼沒變快」。
| 決策點 | 選項 | Trade-off |
|---|---|---|
| API 的寫入方法 | PUT(整份替換)vs POST + Idempotency-Key | PUT 天生冪等、中間層敢重試,但要求客戶端持有完整資源;POST 語義自由卻得自己實作冪等層與結果快取 |
| 快取策略 | 版本化 URL + 長 max-age vs 短 max-age + ETag | 前者命中率最高、完全不發請求,但要求建置流程產生雜湊檔名;後者省事卻每次都付一個 RTT |
| 驗證器 | ETag vs Last-Modified | ETag 精確但要伺服器算雜湊或維護版本;Last-Modified 便宜卻只有秒級精度、且對重新產生的檔案會誤判 |
| 重試策略 | 只重試冪等方法 vs 全部重試 | 全部重試會製造重複訂單;只重試冪等方法則要接受非冪等請求的失敗必須上拋——這就是 Idempotency-Key 的存在理由 |
| 連線協定 | HTTP/1.1 vs HTTP/2 | HTTP/2 在多資源、高 RTT 下大勝;但在丟包鏈路上會被 TCP HoL 吃掉收益,而且單連線也意味著 L4 負載均衡的分流粒度變粗 |
| 負載均衡層 | L4(讀 SNI)vs L7(終止 TLS 讀 Host/:authority) | L4 不解密、成本低,但看不到路徑、無法做 HTTP/2 的 per-stream 分流與快取;L7 功能全開,代價是憑證管理與內網段明文 |
| 後端到後端 | REST over HTTP/2 vs gRPC | gRPC 有 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 出發,把這條連線變成真正雙向的。