總覽與定位
QUIC 不是「HTTP 的新版本號」,而是一個跑在 UDP 上的通用傳輸協定;HTTP/3 只是它第一個、也最大的客戶。
前一篇的 HTTP 篇停在一句話上:問題不出在 HTTP/2,而出在它腳下那個「有序位元組串流」的抽象——只要傳輸層仍然是 TCP,多路復用就永遠只做到一半。本篇兌現那句「不要 TCP 了」。
值得先把「一半」是哪一半講精確。HTTP/2 在自己的層級上做得無可挑剔:每個 frame 都帶 Stream ID,回應可以亂序完成。但這個「亂序」只存在於 HTTP/2 的視野裡——交到 TCP 手上時,它們被壓成了一條必須按序交付的隊伍。TCP 對上層的承諾是「不掉、不亂」,而「不亂」意味著它不能跳過任何一個洞:中間少一段,後面所有已經安全抵達的位元組都得扣在核心緩衝裡等。TCP 不知道那些位元組屬於三個互不相干的 stream,因為位元組串流這個抽象本身就沒有「裡面有幾條 stream」這個概念。
要解掉這件事,只有兩條路:讓 TCP 學會「部分有序交付」,或者不要 TCP。前者被證明走不通——TCP 跑在核心裡、被全球的中間設備(NAT、防火牆、負載均衡器)盯著看,任何新選項都可能被丟棄或改寫,光是讓一個新版本普及就要十年以上。
於是 QUIC 選了第二條路:躲進 UDP 這個中間設備不敢多管的載體裡,在使用者空間把可靠傳輸整套重做一遍,而且這一次讓有序保證的粒度從「整條連線」降到「單一 stream」。這正是 TCP/UDP 篇那句話的兌現——「需要 stream 之間互不阻塞、或需要換網不斷線時,只能在 UDP 上重做」。
QUIC 由 RFC 9000(傳輸)、RFC 9001(與 TLS 1.3 的整合)、RFC 9002(遺失偵測與壅塞控制)三份文件定義,另有 RFC 8999 規定「哪些欄位永遠不會變」。把它與舊堆疊並排看,最直觀:
注意 HTTP/3 那一格變薄了:多路復用、流量控制、連線管理全部下沉進 QUIC。這是本篇的主軸——不是 HTTP 學會了新把戲,而是傳輸層終於提供了 HTTP/2 一直想要卻要不到的東西。
QUIC 是通用傳輸協定,位置等同 TCP,不綁任何應用層語義。HTTP/3(RFC 9114)只是「把 HTTP 語義映射到 QUIC」的那一份規格。已經在用 QUIC 的其他客戶包括:DNS over QUIC(RFC 9250,繼 DoT/DoH 之後的第三種加密 DNS)、WebTransport(瀏覽器直接使用 QUIC stream 與 datagram)、MASQUE(RFC 9298,用 HTTP 通道代理 UDP 流量)、以及 SMB over QUIC。
面試被問到時,把「QUIC 在哪一層、HTTP/3 在哪一層」講清楚,遠比背出 0-RTT 加分。
| 職責 | HTTP/2 堆疊由誰負責 | HTTP/3 堆疊由誰負責 | 本篇章節 |
|---|---|---|---|
| 連線建立與金鑰協商 | TCP 交握 + TLS 交握(兩趟) | QUIC 交握(合併成一趟) | 第 3 節 · 交握合一 |
| 可靠性與遺失復原 | TCP(核心) | QUIC(使用者空間,可換演算法) | 第 2、4 節 |
| 多路復用與有序保證 | HTTP/2 分 stream,TCP 又壓回一條 | QUIC stream,彼此完全獨立 | 第 4 節 · Stream 獨立性 |
| 流量控制 | TCP rwnd + HTTP/2 兩級,共三層 | QUIC 兩級(HTTP/3 不再自己做) | 第 4 節 · 兩級流量控制 |
| 連線識別 | 五元組(換網即斷) | Connection ID(換網不斷) | 第 5 節 · Connection Migration |
| 標頭壓縮 | HPACK(依賴嚴格順序) | QPACK(容忍亂序) | 第 6 節 · QPACK |
這張表就是本篇地圖。每一列的右欄都不是「HTTP/3 的新功能」,而是QUIC 的能力——HTTP/3 只是第一個把它們用起來的協定。
在 UDP 上重建 transport:那張工作清單的交卷
TCP/UDP 篇列過「在 UDP 上造輪子」的五項工作。QUIC 就是那張清單被認真做完的樣子——而且每一項都趁機改掉了 TCP 想改卻改不動的地方。
TCP/UDP 篇談 UDP 時列了一張表:TCP 直接給你的東西,在 UDP 上全部得自己做。當時說那是「所有在 UDP 上造輪子的專案的工作清單,每一列都是實打實的工程量」。現在把 QUIC 的答案填進去:
| 需求 | TCP/UDP 篇:UDP 上要自己做 | QUIC 怎麼交卷 | 相對 TCP 的改動 |
|---|---|---|---|
| 知道對方收到了 | 自訂應用層 ACK 與序號 | ACK frame,可攜帶多段 ACK Range | 等於強制版 SACK,且多回報一個 ACK Delay 讓 RTT 估得更準 |
| 補回遺失資料 | 自行重送,或用 FEC 冗餘編碼 | 重傳「資料」而不是「封包」 | 遺失的內容換一個新的 packet number 重送,消掉 TCP 的重傳歧義 |
| 順序正確 | 自帶序號並在應用層重排 | 每個 stream 各自持有 byte offset | 排序只在 stream 內部生效——本篇的核心,見第 4 節 |
| 不壓垮網路 | 自行實作速率估計與退讓 | 完整壅塞控制(RFC 9002) | 演算法在使用者空間,換 CUBIC/BBR 不必動核心 |
| 連線概念 | 自訂 session id(好處:換網不斷線) | Connection ID | 連線識別脫離五元組,換網不斷線,見第 5 節 |
五列全數交卷。重點在最右欄:QUIC 不是把 TCP 原封不動搬到 UDP 上,而是趁著「反正要重寫一遍」,把 TCP 因為相容性包袱而動不了的設計一次改掉。
1 · 兩層資料單位:packet 裝 frame
QUIC 有兩個容易混淆的層級。一個 UDP 資料報裡放一個或多個 QUIC packet(交握期間常把不同加密等級的 packet 併在同一個資料報送出,稱為 coalescing);每個 QUIC packet 的 payload 裡則放一串 frame。真正表達語義的是 frame,packet 只是加密與遺失偵測的單位:
| 用途分類 | frame 型別 | 對應 TCP 在核心裡做的哪件事 |
|---|---|---|
| 承載資料 | STREAM、CRYPTO、DATAGRAM | 送出位元組。差別是 QUIC 分得出「這些位元組屬於哪條 stream」,而 CRYPTO 讓交握訊息與資料走同一套機制 |
| 確認與探測 | ACK、PING | TCP 標頭裡的 ACK 欄位與 keep-alive。ACK frame 帶的是 ACK Range(可表達多個缺口),比 TCP 的累積 ACK + SACK 選項乾淨 |
| 流量控制 | MAX_DATA、MAX_STREAM_DATA、MAX_STREAMS | TCP 標頭的 Window 欄位。QUIC 拆成連線級與 stream 級兩層,還多控制「能開幾條 stream」 |
| 連線管理 | RESET_STREAM、STOP_SENDING、NEW_CONNECTION_ID、PATH_CHALLENGE | TCP 的 RST 與五元組綁定。取消單條 stream 不必砍掉整條連線,換位址也不必重建 |
四類 frame 恰好對應 TCP 在核心裡做的四件事:送資料、確認、限流、管連線。對照 HTTP 篇的 HTTP/2 frame 型別表會發現大半是同名的——因為 HTTP/2 當年在應用層做的那些事,現在被搬到傳輸層做了一遍;差別是這裡的 MAX_STREAM_DATA 管的是傳輸層的 stream,不是 HTTP 的 stream。
2 · 一個 packet 長什麼樣:明文只剩不變量
交握期的 Long Header 大致如此;交握完成後的 1-RTT packet 只剩「首位元組 + Destination Connection ID + 受保護的 Packet Number + 加密 payload」,連版本欄位都不再出現。藍色是路徑上看得見的部分,綠色連 packet number 都經過 header protection 加密。
Ossification(骨化):只要一個欄位在路徑上是明文,中間設備遲早會依賴它;一旦依賴形成,這個欄位就再也改不動了。TCP 就是這樣被凍住的——某些防火牆會丟棄帶有未知選項的封包、某些 NAT 會改寫序號、某些設備會把它看不懂的封包直接砍掉。結果是 TCP Fast Open、Multipath TCP 這類改良推了十幾年仍難以普及。
QUIC 的對策是把「可見」變成一種明確授權:RFC 8999 只宣告極少數欄位為不變量(首位元的 Header Form、版本欄位、Connection ID 的位置與長度),其餘一律加密——連 packet number 都套上 header protection。中間設備不可能依賴一個它看不到的欄位,於是協定保住了未來繼續演進的權利。這個決定的帳單會在第 8 節出現:你自己的 SRE 也一樣看不到。
3 · 順手修掉的 TCP 老問題:重傳歧義
TCP 的序號綁在位元組位置上,所以重傳的封包會沿用原本的序號。這帶來一個經典麻煩:收到 ACK 時,發送端無法分辨它確認的是原始封包還是重傳封包,RTT 樣本因此可能算錯一整個重傳逾時(TCP 的處理是 Karn 演算法——乾脆放棄重傳封包的 RTT 樣本,等於白白丟掉觀測值)。
QUIC 把兩件事拆開:packet number 單調遞增、永不重複,資料在哪個 stream 的哪個位置則由 STREAM frame 自己的 offset 表達。同一段資料重傳時會裝進一個編號更大的新 packet。於是每個 ACK 都精確對應唯一一次傳送,RTT 樣本永遠乾淨,遺失判定也不再有歧義。
QUIC 的 packet number 不是全連線一個序列,而是分成 Initial/Handshake/Application Data 三個獨立空間,各自編號、各自確認。理由是這三種 packet 用不同的金鑰保護,且到達順序無法保證——交握期的 packet 與 1-RTT packet 混在一起編號會讓遺失偵測失去意義。這也是為什麼 QUIC 的遺失偵測邏輯要對每個空間各跑一份。
交握合一:內建 TLS 1.3、1-RTT 與 0-RTT
TLS 篇留了一個問題:如果傳輸層的交握與加密的交握本來就是同一件事,為什麼要分兩次做?這一節是它的答案。
TLS 篇在結尾算過一筆帳,並在後面加了一句挑釁:
這 2-RTT 的成因很單純:TCP 交握完成之前,TLS 連一個位元組都送不出去——因為 TLS 是「跑在」TCP 之上的另一層。兩層各自有自己的握手儀式,而它們協商的其實是同一件事的兩半:一半是「我們要通訊」(序號、視窗、選項),另一半是「我們要安全地通訊」(金鑰、身分、參數)。
QUIC 的處理不是「把 TLS 跑在 QUIC 上」,而是把 TLS 1.3 嵌進 QUIC 內部(RFC 9001)。具體來說:TLS 交握訊息裝在 CRYPTO frame 裡跟著 QUIC packet 一起飛,TLS 的 record layer 被整個拿掉,TLS 只負責產出金鑰,加密與完整性保護由 QUIC 的 packet protection 執行。結果是一趟往返同時完成四件事:
sequenceDiagram
participant C as Client
participant S as Server
C->>S: Initial · CRYPTO(ClientHello) + transport params
Note over C,S: 這一趟同時談妥四件事
S-->>C: Initial + Handshake · CRYPTO(ServerHello, 憑證, Finished)
C->>S: 1-RTT · 第一個應用位元組
四件事——建立連線狀態、ECDHE 金鑰協商與身分驗證、ALPN 選定 h3、交換 transport parameters——全部裝在同一趟往返的 CRYPTO frame 裡。TCP + TLS 之所以要 2-RTT,不是因為要做的事比較多,而是因為那些事分屬兩層、各自要一次交握。
那個 transport parameters 是 QUIC 特有的一環:初始流量控制視窗、最多可開幾個 stream、閒置逾時、是否停用 connection migration、伺服器的 preferred address……這些在 TCP 世界裡分散在核心參數與 TCP 選項的東西,現在跟著加密的交握一起協商,中間設備既看不到也改不掉。
| 階段 | TCP + TLS 1.3 | QUIC |
|---|---|---|
| 首次連線到可送應用資料 | 2-RTT | 1-RTT |
| Resumption(不帶 early data) | 2-RTT(TCP 那趟省不掉) | 1-RTT |
| Resumption + 0-RTT | 1-RTT(仍要先建 TCP) | 0-RTT |
| 誰保護資料完整性 | TLS record layer | QUIC packet protection |
| 交握內容對中間設備 | TCP 選項明文、SNI 明文 | 除不變量外全加密 |
最值得記的是第二列:在 TCP 上,session resumption 永遠省不掉那趟 TCP 交握——TLS 篇說過「單純的 resumption 仍是 1-RTT,省的是憑證傳輸與非對稱簽章」,加上 TCP 那趟之後,首位元組前還是 2-RTT。QUIC 沒有這個下限,因為它根本沒有「另一層的交握」要等。
0-RTT:加密面完全沿用 TLS 篇,傳輸面多一個問題要處理
QUIC 的 0-RTT 就是 TLS 1.3 的 0-RTT:同一套 PSK ticket、同一份 early data、同樣的兩個結構性代價(無前向保密、可被重放)與同樣的三層防守(單次票券/時效窗+重放偵測/應用層只放冪等請求)。TLS 篇已經把成因與防線論證完整,這裡不重複——結論一模一樣:early data 只能承載被重放一百次也不會多出一筆訂單的請求,而 HTTP 這一層對應的訊號仍是 425 Too Early。
QUIC 真正額外新增的,是一個傳輸層才有的問題:位址驗證。TCP 的三向交握順便證明了「客戶端真的收得到送往這個 IP 的封包」(因為它必須回 ACK 才建得成連線)。UDP 沒有這道程序,攻擊者可以偽造來源 IP 發出一個小小的 Initial packet,讓伺服器把大量交握資料(憑證鏈動輒數 KB)轟向受害者——這就是放大攻擊(Amplification Attack)。QUIC 用三道機制堵住:
| 機制 | 作法 | 代價 |
|---|---|---|
| Anti-amplification limit | 位址驗證完成前,伺服器送出的位元組不得超過收到的 3 倍 | 憑證鏈太大時交握會被迫分批,多花往返 |
| Initial packet 補齊 | 客戶端的 Initial packet 必須 padding 到至少 1200 位元組 | 浪費一點頻寬,換來 3 倍上限有實際意義,順便確保路徑撐得住 1200 |
| Retry + token | 伺服器可回一個 Retry packet 要求客戶端帶 token 再來一次,token 即位址證明 | 多付一個 RTT;通常只在被攻擊時才啟用 |
伺服器還可以用 NEW_TOKEN frame 事先發一張 token 給客戶端,讓下一次連線一開始就帶著位址證明,直接跳過 Retry 那趟。這與 TLS 篇的 session ticket 是不同的東西:ticket 證明「你是上次那個人」,token 證明「你真的在這個 IP 上」,兩者可以同時使用。
CRYPTO frame、record layer 拿掉),所以連線建立、金鑰協商、ALPN 與 transport parameters 在同一趟完成,首次連線 1-RTT、resumption 可到 0-RTT;0-RTT 的重放風險與 TLS 完全相同,QUIC 額外要處理的是 UDP 特有的放大攻擊,靠 3 倍上限、Initial padding 與 Retry token 三道防線。
Stream 獨立性、遺失復原與兩級流量控制
這是 QUIC 相對 HTTP/2 最核心的一項:讓多路復用真正做到底——並且要誠實說明它沒解掉什麼。
1 · 有序保證的粒度
QUIC 的 stream 由一個 62 位元的變長整數識別,最低兩個位元直接編碼了語義:一個位元表示「誰發起的」(客戶端/伺服器),另一個位元表示「雙向還是單向」。所以 HTTP 篇那個「客戶端用奇數、伺服器用偶數」的約定在這裡被吸收進 ID 本身,而且多出了單向 stream 這個新工具——第 6 節的 QPACK 與 control stream 全都靠它。
真正的關鍵只有一句話:
Stream A 的資料遺失,只會擋住 stream A 後續的資料;stream B、C 已抵達的部分照樣立刻交給應用。把 HTTP 篇那張圖重畫一次就看得很清楚:
對照 HTTP 篇的同一張圖:在 TCP 上,那六格全部要扣在核心緩衝裡等。差別不在 HTTP 這一層做了什麼,而在下面那一層願不願意承認「這些位元組其實不相干」。
這題面試很愛追問,答「QUIC 解決了 HoL Blocking」會被扣分。精確的說法是「消掉了傳輸層跨 stream 的那一種」,以下三種仍然存在:
1. Stream 內部的 HoL 依舊存在——而且是刻意保留的。單一 stream 內仍然有序,所以一支大檔下載掉包時,該 stream 一樣要等重傳。如果你的流量本來就是「一條大 stream」,QUIC 在這一項上幫不了忙。
2. 壅塞控制仍然是全連線一份。Stream 獨立不等於免疫丟包——路徑嚴重擁塞時,壅塞視窗縮小會讓所有 stream 一起變慢,只是不會互相「停等」。
3. QPACK 可能重新引入 stream 級的阻塞(第 6 節會說明,而且那是可調的)。
2 · 遺失復原:把 TCP 的補丁重寫成內建設計
RFC 9002 的遺失偵測與 TCP 的觀念一脈相承(TCP/UDP 篇已把快速重傳、RTO、SACK 講過),差別在於它是重新設計而不是三十年疊上去的補丁:
| 面向 | TCP | QUIC(RFC 9002) |
|---|---|---|
| 選擇性確認 | SACK 是可選選項,且範圍數量受選項空間限制 | ACK frame 內建多段 ACK Range,數量不受選項欄位擠壓 |
| RTT 量測 | 重傳歧義 → Karn 演算法丟棄樣本 | packet number 唯一,樣本永遠有效;再加上明確回報的 ACK Delay |
| 遺失判定 | 3 個重複 ACK(快速重傳) | 封包門檻(落後 3 個)或時間門檻(9/8 × 最近 RTT),兩者取先觸發 |
| 逾時重傳 | RTO + Tail Loss Probe 等多個機制疊加 | 統一為 PTO(Probe Timeout),逾時先送探測而非立即崩掉壅塞視窗 |
| 壅塞控制演算法 | 核心提供,換演算法要動作業系統 | 使用者空間實作,可依服務特性換(CUBIC/BBR)甚至 A/B 測試 |
最後一列在實務上被低估了:壅塞控制的迭代速度從「作業系統升級週期」變成「應用發版週期」。這是 QUIC 跑在使用者空間最被忽略的紅利,也是第 8 節要跟 CPU 成本一起放上天秤的東西。
3 · 流量控制:兩級,而且是絕對值
HTTP 篇那張表列了三層疊在一起的流量控制:TCP 的 rwnd、HTTP/2 的 connection 視窗、HTTP/2 的 stream 視窗。QUIC 把它收斂成兩級,而且 HTTP/3 不再有自己的流量控制——這一層整個下沉了:
| 層級 | HTTP/2 堆疊 | QUIC / HTTP/3 堆疊 | 差異重點 |
|---|---|---|---|
| 連線層 | TCP rwnd + HTTP/2 connection 視窗(預設 65,535) | MAX_DATA | 兩層併成一層 |
| Stream 層 | HTTP/2 stream 視窗(預設 65,535) | MAX_STREAM_DATA | 同樣是 credit-based |
| 並行度 | SETTINGS_MAX_CONCURRENT_STREAMS | MAX_STREAMS frame | 從「一個設定值」變成可隨時追加的額度 |
| 額度的表達方式 | WINDOW_UPDATE 送增量 | 送絕對上限 | 亂序或重複抵達也不會算錯,遺失一則只要下一則更大就自動追上 |
最後一列是個小而漂亮的設計。HTTP/2 的增量式視窗必須靠 TCP 的可靠有序來保證不重不漏;QUIC 既然允許亂序,就把額度改成冪等的絕對值——「你最多可以送到第 N 個位元組」,收兩次或亂序收都不會出錯。這是「不能依賴順序」如何反過來讓設計變乾淨的典型例子。
值得注意的是,TCP/UDP 篇那條 backpressure 因果鏈(接收端不消費 → 不歸還額度 → 發送端停下來)在這裡完全成立,只是換了 frame 名稱。而 RESET_STREAM(我不送了)與 STOP_SENDING(你別送了)這對取消機制,比 HTTP/2 只有單一 RST_STREAM 更精確:取消一個下載時,你要表達的是「別再送」,而不是「這條 stream 出錯了」。
MAX_DATA / MAX_STREAM_DATA 兩級並改用絕對上限——但 stream 內部的 HoL 與全連線共用的壅塞控制都還在。
Connection Migration:連線識別脫離五元組
TCP/UDP 篇那張 UDP 造輪子清單的最後一列寫著「自訂 session id(好處:換網不斷線)」。這一節就是那個伏筆的兌現。
TCP/UDP 篇定義過:一條 TCP 連線在核心裡的唯一識別是五元組(協定、來源 IP、來源 port、目的 IP、目的 port)。這個定義有個直接後果——只要來源位址變了,那就是另一條連線。手機從 Wi-Fi 走到 4G、筆電從有線切到無線、NAT 映射逾時後重新綁定,五元組全都會變,舊連線在伺服器眼裡瞬間變成陌生封包,只能重建:TCP 三向交握、TLS 交握、HTTP/2 的 SETTINGS 與 HPACK 動態表全部從零來過。
QUIC 的作法是把識別從「位址」換成一個與位址無關的標籤:
| 連線識別 | TCP | QUIC |
|---|---|---|
| 識別是什麼 | 五元組(協定, srcIP, srcPort, dstIP, dstPort) | Connection ID 一段與位址無關的不透明位元組 |
| 誰決定這個識別 | 由路徑決定——雙方都沒得挑,位址是什麼就是什麼 | 由接收方自己挑:「你要送封包給我,請在 Destination Connection ID 欄位寫這個值」 |
| 位址改變後 | 五元組不再匹配 → 那就是另一條連線,只能整條重建 | DCID 不變 → 查表仍命中同一條連線,位址可以隨便換 |
差別只有一行:TCP 的連線識別由「路徑」決定,QUIC 的由「端點自己挑的標籤」決定——所以路徑可以換,連線不必換。
Connection ID(CID)是一段不透明的位元組,由接收方自己挑選——「你要送封包給我,請在 Destination Connection ID 欄位寫這個值」。伺服器收到封包後不看來源 IP,直接用 DCID 查出對應的連線狀態。每一端還會透過 NEW_CONNECTION_ID frame 預先提供一批備用 CID,讓對方隨時可以換用。
遷移不是「認得就好」,還有三件事要處理
| 顧慮 | 為什麼不能無腦接受 | QUIC 的處理 |
|---|---|---|
| 位址欺騙 | 攻擊者可以偽稱「我搬到某個受害者 IP 了」,把大流量導向第三方 | 路徑驗證:送 PATH_CHALLENGE(隨機值),必須在新路徑上收到正確的 PATH_RESPONSE |
| 路徑特性全變了 | 新路徑的頻寬與 RTT 與舊路徑毫無關係,沿用舊估計會直接打爆 | 重置壅塞控制器與 RTT 估計,在新路徑上重新探測 |
| 可被關聯追蹤 | 若換網後沿用同一個 CID,路徑上的觀察者能把「Wi-Fi 的你」與「4G 的你」串起來 | 遷移時必須改用一個未曾在網路上出現過的 CID |
所以 connection migration 不是「換 IP 照跑」,而是「換 IP 之後走一段輕量的驗證與重新探測」——但這比完整重建連線便宜太多,而且應用層完全無感:stream 的進度、已解析的標頭、傳到一半的檔案全部保留。
幾個容易被追問的邊界:只有客戶端能主動遷移(伺服器要換位址得在交握時透過 preferred_address transport parameter 宣告);連線也可以在交握時透過 disable_active_migration 直接關掉這個能力;此外 NAT 映射逾時後重新綁定造成的位址變化(TCP/UDP 篇與 NAT 篇談過的 idle timeout)在 QUIC 這裡會被當成一次遷移自動處理,不再需要靠心跳硬撐著映射不放。
傳統 L4 負載均衡器靠五元組雜湊決定把封包送到哪一台後端,這在 TCP 上運作良好,因為五元組在連線期間不變。QUIC 一旦遷移,五元組變了,雜湊值也變了——封包會被分派到一台根本沒有這條連線狀態的伺服器,而那台伺服器只能回一個 stateless reset,等於連線還是斷了。
正解是 CID 感知路由:伺服器在挑選自己的 CID 時,把「我是哪一台後端」編碼進 CID 的前幾個位元組,負載均衡器只讀 CID 就能路由,完全不必解密。代價有兩個:CID 不再是純亂數,必須做加密混淆以免對外洩漏叢集拓撲;而且客戶端的第一個 Initial packet 的 DCID 是它自己隨機挑的,此時還沒有任何路由資訊,得另外處理(通常是對這類封包做五元組雜湊,並確保後續 Retry/握手封包一致)。
這個結論在系列收尾的連線擴充篇會再用到:QUIC 的 sticky 需求不是靠 cookie 或來源 IP,而是靠連線識別本身。
另外補一個常被忽略的機制:stateless reset。伺服器重啟或狀態被淘汰後,它收到一個認不得的 CID 時無法用正常方式關閉連線(金鑰早就沒了),於是回送一個帶著 stateless reset token 的封包——這個 token 是交握時就給過客戶端的。客戶端一認出它,就知道「對面真的沒有我的狀態了」,立刻放棄並重連,而不是傻等到閒置逾時。
PATH_CHALLENGE/PATH_RESPONSE 路徑驗證、重置壅塞控制與 RTT 估計,並換一個新 CID 避免被關聯——代價是負載均衡器不能再用五元組雜湊,必須改成 CID 感知路由。
HTTP/3:同一套語義,換一套映射
沿用 HTTP 篇的分界——語義(RFC 9110)一行沒動,改的是 wire format,以及它對底層傳輸保證的假設。
HTTP 篇的版本表最後一列寫著「HTTP/3 還是同一套語義,TCP 被換掉了」。現在把它展開。HTTP/3(RFC 9114)沒有新增任何方法、任何狀態碼、任何快取規則。你的 GET /users/1、你的 Cache-Control、你的 ETag/304、你的冪等性推論全部原封不動——這正是 HTTP 篇那句「語義是穩定的資產,傳輸表示法才是每十年換一次的部分」的第二次驗證。
1 · 映射:一個請求佔一條 QUIC stream
HTTP/3 的核心映射只有一句:一次請求/回應交換 = 一條客戶端發起的雙向 QUIC stream。HTTP 篇的「Stream ID 奇偶約定」不再需要(QUIC 的 stream ID 自己編碼了發起者與方向),而 HTTP/2 的 frame 型別有一半被刪掉了——因為那些工作現在由 QUIC 做:
| HTTP/2 | HTTP/3 | 原因 |
|---|---|---|
HEADERS / DATA | 保留 | 語義單位不變,但 HEADERS 改用 QPACK 編碼 |
SETTINGS / GOAWAY | 保留 | 走一條專屬的單向 control stream;GOAWAY 的優雅關閉語義同 HTTP 篇 |
WINDOW_UPDATE | 刪除 | 流量控制整個交給 QUIC 的 MAX_DATA/MAX_STREAM_DATA |
RST_STREAM | 刪除 | 改用 QUIC 的 RESET_STREAM/STOP_SENDING(見第 4 節) |
PRIORITY | 刪除 | 改用 RFC 9218 的 Priority 標頭(u/i,HTTP 篇已介紹) |
PING | 刪除 | QUIC 自己有 PING frame 與閒置逾時機制 |
PUSH_PROMISE | 保留(但同樣乏人問津) | 失敗原因 HTTP 篇已論證完,換傳輸層並沒有改變那三個理由 |
HTTP/3 的 frame 集合明顯比 HTTP/2 小——這是把責任下推到正確的層級之後應有的樣子。另外 HTTP/3 定義了幾條特殊用途的單向 stream,開頭第一個位元組是型別:control(0x00)、push(0x01)、QPACK encoder(0x02)、QPACK decoder(0x03)。
2 · QPACK:為什麼 HPACK 在亂序傳輸下會崩潰
HTTP 篇埋過這個伏筆:HPACK 的動態表要求收發雙方對「第 N 號索引是什麼」有完全一致的認知,而這個一致性建立在一個隱含假設上——標頭區塊嚴格按照送出的順序被處理。在 TCP 上這是白拿的(TCP 本來就保證順序),但 QUIC 故意不保證:stream 8 的 HEADERS 完全可能比 stream 0 的先到。
如果照搬 HPACK 會發生什麼?編碼端在處理 stream 0 時往動態表插入了「cookie: abc…」成為第 62 號條目,接著在 stream 8 引用「第 62 號」。若 stream 8 先抵達,解碼端的表裡根本還沒有第 62 號——它要嘛解出錯的標頭(災難),要嘛只能停下來等 stream 0,而後者等於把剛剛買到的 stream 獨立性又整個賠回去。
QPACK(RFC 9204)的解法是把「表的更新」與「表的使用」拆到不同 stream 上:
| 元件 | 方向 | 作用 |
|---|---|---|
Encoder stream(0x02) | 編碼端 → 解碼端 | 只送動態表的插入指令。這條 stream 內部有序,所以表的狀態演進是確定的 |
Decoder stream(0x03) | 解碼端 → 編碼端 | 回報 Section Acknowledgment、Stream Cancellation、Insert Count Increment——讓編碼端知道對方的表追到哪了 |
| Required Insert Count | 每個標頭區塊的前綴 | 宣告「要解開我,你的動態表至少得插入到第 N 筆」 |
於是流程變成:解碼端拿到一個標頭區塊,先看 Required Insert Count。若表已經追上就直接解;若還沒追上,只把這一條 stream 掛起(blocked stream),其他 stream 照常處理,等 encoder stream 上的插入指令補到就繼續。
最漂亮的設計在這裡:SETTINGS_QPACK_BLOCKED_STREAMS 宣告「我最多容忍幾條 stream 同時被掛起」。把它設成 0,編碼端就只准引用「已經被 decoder stream 確認收到」的條目,其餘一律送字面值——永遠不會阻塞,代價是壓縮率下降。設得大一點,壓縮率提高,但偶爾會有 stream 短暫掛起。
這是一個乾淨的旋鈕:壓縮率 ⇄ 阻塞風險。對照 HPACK——在 HPACK 裡這根本不是選項,順序依賴是寫死在設計裡的。順帶一提,QPACK 的靜態表也從 HPACK 的 61 筆擴到 99 筆,並針對現代網頁常見的標頭重新挑選過。
3 · 探索與 fallback:客戶端怎麼知道可以用 h3
這是 HTTP/3 部署上最反直覺的一點:你沒辦法「直接」用 HTTP/3 連上一個網站。URL 裡的 https:// 沒有任何地方標示傳輸協定,而 UDP 443 是否可達更是無從預知。實務上有三個機制疊在一起:
Alt-Svc標頭(RFC 7838):先用 HTTP/1.1 或 HTTP/2 連上,伺服器在回應裡附Alt-Svc: h3=":443"; ma=86400,客戶端記下來,下一次才改走 h3。所以第一次連線幾乎一定是 TCP——這也意味著 h3 對「只來一次的訪客」幫助有限。- HTTPS 資源紀錄(RFC 9460):把
alpn="h3"寫進 DNS。客戶端解析名稱時就知道對方支援 h3,第一次連線就能直接上——這正好回扣dns-deep-dive:名稱解析不只回答「連哪個 IP」,也可以回答「怎麼連」。 - 競速(happy eyeballs 式):瀏覽器實務上會同時、或錯開數十毫秒發起 QUIC 與 TCP 兩種連線嘗試,誰先成功用誰、另一條丟棄。這樣在 UDP 被封鎖或限速的網路上不必空等逾時。形式上與
dns-deep-dive提過的 IPv4/IPv6 Happy Eyeballs 完全一致,只是競速的對象從位址族換成了傳輸協定。
$ curl --http3 -v https://cloudflare-quic.com/
* Connected to cloudflare-quic.com port 443
* ALPN: curl offers h3 # 不是 h2,也不是 http/1.1
* QUIC handshake complete (1-RTT) # 「先建 TCP」這一步不存在了
* Server certificate: CN=cloudflare-quic.com # 憑證鏈驗證與 TLS 篇完全相同
* ALPN: server accepted h3
* using HTTP/3
> GET / HTTP/3
> :authority: cloudflare-quic.com # pseudo-header 與 HTTP/2 一模一樣
> :method: GET
< HTTP/3 200
< alt-svc: h3=":443"; ma=86400 # 「下次直接來 h3,這個資訊記 24 小時」
兩個細節:(1) 請求行與 pseudo-header 和 HTTP 篇的 HTTP/2 完全相同——再一次證明語義沒變。(2) curl 8 之後 --http3 會與 TCP 競速並在失敗時退回,--http3-only 才是強制不退回;用它來驗證「邊緣到底有沒有真的開 h3」最乾脆。
$ curl -sI https://example.com | grep -iE '^HTTP|alt-svc'
HTTP/2 200 # 第一次連線仍然是 TCP + TLS + HTTP/2
alt-svc: h3=":443"; ma=86400 # 伺服器在此廣告 h3
$ dig +short HTTPS example.com # RFC 9460:把「怎麼連」寫進 DNS
1 . alpn="h3,h2" ipv4hint=93.184.216.34 # 有這筆,第一次連線就能直接上 h3
這兩條指令是排查「為什麼我的服務沒跑在 h3 上」的第一步:先確認邊緣有沒有吐 Alt-Svc,再確認 DNS 有沒有 HTTPS RR。
WINDOW_UPDATE/RST_STREAM/PRIORITY/PING(QUIC 已提供);HPACK 因為依賴嚴格順序而必須換成 QPACK,用 encoder/decoder stream 同步動態表,並讓「阻塞風險 vs 壓縮率」變成一個可調參數;至於客戶端怎麼知道能用 h3,靠 Alt-Svc、DNS HTTPS RR 與競速三者疊加。
互動 · TCP HoL vs QUIC stream 獨立性
同一批資料、同一個洞、同一條鏈路,只換傳輸層:看兩側各條 stream 的完成時刻差多少。
下面的模擬器把三個 stream(A/B/C)的資料塊交錯送上線(送出順序 A1 B1 C1 A2 B2 C2 …),兩側收到的封包順序完全相同,差別只在下面那一層是 TCP 還是 QUIC。四支 slider 是這個 demo 的全部自由度:哪一個封包遺失、每條 stream 幾塊資料、RTT 多長,以及時間游標停在哪一刻。兩條泳道吃同一組輸入、同時重算。
每一塊資料畫在它「可以交給應用」的那個時刻上:綠色=一抵達就交付,藍色=已經安全抵達卻被扣住(斜線帶就是它等待的那段時間),紅色=遺失並由重傳補上(虛線框是它原本該抵達的位置)。右側的數字是那條 stream 的完成時刻。
四個值得停下來比對的位置:(1) 預設(遺失 B2、每 stream 3 塊、RTT 80)——TCP 三條 stream 全部拖到 120 ms 才完成,QUIC 是 A 35/B 120/C 45,平均差 53.3 ms;時間游標停在 60 ms 時,TCP 只交付了 4 塊而 QUIC 已經交了 7 塊。(2) 遺失點拖到 第 1 個封包:TCP 扣住的量衝到最大(8 塊、580 塊·ms),但平均差額反而降到 38.3 ms——最早的那個洞同時也擋住了 QUIC 自己那條 stream。(3) 遺失點拖到倒數第三個之後(例如第 7 個):洞後已不足 3 個封包可觸發封包門檻,偵測退回 PTO,恢復時間由 95 ms 跳到 115 ms。(4) 遺失點拖到最後一個封包:差額歸零——HoL 的傷害與「洞出現得多早」成正比,這解釋了為什麼高丟包鏈路上 HTTP/2 的劣勢會被放大。
算的部分:封包在線上的間隔固定為 5 ms(等於假設一條固定速率的鏈路);遺失偵測依 RFC 9002 先試封包門檻(洞之後第 3 個封包抵達即判定),不足時退回 PTO(以 1 RTT 加上 max_ack_delay 預設的 25 ms 估算);重傳一律視為一次成功,抵達時刻為偵測時刻再加一個往返。交付規則就是本篇的主軸:TCP 側「前面每一個位元組都到了才交得上去」,QUIC 側「同一條 stream 內前面每一塊都到了才交得上去」。
沒算的部分:不畫壅塞視窗、不畫 ACK 逐則往返、不模擬二次遺失,也不管兩側壅塞控制實作的差別。真實情況下丟包會讓兩側同時降速——所以請把它讀成「誰被卡住、卡多久」的示意,不是效能量測。
分工也刻意錯開:TCP/UDP 篇的 demo 看的是單一連線內的重傳與排序緩衝怎麼運作;HTTP 篇的 demo 比的是連線數與排隊(乾淨網路、不注入丟包);本篇比的是丟包時 stream 之間的阻塞關係。三者相加,才是完整的圖。
面試折衷與實務選型
QUIC 的技術優勢很好講,難的是那四筆部署帳單:UDP 不一定通、CPU 更貴、看不見、以及負載均衡要重寫。
把前七節收攏成一句:QUIC 用「重寫整個傳輸層」換到了 stream 獨立、交握合一與連線可遷移。但這三項收益不是免費的,而且帳單多半落在部署與維運而不是協定本身。
1 · 部署現實:四筆必須先算清楚的帳
| 問題 | 成因 | 後果與對策 |
|---|---|---|
| UDP 被封鎖或限速 | 部分企業網路、老舊防火牆與電信設備只放行 TCP 443,或對 UDP 做嚴格限速 | h3 永遠只能是機會性升級:TCP 路徑必須保留,客戶端要有競速與退回機制——你的邊緣得同時維運兩套 stack,這是硬成本 |
| CPU 成本明顯較高 | 跑在使用者空間、無法沿用 TCP 的網卡卸載;加密是 per-packet(約 1200 位元組一次)而非 TLS 的 per-record(可到 16KB 一次);系統呼叫次數暴增 | 早期實測 CPU 約為 TLS/TCP 的兩倍,靠 UDP GSO/GRO、sendmmsg 批次化已大幅改善但仍較高。高吞吐靜態內容服務要實測再決定 |
| 可觀測性歸零 | 第 2 節那個「除不變量外全加密」的決定,對你自己也一視同仁 | tcpdump 只看得到 UDP 與 CID,ss 沒有 QUIC 連線表。要改用 qlog/qvis、SSLKEYLOGFILE 與伺服器端指標——把除錯手段換掉是遷移計畫的一部分 |
| 負載均衡要重寫 | 五元組雜湊在 connection migration 後失效(第 5 節) | 改用 CID 感知路由:後端識別編碼進 CID 並加密混淆,並處理 Initial packet 的隨機 DCID |
要平衡地看,還有一筆反方向的帳常被漏掉:QUIC 跑在使用者空間,所以協定演進不再被作業系統升級週期綁架。瀏覽器、CDN、行動 App 可以自行升級傳輸層實作,換壅塞控制演算法只是發個版——而這正是 TCP 三十年來改不動的根本原因。「CPU 貴一點」與「演進快十年」是同一個決定的兩面。
2 · 選型表
| 決策點 | 選項 | Trade-off |
|---|---|---|
| 邊緣要不要開 h3 | 開(廣告 Alt-Svc)vs 只跑 h2 | 行動端、跨區、高丟包鏈路收益最大;代價是雙 stack 維運、CPU 與整套可觀測性重建。多數團隊的正解是「CDN 幫你開」而不是自建 |
| 第一次連線要不要也走 h3 | 只靠 Alt-Svc vs 加上 DNS HTTPS RR | Alt-Svc 零成本但第一次一定是 TCP;HTTPS RR 能救到首次連線,代價是 DNS 設定多一筆、且需要客戶端支援 |
| 0-RTT | 開 vs 關 | 與 TLS 篇結論相同:只放冪等請求、叢集要有重放偵測。QUIC 端額外要考慮位址驗證與 Retry token 的設定 |
| L4 分流 | 五元組雜湊 vs CID 感知路由 | 前者在 migration 後直接送錯機器;後者要把後端識別編進 CID 並做加密混淆,避免對外洩漏叢集拓撲 |
| 內部服務之間 | gRPC over HTTP/2 vs HTTP/3 | 機房內丟包低、RTT 低,HoL 本來就不是瓶頸,QUIC 收益小而 CPU 代價立刻可見;生態與除錯工具也還是 h2 成熟 |
| 大檔單流下載 | h2 vs h3 | 單一 stream 沒有「跨 stream HoL」可解,差異主要來自交握成本與壅塞控制實作,不是 stream 獨立性 |
| 行動端長時間連線 | TCP + 重連邏輯 vs QUIC migration | QUIC 在此優勢最大(切網、NAT 重綁都不斷);但客戶端 SDK 與伺服器都要支援,且 LB 必須先解決 CID 路由 |
共同結構:QUIC 的收益與「網路有多糟」成正比——丟包越高、RTT 越長、位址越容易變(行動端),收益越大;而它的成本與「你自己維運多少層」成正比。這也是為什麼公網面向使用者的邊緣普遍上了 h3,機房內部卻大多還在 h2。
Q:QUIC 就是 HTTP/3 嗎?
答:不是。QUIC 是 UDP 之上的通用傳輸協定,位置等同 TCP;HTTP/3 只是把 HTTP 語義映射到 QUIC 的那份規格。DNS over QUIC、WebTransport、MASQUE 都是 QUIC 的其他客戶。
Q:QUIC 為什麼比 TCP + TLS 少一個往返?
答:因為交握合一——TLS 訊息裝在 CRYPTO frame 裡跟著 QUIC 交握一起走,不需要先建 TCP 再談 TLS。而在 TCP 上,session resumption 也永遠省不掉那趟 TCP 交握。
Q:QUIC 徹底消滅 HoL Blocking 了嗎?
答:只消滅了「傳輸層跨 stream」的那一種。stream 內部仍然有序、壅塞控制仍是全連線一份、QPACK 若允許引用未確認條目也可能造成 stream 掛起(可調)。
Q:為什麼跑在 UDP 上,而不是定義一個新的 L4 協定?
答:middlebox。新的 IP 協定號在公網上幾乎過不去,UDP 是唯一有普遍通行權的資料報載體。同樣的理由也解釋了為什麼 QUIC 連 packet number 都加密——不給中間設備養成依賴的機會。
Q:切換網路為什麼不斷線?
答:連線識別是 Connection ID 而不是五元組。但遷移要走 PATH_CHALLENGE/PATH_RESPONSE 驗證新路徑、重置壅塞控制與 RTT 估計,並換一個沒用過的 CID 避免被關聯追蹤。
Q:升級 h3 之後沒變快,可能是什麼原因?
答:先確認真的協商到 h3(Alt-Svc 是機會性升級,第一次連線多半仍是 TCP,可用 curl --http3-only 驗證);再看網路品質——低丟包低 RTT 的機房網路本來就沒多少 HoL 可省;最後看 CPU 有沒有變成新瓶頸。
Q:HPACK 為什麼不能直接用在 HTTP/3?
答:動態表的索引一致性依賴「標頭區塊嚴格按序處理」,而 QUIC 刻意允許 stream 之間亂序。QPACK 把表的更新拆到專屬的 encoder/decoder stream,並用 Required Insert Count 讓最多幾條 stream 可被掛起變成可調參數。
把本篇收攏成一句:HTTP/2 已經在自己的層級上做對了每一件事,剩下的問題只能往下一層解。QUIC 的做法不是優化 TCP,而是承認「單一有序位元組串流」這個抽象本身不適合多路復用,然後在 UDP 上重新選了一組保證——每個 stream 各自有序、連線識別與位址脫鉤、交握與加密合而為一。代價是所有依附在 TCP 生態上的東西(網卡卸載、抓包工具、L4 負載均衡)都得重做一遍。
但把 transport 換掉,換到的仍然是同一種通訊模式的更好版本:客戶端問、伺服器答,只是問得更快、答得更不容易被卡住。有一整類需求它從頭到尾沒有碰——伺服器想在沒有人問的時候主動開口,而且要持續地講下去。這需要的不是更快的請求回應,而是一條真正的雙向長連線。下一篇進入 WebSocket:它的交握、framing、保活與重連,以及在 HTTP/3 世界裡的對應物(RFC 9220 的 Extended CONNECT over HTTP/3,以及直接吃 QUIC stream 與 datagram 的 WebTransport)——那些細節,留給 WebSocket 篇。