01

總覽與定位

QUIC 不是「HTTP 的新版本號」,而是一個跑在 UDP 上的通用傳輸協定;HTTP/3 只是它第一個、也最大的客戶。

前一篇的 HTTP 篇停在一句話上:問題不出在 HTTP/2,而出在它腳下那個「有序位元組串流」的抽象——只要傳輸層仍然是 TCP,多路復用就永遠只做到一半。本篇兌現那句「不要 TCP 了」。

值得先把「一半」是哪一半講精確。HTTP/2 在自己的層級上做得無可挑剔:每個 frame 都帶 Stream ID,回應可以亂序完成。但這個「亂序」只存在於 HTTP/2 的視野裡——交到 TCP 手上時,它們被壓成了一條必須按序交付的隊伍。TCP 對上層的承諾是「不掉、不亂」,而「不亂」意味著它不能跳過任何一個洞:中間少一段,後面所有已經安全抵達的位元組都得扣在核心緩衝裡等。TCP 不知道那些位元組屬於三個互不相干的 stream,因為位元組串流這個抽象本身就沒有「裡面有幾條 stream」這個概念。

座標接 HTTP 篇的斷點:把有序保證的粒度改掉

要解掉這件事,只有兩條路:讓 TCP 學會「部分有序交付」,或者不要 TCP。前者被證明走不通——TCP 跑在核心裡、被全球的中間設備(NAT、防火牆、負載均衡器)盯著看,任何新選項都可能被丟棄或改寫,光是讓一個新版本普及就要十年以上。
於是 QUIC 選了第二條路:躲進 UDP 這個中間設備不敢多管的載體裡,在使用者空間把可靠傳輸整套重做一遍,而且這一次讓有序保證的粒度從「整條連線」降到「單一 stream」。這正是 TCP/UDP 篇那句話的兌現——「需要 stream 之間互不阻塞、或需要換網不斷線時,只能在 UDP 上重做」。

QUIC 由 RFC 9000(傳輸)、RFC 9001(與 TLS 1.3 的整合)、RFC 9002(遺失偵測與壅塞控制)三份文件定義,另有 RFC 8999 規定「哪些欄位永遠不會變」。把它與舊堆疊並排看,最直觀:

HTTP/2 的堆疊 · 四層各管各的
HTTP/2 framing、多路復用、HPACK、兩級流量控制
TLS 1.3 交握、record layer、加密
TCP 連線、可靠性、單一有序位元組串流、壅塞控制(核心)
IP
HTTP/3 的堆疊 · 中間兩層被合併重寫
HTTP/3 framing、QPACK(多路復用下沉了)
QUIC 連線、獨立 stream、可靠性、壅塞控制、內建 TLS 1.3(使用者空間)
UDP 只提供 port 分流與 checksum
IP

注意 HTTP/3 那一格變薄了:多路復用、流量控制、連線管理全部下沉進 QUIC。這是本篇的主軸——不是 HTTP 學會了新把戲,而是傳輸層終於提供了 HTTP/2 一直想要卻要不到的東西。

陷阱「QUIC=HTTP/3」是本篇最該戒掉的說法

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 只是第一個把它們用起來的協定。

一句話答法QUIC 是跑在 UDP 上、內建 TLS 1.3 的通用傳輸協定,它把有序交付的粒度從「整條連線」降到「單一 stream」,並用 Connection ID 取代五元組;HTTP/3 只是把 HTTP 語義映射到 QUIC 的那份規格,語義一行都沒改。
02

在 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、PINGTCP 標頭裡的 ACK 欄位與 keep-alive。ACK frame 帶的是 ACK Range(可表達多個缺口),比 TCP 的累積 ACK + SACK 選項乾淨
流量控制MAX_DATA、MAX_STREAM_DATA、MAX_STREAMSTCP 標頭的 Window 欄位。QUIC 拆成連線級與 stream 級兩層,還多控制「能開幾條 stream」
連線管理RESET_STREAM、STOP_SENDING、NEW_CONNECTION_ID、PATH_CHALLENGETCP 的 RST 與五元組綁定。取消單條 stream 不必砍掉整條連線,換位址也不必重建

四類 frame 恰好對應 TCP 在核心裡做的四件事:送資料、確認、限流、管連線。對照 HTTP 篇的 HTTP/2 frame 型別表會發現大半是同名的——因為 HTTP/2 當年在應用層做的那些事,現在被搬到傳輸層做了一遍;差別是這裡的 MAX_STREAM_DATA 管的是傳輸層的 stream,不是 HTTP 的 stream。

2 · 一個 packet 長什麼樣:明文只剩不變量

Header Form + 版本
Destination Connection ID
Source Connection ID
Packet Number 🔒
Payload:一串 frame 🔒(STREAM、ACK、MAX_DATA …)

交握期的 Long Header 大致如此;交握完成後的 1-RTT packet 只剩「首位元組 + Destination Connection ID + 受保護的 Packet Number + 加密 payload」,連版本欄位都不再出現。藍色是路徑上看得見的部分,綠色連 packet number 都經過 header protection 加密。

關鍵為什麼連 packet number 都要加密?——ossification

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 樣本永遠乾淨,遺失判定也不再有歧義。

補充三個 packet number space

QUIC 的 packet number 不是全連線一個序列,而是分成 Initial/Handshake/Application Data 三個獨立空間,各自編號、各自確認。理由是這三種 packet 用不同的金鑰保護,且到達順序無法保證——交握期的 packet 與 1-RTT packet 混在一起編號會讓遺失偵測失去意義。這也是為什麼 QUIC 的遺失偵測邏輯要對每個空間各跑一份。

一句話答法QUIC 在 UDP 上把 ACK、重傳、排序、壅塞控制、連線識別整套重做,並趁機修掉 TCP 的相容性包袱——packet number 單調遞增消掉重傳歧義、多段 ACK Range 等於強制 SACK、除了 RFC 8999 的不變量以外全部加密以避免 ossification。
03

交握合一:內建 TLS 1.3、1-RTT 與 0-RTT

TLS 篇留了一個問題:如果傳輸層的交握與加密的交握本來就是同一件事,為什麼要分兩次做?這一節是它的答案。

TLS 篇在結尾算過一筆帳,並在後面加了一句挑釁:

首位元組之前(TCP + TLS 1.3)= 三向交握 1-RTT + TLS 交握 1-RTT = 2-RTT

這 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,不是因為要做的事比較多,而是因為那些事分屬兩層、各自要一次交握。

首位元組之前(QUIC)= 交握 1-RTT;resumption + 0-RTT 時 = 0-RTT

那個 transport parameters 是 QUIC 特有的一環:初始流量控制視窗、最多可開幾個 stream、閒置逾時、是否停用 connection migration、伺服器的 preferred address……這些在 TCP 世界裡分散在核心參數與 TCP 選項的東西,現在跟著加密的交握一起協商,中間設備既看不到也改不掉。

階段TCP + TLS 1.3QUIC
首次連線到可送應用資料2-RTT1-RTT
Resumption(不帶 early data)2-RTT(TCP 那趟省不掉)1-RTT
Resumption + 0-RTT1-RTT(仍要先建 TCP)0-RTT
誰保護資料完整性TLS record layerQUIC 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;通常只在被攻擊時才啟用
位址驗證完成前:伺服器送出位元組 ≤ 3 × 已收到位元組

伺服器還可以用 NEW_TOKEN frame 事先發一張 token 給客戶端,讓下一次連線一開始就帶著位址證明,直接跳過 Retry 那趟。這與 TLS 篇的 session ticket 是不同的東西:ticket 證明「你是上次那個人」,token 證明「你真的在這個 IP 上」,兩者可以同時使用。

一句話答法QUIC 把 TLS 1.3 嵌進傳輸交握(訊息走 CRYPTO frame、record layer 拿掉),所以連線建立、金鑰協商、ALPN 與 transport parameters 在同一趟完成,首次連線 1-RTT、resumption 可到 0-RTT;0-RTT 的重放風險與 TLS 完全相同,QUIC 額外要處理的是 UDP 特有的放大攻擊,靠 3 倍上限、Initial padding 與 Retry token 三道防線。
04

Stream 獨立性、遺失復原與兩級流量控制

這是 QUIC 相對 HTTP/2 最核心的一項:讓多路復用真正做到底——並且要誠實說明它沒解掉什麼。

1 · 有序保證的粒度

QUIC 的 stream 由一個 62 位元的變長整數識別,最低兩個位元直接編碼了語義:一個位元表示「誰發起的」(客戶端/伺服器),另一個位元表示「雙向還是單向」。所以 HTTP 篇那個「客戶端用奇數、伺服器用偶數」的約定在這裡被吸收進 ID 本身,而且多出了單向 stream 這個新工具——第 6 節的 QPACK 與 control stream 全都靠它。

真正的關鍵只有一句話:

QUIC 的有序交付保證,只在單一 stream 內部成立

Stream A 的資料遺失,只會擋住 stream A 後續的資料;stream B、C 已抵達的部分照樣立刻交給應用。把 HTTP 篇那張圖重畫一次就看得很清楚:

S-A DATA ✓交付 S-B DATA(遺失) S-C DATA ✓交付 S-A DATA ✓交付 S-B DATA ⏸等 B 補洞 S-C DATA ✓交付 ↑ 只有 S-B 停等,A 與 C 完全不受影響

對照 HTTP 篇的同一張圖:在 TCP 上,那六格全部要扣在核心緩衝裡等。差別不在 HTTP 這一層做了什麼,而在下面那一層願不願意承認「這些位元組其實不相干」。

陷阱QUIC 沒有消滅 HoL Blocking,它縮小了 HoL Blocking 的作用範圍

這題面試很愛追問,答「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 講過),差別在於它是重新設計而不是三十年疊上去的補丁:

面向TCPQUIC(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_STREAMSMAX_STREAMS frame從「一個設定值」變成可隨時追加的額度
額度的表達方式WINDOW_UPDATE 送增量送絕對上限亂序或重複抵達也不會算錯,遺失一則只要下一則更大就自動追上

最後一列是個小而漂亮的設計。HTTP/2 的增量式視窗必須靠 TCP 的可靠有序來保證不重不漏;QUIC 既然允許亂序,就把額度改成冪等的絕對值——「你最多可以送到第 N 個位元組」,收兩次或亂序收都不會出錯。這是「不能依賴順序」如何反過來讓設計變乾淨的典型例子。

值得注意的是,TCP/UDP 篇那條 backpressure 因果鏈(接收端不消費 → 不歸還額度 → 發送端停下來)在這裡完全成立,只是換了 frame 名稱。而 RESET_STREAM(我不送了)與 STOP_SENDING(你別送了)這對取消機制,比 HTTP/2 只有單一 RST_STREAM 更精確:取消一個下載時,你要表達的是「別再送」,而不是「這條 stream 出錯了」。

一句話答法QUIC 把有序保證的粒度縮到單一 stream,所以一個封包遺失只擋住它自己的 stream;遺失復原改用單調遞增的 packet number、內建多段 ACK Range 與統一的 PTO;流量控制收斂成 MAX_DATA / MAX_STREAM_DATA 兩級並改用絕對上限——但 stream 內部的 HoL 與全連線共用的壅塞控制都還在。
05

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 的作法是把識別從「位址」換成一個與位址無關的標籤:

連線識別TCPQUIC
識別是什麼五元組
(協定, 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 這裡會被當成一次遷移自動處理,不再需要靠心跳硬撐著映射不放。

陷阱Connection ID 把難題丟給了負載均衡器

傳統 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 是交握時就給過客戶端的。客戶端一認出它,就知道「對面真的沒有我的狀態了」,立刻放棄並重連,而不是傻等到閒置逾時。

一句話答法QUIC 用 Connection ID 取代五元組作為連線識別,所以切換網路、NAT 重新綁定都不會斷線;但遷移必須走 PATH_CHALLENGE/PATH_RESPONSE 路徑驗證、重置壅塞控制與 RTT 估計,並換一個新 CID 避免被關聯——代價是負載均衡器不能再用五元組雜湊,必須改成 CID 感知路由。
06

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/2HTTP/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 上的插入指令補到就繼續。

關鍵QPACK 把 HoL Blocking 從「結構性」變成「可調的」

最漂亮的設計在這裡: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。

一句話答法HTTP/3 沒改任何語義,只是把一次請求映射到一條 QUIC 雙向 stream,並刪掉 WINDOW_UPDATE/RST_STREAM/PRIORITY/PING(QUIC 已提供);HPACK 因為依賴嚴格順序而必須換成 QPACK,用 encoder/decoder stream 同步動態表,並讓「阻塞風險 vs 壓縮率」變成一個可調參數;至於客戶端怎麼知道能用 h3,靠 Alt-Svc、DNS HTTPS RR 與競速三者疊加。
07

互動 · TCP HoL vs QUIC stream 獨立性

同一批資料、同一個洞、同一條鏈路,只換傳輸層:看兩側各條 stream 的完成時刻差多少。

下面的模擬器把三個 stream(A/B/C)的資料塊交錯送上線(送出順序 A1 B1 C1 A2 B2 C2 …),兩側收到的封包順序完全相同,差別只在下面那一層是 TCP 還是 QUIC。四支 slider 是這個 demo 的全部自由度:哪一個封包遺失、每條 stream 幾塊資料、RTT 多長,以及時間游標停在哪一刻。兩條泳道吃同一組輸入、同時重算。

每一塊資料畫在它「可以交給應用」的那個時刻上:綠色=一抵達就交付,藍色=已經安全抵達卻被扣住(斜線帶就是它等待的那段時間),紅色=遺失並由重傳補上(虛線框是它原本該抵達的位置)。右側的數字是那條 stream 的完成時刻。

互動 · TCP HoL vs QUIC stream 獨立性 拖任一支 slider,兩側同時重算
一抵達即交付 已抵達但被扣住 遺失,由重傳補上
拖動 slider 後兩側同時重算。
HTTP/2 over TCP單一有序位元組串流
—
HTTP/3 over QUIC三條獨立 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 的劣勢會被放大。

說明這個模型算了什麼、沒算什麼,以及它跟前兩個 demo 的分工

算的部分:封包在線上的間隔固定為 5 ms(等於假設一條固定速率的鏈路);遺失偵測依 RFC 9002 先試封包門檻(洞之後第 3 個封包抵達即判定),不足時退回 PTO(以 1 RTT 加上 max_ack_delay 預設的 25 ms 估算);重傳一律視為一次成功,抵達時刻為偵測時刻再加一個往返。交付規則就是本篇的主軸:TCP 側「前面每一個位元組都到了才交得上去」,QUIC 側「同一條 stream 內前面每一塊都到了才交得上去」。
沒算的部分:不畫壅塞視窗、不畫 ACK 逐則往返、不模擬二次遺失,也不管兩側壅塞控制實作的差別。真實情況下丟包會讓兩側同時降速——所以請把它讀成「誰被卡住、卡多久」的示意,不是效能量測。
分工也刻意錯開:TCP/UDP 篇的 demo 看的是單一連線內的重傳與排序緩衝怎麼運作;HTTP 篇的 demo 比的是連線數與排隊(乾淨網路、不注入丟包);本篇比的是丟包時 stream 之間的阻塞關係。三者相加,才是完整的圖。

一句話答法同一個丟包位置,TCP 側所有 stream 的完成時刻一起被推到重傳抵達那一刻、後續已抵達的資料全被扣在核心緩衝,QUIC 側只有遺失資料所屬的那條 stream 被推遲,另外兩條照原時刻交付;差額的大小同時取決於洞的位置與 RTT,而且洞落在最後一個封包時會收斂到零。
08

面試折衷與實務選型

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 RRAlt-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 migrationQUIC 在此優勢最大(切網、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 篇。

一句話答法選 QUIC 買的是「網路越糟收益越大」的三件事:stream 獨立、1-RTT/0-RTT 交握、連線可遷移;付的是雙 stack 維運、較高 CPU、歸零的可觀測性與必須重寫的 L4 負載均衡——所以公網邊緣普遍值得開,機房內部通常不划算。