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、流级独立),正是为了解决这个问题。

关联