bigywater.tech
网络协议 · 86

HTTP/3 与 QUIC:基于 UDP 的传输

HTTP/3 把 HTTP 搬到 QUIC 上,QUIC 又建在 UDP 之上:合并握手、每个流独立丢包恢复、全程加密。这篇用 aioquic 对真实服务器发 HTTP/3 请求,抓包看 QUIC 的包头和 1200 字节填充,量握手省下的一个往返;在本机服务端上实测 0-RTT 再省一个往返;最后在丢包下和 curl + Caddy 的原生实现对照,看 aioquic 的 13 秒慢在哪里。

171 vs 324 ms
QUIC 握手 与 TCP + TLS 1.3 握手 的耗时中位数(example.com)
209 → 105 ms
本机服务端上完整握手与 0-RTT 的响应头耗时,往返 100 ms,省了约一个往返
13189 → 964 ms
3% 丢包下取 10 个 100 KB:aioquic 对 aioquic 与 curl 对 Caddy 的 HTTP/3 耗时中位数(同一时刻 curl 对 Caddy 的 HTTP/2 是 702 ms)

少一个往返

实测 · 本机执行输出
  HTTP/3:ALPN h3,握手(到可发请求)逐次 [194, 167, 325, 169, 171] ms,中位 171;请求发出到首字节 中位 163 ms;总计 中位 348 ms;状态 b'200',577 字节
  HTTP/2:TCP 握手 中位 160 ms + TLS 握手 中位 164 ms + 请求到首字节 中位 343 ms;总计 中位 680 ms

QUIC 把传输握手和 TLS 握手合并,1 个 RTT 即可发请求;TCP + TLS 1.3 要 2 个。整次取回首页 348 ms 对 680 ms。

线上的 QUIC

实测 · 本机执行输出
  整个请求在线上:21 个 UDP 数据报,9418 字节(UDP 载荷);客户端→服务器 10 个 / 2984 字节;服务器→客户端 11 个 / 6434 字节
    +    0.0 ms  C→S   1200 字节  长包头 Initial 版本 0x00000001 DCID 8 字节
    +  156.2 ms  S→C   1200 字节  长包头 Initial 版本 0x00000001 DCID 8 字节
    +  157.0 ms  C→S   1200 字节  长包头 Initial 版本 0x00000001 DCID 20 字节
    +  158.6 ms  S→C   1200 字节  长包头 Handshake 版本 0x00000001 DCID 8 字节
    +  158.6 ms  S→C   1200 字节  长包头 Handshake 版本 0x00000001 DCID 8 字节
    +  158.6 ms  S→C   1200 字节  长包头 Handshake 版本 0x00000001 DCID 8 字节
    +  158.6 ms  S→C    552 字节  长包头 Handshake 版本 0x00000001 DCID 8 字节
    +  159.0 ms  C→S     60 字节  长包头 Handshake 版本 0x00000001 DCID 20 字节
    +  159.1 ms  C→S     60 字节  长包头 Handshake 版本 0x00000001 DCID 20 字节
    +  159.2 ms  C→S     60 字节  长包头 Handshake 版本 0x00000001 DCID 20 字节
    +  164.9 ms  C→S    213 字节  长包头 Handshake 版本 0x00000001 DCID 20 字节
    +  165.2 ms  C→S     60 字节  短包头(1-RTT)
  客户端第一个数据报 1200 字节(规范要求 Initial 所在的数据报至少 1200 字节,故意填充,防止 QUIC 服务器被拿来做流量放大);版本 0x00000001 = QUIC v1

长包头(Initial、Handshake)带版本号和连接 ID,握手后用短包头;Initial 所在的数据报填充到 1200 字节。

会话恢复

实测 · 本机执行输出
  第一次(完整握手):响应头 498 ms,状态 b'200';收到 2 个 session ticket;ticket 的 max_early_data_size = None
    A 完整握手                   响应头 逐次 [524, 524, 630, 480] 中位 524 ms;session_resumed = False,early_data_accepted = False,状态 [b'200']
    B 带 ticket,等握手完成         响应头 逐次 [473, 562, 1352, 474] 中位 518 ms;session_resumed = True,early_data_accepted = False,状态 [b'200']
    C 带 ticket,0-RTT         响应头 逐次 [610, 476, 615, 524] 中位 567 ms;session_resumed = True,early_data_accepted = False,状态 [b'200']

example.com 会发 session ticket,带着它连能恢复会话,但 ticket 不允许 early data,所以没有 0-RTT。我最初没拿到 ticket 是自己代码的错误(回调要传给 connect())。

0-RTT

实测 · 本机执行输出
  第一次(完整握手,同时保存 session ticket):响应头 218 ms,收完 525 ms;收到 1 个 ticket;ticket 里 max_early_data_size = 4294967295
    A 完整握手                   响应头 逐次 [206, 209, 218, 208, 210, 207] 中位 209 ms;收完 中位 519 ms;session_resumed = False,early_data_accepted = False
    B 带 ticket,等握手完成         响应头 逐次 [215, 218, 204, 203, 209, 209] 中位 209 ms;收完 中位 514 ms;session_resumed = True,early_data_accepted = True
    C 带 ticket,0-RTT         响应头 逐次 [105, 104, 106, 110, 107, 104] 中位 105 ms;收完 中位 461 ms;session_resumed = True,early_data_accepted = True
  (往返 100 ms:A、B 应当约等于 2 个往返——1 个等握手,1 个等响应;C 约等于 1 个往返)

自己的 aioquic 服务端开了 early data 后,0-RTT 请求在握手完成前就发出,响应头 105 ms 对 209 ms,约少一个往返(往返 100 ms)。

aioquic 的 13 秒

实测 · 本机执行输出
    丢包率    协议      总耗时(ms)逐次                      中位     第一个流完成 中位   最后一个流完成 中位
    0%       HTTP/2   [610, 611, 613, 708]      612        408             612
    0%       HTTP/3   [848, 842, 839, 838]      840        839             840
    3%       HTTP/2   [1498, 810, 915, 918]      917        462             917
    3%       HTTP/3   [10591, 15373, 15684, 13839]    14606      13948           14606

最初的对比里 aioquic 对 aioquic 在 3% 丢包下要 14606 ms,比 HTTP/2 的 917 ms 慢十几倍,但这个数字主要来自 aioquic 这个纯 Python 实现。

原生实现对照

实测 · 本机执行输出
  自检:curl --http3-only 取 Caddy 的 100 KB 文件 → 200 3 100000 (状态码 / HTTP 版本 / 字节数)
    丢包率  协议      客户端 → 服务端                         总耗时(ms)逐次                    中位
    0%     HTTP/3   aioquic 客户端 → aioquic 服务端              [842, 845, 846, 844]       844
    0%     HTTP/3   aioquic 客户端 → Caddy 服务端                [708, 695, 693, 696]       695
    0%     HTTP/3   curl 客户端 → aioquic 服务端                 [579, 550, 540, 546]       548
    0%     HTTP/3   curl 客户端 → Caddy 服务端                   [388, 392, 391, 396]       392
    0%     HTTP/2   python h2 客户端 → python h2 服务端          [710, 608, 708, 611]       659
    0%     HTTP/2   python h2 客户端 → Caddy 服务端              [611, 608, 608, 610]       609
    0%     HTTP/2   curl 客户端 → python h2 服务端               [449, 440, 444, 428]       442
    0%     HTTP/2   curl 客户端 → Caddy 服务端                   [341, 337, 336, 343]       339
    3%     HTTP/3   aioquic 客户端 → aioquic 服务端              [11051, 13927, 13359, 13020]     13189
    3%     HTTP/3   aioquic 客户端 → Caddy 服务端                [7229, 7051, 6064, 8456]      7140
    3%     HTTP/3   curl 客户端 → aioquic 服务端                 [1983, 1899, 1724, 2184]      1941
    3%     HTTP/3   curl 客户端 → Caddy 服务端                   [913, 797, 1064, 1014]       964
    3%     HTTP/2   python h2 客户端 → python h2 服务端          [709, 908, 711, 716]       714
    3%     HTTP/2   python h2 客户端 → Caddy 服务端              [917, 1116, 1612, 1218]      1167
    3%     HTTP/2   curl 客户端 → python h2 服务端               [842, 562, 731, 731]       731
    3%     HTTP/2   curl 客户端 → Caddy 服务端                   [926, 769, 547, 636]       702

aioquic 对 aioquic 在 3% 丢包下 13189 ms;换成 curl 对 Caddy 后降到 964 ms,但仍比同样原生的 HTTP/2(702 ms)慢——在这个很小的测试里没有显示出 HTTP/3 在丢包下更好。

分步演示

HTTP/3 与 QUIC
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64(Apple 芯片)/ Python 3.14.8 / tcpdump(libpcap)/ scapy 2.8 / dpkt / dnspython / h2 / aioquic / OpenSSL / curl 8.7.1;抓包在 lo0(环回)上做,少数实验用真实网卡 en0(这台机器上 en0 是 Wi-Fi,tcpdump 看到的是系统转成以太网格式的帧,所以文中的“以太网头”都指这个 14 字节的头)或访问公网(会在文章里注明);需要丢包 / 延迟 / 限速时,用一个只作用于 lo0 端口 9400-9499 的 dummynet 辅助脚本(netlab);所有服务端都是本机自己起的进程;时间戳取自 tcpdump 的 -tt,数字每次运行会有小幅抖动。第一、二、三节对 example.com 的真实 HTTP/3(UDP 443,aioquic),HTTP/2 对照用 Python 的 ssl + h2;握手耗时的单次样本偶有 300 ms 以上的抖动;第四节是自己的 aioquic 服务端(开了 session ticket 与 early data),netlab 每方向 50 ms,每种连法 6 次;第五节是本机自签名证书的 aioquic 服务器与 h2 服务器,netlab 每方向 50 ms,每格 4 次;第六节的客户端 curl 8.22.0(ngtcp2 1.25 / nghttp3 1.18)和服务端 Caddy 2.11.7(关掉了自动信任证书,绑定 127.0.0.1),与 aioquic、python h2 交叉成 2×2,同样是 10 × 100 KB、一条连接、每方向 50 ms、丢包 0% 和 3%,每格 4 次;第三节的修正:最初版本把 session ticket 的回调传错了位置,拿不到 ticket
    • 没有验证:连接迁移(换网络后连接不断);QUIC 的拥塞控制算法与 TCP 的比较;真实网络(非 netlab)里的丢包对 HTTP/3 的影响,以及丢包是突发而非均匀随机时的表现;更大的资源、更多并发流或不同 RTT 下 HTTP/3 与 HTTP/2 的比较(只测了 10 × 100 KB 这一种);为什么 aioquic 在丢包下那么慢(客户端是主因,服务端也有影响,具体原因没有查);Cloudflare 以外支持 0-RTT 的公网服务器;QPACK 头部压缩;UDP 被防火墙限速或封锁时浏览器回退到 HTTP/2 的过程;Alt-Svc 发现机制(只在 w74 里看到 alt-svc 头)
    京ICP备2026067502号-1京公网安备11010502063487号