8.5 HTTP/1.1、HTTP/2、HTTP/3 (QUIC)
一句话
三代 HTTP 解决的是同一个问题——一条链路上怎么并发跑多个请求-响应:1.1 用“多开连接”绕开,2 用二进制分帧把阻塞从应用层挪到 TCP 层,3 把流下沉到 QUIC 让每个流各自排序;代价依次是连接数、TCP 队头阻塞、以及对 UDP 的普适性。
三代一张表看全
| 维度 | HTTP/1.1 | HTTP/2 | HTTP/3 |
|---|---|---|---|
| 规范 | RFC 9112 | RFC 9113 | RFC 9114(QUIC:RFC 9000) |
| 传输层 | TCP | TCP | UDP + QUIC |
| 报文形态 | 文本(请求行 + 头 + 空行 + 体) | 二进制分帧(9 字节帧头) | 二进制分帧(帧跑在 QUIC 流上) |
| 并发单位 | 连接 | 流(stream) | 流(QUIC 流) |
| 应用层队头阻塞 | 有(连接内串行) | 无(流交错) | 无 |
| 传输层队头阻塞 | 有(TCP 本身就串行) | 有(一个包丢了所有流等) | 无(每条流独立交付,共享拥塞窗) |
| 头部压缩 | 无 | HPACK | QPACK |
| 新连接握手 | TCP 1 RTT + TLS 1 RTT | 同左(ALPN 搭车) | QUIC 1 RTT(含 TLS 1.3) |
| 会话恢复 | TLS session ticket(仍要 TCP 握手) | 同左 | 0-RTT(有重放风险) |
| 服务端推送 | 无 | 有,但已被浏览器移除 | 无 |
| 连接标识 | 四元组 | 四元组 | connection ID(换网不断连) |
| 加密范围 | TLS 只加密载荷 | 同左 | 连传输层元数据一起加密 |
| 今天的默认 | 老客户端、明文内网 | 主流 Web/API | 大厂 CDN + 现代浏览器 |
HTTP/1.1:一切限制都来自“一条连接一个在途请求”
HTTP/1.1 在 8.1 TCP/IP 与三次握手 的 TCP 之上只做了三件事:持久连接(默认 keep-alive)、分块传输(Transfer-Encoding: chunked,让长度未知的响应也能流式发)、缓存与条件请求(ETag/If-None-Match,主场在 9.1 边缘缓存原理)。
真正卡住它的是四个硬限制:
| 限制 | 具体数字 | 后果 |
|---|---|---|
| 一条连接同时只有一个请求 | 并发度 = 1 / 连接 | 浏览器只能对同一源开 6 条连接(Chrome、Firefox 的默认值)兜底 |
| 头部不压缩 | 请求头常见 400–800B;单个 Cookie 上限 4KB | 每个请求重复发送整份 Cookie |
| 文本协议 | 每行 \r\n、字段名大小写不敏感、允许折行 | 解析要逐字符,且容忍歧义——这正是请求走私的土壤(见 8.10 反向代理与转发策略) |
| 队头阻塞 | 前一个响应没发完,后面只能等 | 单连接上大响应会卡住小请求 |
流水线(pipelining)为什么没救场:RFC 9112 仍定义了它——客户端连续发多个请求,服务端必须按序回。但响应乱序无法处理、中间设备兼容性差、出错后无法判断哪个请求失败,浏览器从未默认启用。所以 1.1 的并发只能靠“多开连接”,代价是每源 6 条连接 × 每连接一次握手。
🔑 1.1 的性能问题不是“协议慢”,而是固定成本按请求数重复支付:连接、TLS、头部。这三笔账分别在 8.3 连接复用与 TIME_WAIT、8.6 TLS、HTTP/2 的 HPACK 里被摊薄。
HTTP/2:把并发搬进一条连接
HTTP/2 的三个机制,各自解决 1.1 的一笔账:
| 机制 | 实现细节 | 解决了什么 |
|---|---|---|
| 二进制分帧 | 帧头固定 9 字节:24 位长度 + 8 位类型 + 8 位标志 + 31 位流 ID;帧类型有 DATA、HEADERS、SETTINGS、WINDOW_UPDATE、RST_STREAM、GOAWAY、PING | 一条连接上交错发送多个流 |
| 多路复用 | 请求 = HEADERS 帧 + 若干 DATA 帧;流 ID 区分归属 | 应用层队头阻塞消失,1 条连接顶 6 条 |
| HPACK | 静态表 61 项 + 动态表(默认 4096B)+ Huffman 编码 | 头部从几百字节压到几十字节 |
还有两个容易被忽略的机制:
- 流控是两级的:每条流、整条连接各有一个窗口,初始 65535 字节,靠
WINDOW_UPDATE补充。只调大连接窗口而不动流窗口,大响应依旧被卡住。 SETTINGS_MAX_CONCURRENT_STREAMS:服务端告诉客户端“一条连接最多同时开几个流”。常见值 100–250(Go 的 http2 服务端默认 250,nginx 默认 128)。超出的请求不会报错,而是排队——表现为“并发一高,P99 悄悄变长”,最容易被当成 GC 或下游慢。
两个已经死掉的设计,别照着老文章做方案:
| 设计 | 现状 | 结论 |
|---|---|---|
| 优先级树(PRIORITY 帧、依赖权重) | RFC 9113 弃用;RFC 9218 改用 Priority 头 + PRIORITY_UPDATE,主流实现基本按到达顺序处理 | 不要指望“给关键请求提优先级” |
| 服务端推送(PUSH_PROMISE) | Chrome 106 起移除,其它浏览器也不再支持 | 首屏优化用 preload/103 Early Hints,不是推送 |
连接合并(connection coalescing):同一个 IP、且证书覆盖多个域名时,浏览器会把 a.example.com 与 b.example.com 复用到一条连接上。代价是请求可能落到“不是为这个域名准备”的后端,于是有了 421 Misdirected Request——服务端用它告诉客户端“你发错地方了,换条连接重发”。排查“某些域名偶发 421”时,根因通常在证书 SAN 与合并策略,不在应用。
HTTP/2 没解决的三件事
| 问题 | 机制 | 线上表现 |
|---|---|---|
| TCP 层队头阻塞 | TCP 必须按序交付;一个段丢了,内核不会把后面的字节交给 HTTP/2 | 1% 丢包 + 100ms RTT 的跨境链路上,h2 的 P99 可能比 h1 更差:6 条连接时丢包只影响 1/6 的请求,合成 1 条后所有请求一起等重传 |
| 拥塞窗口共享 | 一条连接一个 cwnd,丢包后减半 | 大响应与小请求互相拖累,无法隔离 |
| 单连接故障面 | 一条连接承载全部流量,一个 RST/GOAWAY 影响所有在途流 | 发布时若直接关连接,客户端看到成片 5xx |
⚠️ 因此“h2 一条连接跑满”是反模式。工程上的折中是:每条连接承载几十到几百个流,池里放几条到几十条连接——既摊薄握手,又保留故障隔离。这条结论与 8.3 连接复用与 TIME_WAIT 的多路复用表一致;内网 gRPC 把多路复用用到极致,它的连接池取舍见 42.1 RPC 原理与 Stub。
优雅退出:h2 服务端关闭前应先发 GOAWAY(带上最后处理的流 ID),让客户端知道“这些流会完成、新的别发了”,处理完再关 socket。直接 Close() 会让在途流全部失败,客户端重试又把流量打回正在重启的实例。
HTTP/3:把“流”下沉到 QUIC
HTTP/3 不是“HTTP/2 换个传输层”,而是把可靠传输的职责从内核搬到用户态:QUIC 在 UDP 之上自己实现丢包重传、拥塞控制、流控,并把 TLS 1.3 直接嵌进握手。
| 维度 | HTTP/2 over TCP | HTTP/3 over QUIC |
|---|---|---|
| 握手 | TCP 1 RTT + TLS 1 RTT | 1 RTT 完成传输 + 加密,会话恢复可 0-RTT |
| 丢包影响 | 所有流等重传(按序交付) | 只有该流等;但 cwnd 仍共享,其它流会变慢 |
| 头部压缩 | HPACK:动态表更新必须按序处理 | QPACK:动态表更新走独立单向流,可在乱序下解码 |
| 连接迁移 | 换 IP 即断(四元组变了) | connection ID 标识连接,Wi-Fi 切 4G 不断 |
| 可观测性 | 明文头可抓 | 传输层元数据也加密,抓包只能看到“有流量” |
| 部署阻力 | 几乎没有 | UDP 443 可能被防火墙限速或直接阻断 |
三个必须知道的细节:
- 0-RTT 是可重放的。0-RTT 数据用的是上一次会话的密钥,攻击者可以重放。所以只能用于幂等请求(GET 类),且服务端要自己做防重(见 25.4 幂等设计与去重)。
- QPACK 不是 HPACK 的改名。HPACK 的动态表是全局有序的:后到的头必须等前面的表更新。QUIC 的流可以乱序到达,直接套 HPACK 会把队头阻塞又请回来;QPACK 用“独立流传表更新 + 限制阻塞流数量”解决。
- 怎么发现 h3:首次访问靠
Alt-Svc: h3=":443"; ma=86400告知,浏览器缓存后下次直连;更彻底的是 DNS 的HTTPS记录(RFC 9460),第一次就能选对。没有回退就是事故:UDP 被封时客户端必须能退回 h2/h1.1。
🔑 HTTP/3 消除的是“交付阻塞”,不是“拥塞共享”。一条链路上丢包,QUIC 仍会把 cwnd 减半,所有流一起变慢——区别是它们不用等同一个字节序列补齐。
建连时间账:新连接到底几个 RTT
| 场景 | 传输握手 | 加密握手 | 首字节总计 | 30ms RTT 下 |
|---|---|---|---|---|
| h1.1 + TLS 1.3,新连接 | 1 RTT | 1 RTT | 3 RTT | 90ms |
| h2,新连接 | 1 RTT | 1 RTT(ALPN 搭车) | 3 RTT | 90ms |
| h3,新连接 | 1 RTT(含 TLS) | 0(已在其中) | 2 RTT | 60ms |
| h3,会话恢复 + 0-RTT | 0 | 0 | 1 RTT | 30ms |
| 任意协议,复用连接 | 0 | 0 | 1 RTT | 30ms |
💡 这张表解释了为什么“省握手”比“升协议”更值钱:在 30ms 链路上,复用连接直接把 90ms 砍到 30ms,收益比 h1→h3 还大。协议升级的收益主要在并发与弱网,不在单次建连。
七层代理在这里承担“终止一段、重建另一段”的角色:客户端协商到 h3,代理回源可能还在用 h1.1,降级与复用都在代理里发生——它的连接池怎么配,见 47.1 反向代理。
实测:100 个并发请求,连接数与字节数
下面这段代码用 httptest 起一个同时支持 h2 的服务端,在监听器上统计新建连接数与服务端收到的字节数;客户端先预热一次建好连接,再并发打 100 个请求。字节数统计的是 TLS 密文(含每条记录 5 字节头 + 16 字节 AEAD tag),所以绝对值比明文略大,看比例。
package main
import (
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"time"
)
// 在监听器上数连接和字节:Accept 一次算一条新连接
type countingListener struct {
net.Listener
conns int64
bytes int64
}
func (l *countingListener) Accept() (net.Conn, error) {
c, err := l.Listener.Accept()
if err != nil {
return nil, err
}
atomic.AddInt64(&l.conns, 1)
return &countingConn{Conn: c, n: &l.bytes}, nil
}
type countingConn struct {
net.Conn
n *int64
}
func (c *countingConn) Read(p []byte) (int, error) {
n, err := c.Conn.Read(p)
atomic.AddInt64(c.n, int64(n))
return n, err
}
func newServer() (*httptest.Server, *countingListener) {
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(10 * time.Millisecond) // 模拟 10ms 业务处理,制造并发在途
io.WriteString(w, "ok")
}))
srv.EnableHTTP2 = true
cl := &countingListener{Listener: srv.Listener}
srv.Listener = cl
srv.StartTLS()
return srv, cl
}
func run(forceH2 bool, url, cookie string) (time.Duration, string) {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // 本地自签证书
ForceAttemptHTTP2: forceH2,
}
defer tr.CloseIdleConnections()
c := &http.Client{Transport: tr, Timeout: 5 * time.Second}
// 预热一次:先把连接建好,避免 100 个请求同时抢建连(那样 h2 也会开 100 条)
req, _ := http.NewRequest("GET", url+"/warmup", nil)
resp, err := c.Do(req)
if err != nil {
panic(err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
var mu sync.Mutex
proto := ""
t0 := time.Now()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
req, _ := http.NewRequest("GET", url+"/api", nil)
if cookie != "" {
req.Header.Set("Cookie", cookie)
}
resp, err := c.Do(req)
if err != nil {
panic(err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
mu.Lock()
proto = resp.Proto
mu.Unlock()
}()
}
wg.Wait()
return time.Since(t0), proto
}
func main() {
cookie := "session=" + strings.Repeat("a", 4000) // 4KB Cookie:贴近浏览器单 Cookie 上限
for _, tc := range []struct{ name, cookie string }{
{"无 Cookie", ""},
{"500B Cookie", "session=" + strings.Repeat("b", 492)},
{"4KB Cookie", cookie},
} {
fmt.Printf("== 100 个并发请求,%s ==\n", tc.name)
for _, h2 := range []bool{false, true} {
srv, cl := newServer()
d, proto := run(h2, srv.URL, tc.cookie)
fmt.Printf(" %-8s 协议 %-8s 耗时 %-9v 新建连接 %3d TLS 握手 %3d 服务端收到 %6.1f KB\n",
map[bool]string{false: "HTTP/1.1", true: "HTTP/2"}[h2],
proto, d.Round(time.Millisecond),
atomic.LoadInt64(&cl.conns), atomic.LoadInt64(&cl.conns),
float64(atomic.LoadInt64(&cl.bytes))/1024)
srv.CloseClientConnections()
srv.Close()
}
}
}
本机实测(macOS,Apple M1,Go 1.27;跑三次,字节数稳定,新建连接数 98–100,耗时波动 ±30%):
== 100 个并发请求,无 Cookie ==
HTTP/1.1 协议 HTTP/1.1 耗时 56ms 新建连接 100 TLS 握手 100 服务端收到 47.1 KB
HTTP/2 协议 HTTP/2.0 耗时 13ms 新建连接 1 TLS 握手 1 服务端收到 4.2 KB
== 100 个并发请求,500B Cookie ==
HTTP/1.1 协议 HTTP/1.1 耗时 46ms 新建连接 98 TLS 握手 98 服务端收到 96.1 KB
HTTP/2 协议 HTTP/2.0 耗时 13ms 新建连接 1 TLS 握手 1 服务端收到 4.6 KB
== 100 个并发请求,4KB Cookie ==
HTTP/1.1 协议 HTTP/1.1 耗时 44ms 新建连接 100 TLS 握手 100 服务端收到 444.6 KB
HTTP/2 协议 HTTP/2.0 耗时 15ms 新建连接 1 TLS 握手 1 服务端收到 252.6 KB
换算成每请求开销,结论比绝对值更重要:
| Cookie 大小 | h1.1 每请求 | h2 每请求 | 压缩比 | 为什么 |
|---|---|---|---|---|
| 无 | 471 B | 42 B | −91% | HPACK 把常量头放进动态表,后续只发索引 |
| 500 B | 961 B | 46 B | −95% | 500B 条目能放进 4096B 动态表,后续请求只发“表索引 + 增量” |
| 4 KB | 4.45 KB | 2.53 KB | −43% | 条目超过动态表容量,无法索引,只能每请求 Huffman 重发 |
⚠️ 最后一行是本节最反直觉的数字:HTTP/2 的头部压缩治不了超大 Cookie。HPACK 要索引一条头,条目大小(名 + 值 + 32 字节开销)必须小于动态表容量;默认 4096 字节的表装不下 4KB 的 Cookie。把会话塞进 4KB Cookie 的系统,升到 h2 后头部开销只降一半——该改的是“别把状态放 Cookie 里”,不是协议。
💡 连接数与握手数的比值解释了耗时差:h1.1 的 100 个并发要 100 次 TLS 握手(回环上每次约 0.4ms,串起来就是几十毫秒),h2 只要 1 次。生产环境 RTT 越大,这个差距越大。
怎么选:决策表
| 你的情况 | 选择 | 原因 |
|---|---|---|
| 公网 Web / API,用户是浏览器 | h3 优先 + h2/h1.1 回退 | CDN 与主流浏览器已默认;回退是必须项 |
| 内网 gRPC / RPC | h2c(明文)或 h2 | h3 的收益在弱网与移动端,内网几乎没有 |
| 老客户端、老旧中间设备多 | h1.1 + keep-alive + 连接池 | 协议升级的兼容成本高于收益 |
| 大文件下载、视频 | h1.1 或 h2 均可 | 瓶颈在带宽与缓冲区,不在协议 |
| 有状态推送 / 双向 | WebSocket(h1.1 Upgrade)或 h2 流 | 见 8.11 长连接与 WebSocket |
| 需要抓包审计、链路排障 | h1.1/h2 + keylog | h3 的传输层元数据加密,抓包只剩密文 |
| 移动端 / 频繁切网 | h3 | connection ID 让换网不断连 |
什么时候别升 h2/h3:① 服务端是“一请求一进程”的老模型(如 CGI 风格),多路复用没有收益;② 全站流量已经集中在 CDN,源站只面对少量长连接;③ 团队没有能力排查 h2 的流控与 GOAWAY 问题——升上去后“偶发变慢”会变成常态。我的判断是:没有 h2 排障能力的团队,上 h2 的净收益可能为负。
反模式
| 反模式 | 后果 |
|---|---|
| 上了 h2 就以为不用连接池,一条连接跑全站 | 一次 RST/GOAWAY 全部在途流失败,故障面被放大 |
| 把 4KB 会话塞进 Cookie,指望 HPACK 压缩 | 动态表装不下,每请求仍重发 2.5KB |
| 只调连接窗口、不调流窗口 | 大响应被单流窗口卡住,吞吐上不去 |
| 按“连接数”做限流/负载均衡 | h2 下连接数恒定,限流与最少连接都失真(见 8.9 四层与七层负载均衡) |
发布时直接 Close() 而不发 GOAWAY | 客户端成片 5xx,重试又打回重启中的实例 |
| 用服务端推送做首屏优化 | 浏览器已移除支持,纯浪费 |
| 只开 h3、不保留 h1.1 回退 | UDP 443 被限速/阻断时全站不可用 |
不设 MaxConcurrentStreams 就上线 | 单连接被少数大请求占满,其余请求排队 |
| 把 h2 多路复用当“零成本并发” | 丢包时 cwnd 减半,所有流一起变慢,P99 反而比 h1 差 |
自检
Q1 一条 100ms RTT、1% 丢包的跨境链路上,把 h1.1(浏览器 6 连接)换成单连接 h2,页面加载反而变慢。按机制解释原因,并给出改法。
▸ 答案:两个机制叠加。① TCP 层队头阻塞:6 条连接时一个丢包只阻塞 1/6 的请求,合成 1 条后所有流一起等重传,而重传要等快速重传或 RTO(100ms RTT 下 RTO 常在 200ms 以上);② cwnd 共享:丢包把唯一一条连接的拥塞窗口减半,所有流一起降速。改法:不要压到 1 条连接(按“几十个流/连接、几条连接”配池),或换 h3(丢包只阻塞该流,但 cwnd 仍共享),或对跨境链路换 BBR 类拥塞控制(见 8.2 拥塞控制、Nagle 与延迟)。
Q2 压测显示 h2 下 4KB Cookie 的请求头部开销只比 h1.1 少 43%,而 500B Cookie 少了 95%。为什么?这说明了什么?
▸ 答案:HPACK 的动态表默认只有 4096 字节,一条可索引的条目要算“名字 + 值 + 32 字节开销”,4KB 的 Cookie 加进去就超了,于是不索引,只能每请求用 Huffman 重发(实测每请求 2.53KB);500B 能索引,后续请求只发几个字节的索引(实测 46B/请求)。说明:协议压缩的上限由表容量决定,不是“h2 一定压得狠”;真正该做的是缩小 Cookie(会话存服务端、只放 session id)。
Q3 一个页面 100 个资源,h1.1 开 6 条连接与 h2 开 1 条连接,哪种更快?答案取决于哪两个变量?
▸ 答案:取决于丢包率与RTT。无丢包、RTT 大时 h2 明显更快:6 条连接要串行排队 100 个请求(每条约 17 个),而 h2 全部并发、头部还压缩了;有丢包时结论反转:h1.1 的多连接把丢包影响限制在 1/6 的请求上,h2 一条连接上任何丢包都会阻塞所有流并减半 cwnd。RTT 小(同机房)时两者差距缩小,因为握手与排队成本都低。
Q4 为什么 HTTP/3 选择“在 UDP 上重建可靠传输”,而不是继续改进 TCP?
▸ 答案:中间设备僵化(ossification)。TCP 头是明文,路径上的防火墙/NAT/负载均衡会检查甚至改写特定字段,任何新传输特性(新的握手选项、更细的流语义)部署率都极低;而且 TCP 在内核里,升级要动操作系统。QUIC 把可靠传输搬到用户态、把传输层元数据一起加密,中间设备“看不见就改不了”,新算法(如 BBR)也能随应用发布。代价是:UDP 可能被限速或阻断、用户态协议栈的 CPU 开销更高、抓包排障更难。
Q5 为什么 HTTP/2 在丢包严重时可能比 HTTP/1.1 还慢?
▸ 答案:因为 HTTP/2 的多路复用跑在单条 TCP 连接上:一个丢包会阻塞这条连接上的所有流(TCP 层队头阻塞),而 HTTP/1.1 用 6 条连接时,丢包只影响其中一条。HTTP/3 改用 QUIC(基于 UDP、流级独立),正是为了解决这个问题。