# 第 18 章 HTTP 演进与 HTTPS


> 本章回答：为什么 HTTP/1.1 必须靠"多开连接"续命，而 HTTPS 又凭什么在 1~2 个 RTT 内谈完密钥。

## 学习目标

读完本章，你应当能够：

1. 推导 HTTP/1.1 队头阻塞（head-of-line blocking, HOL）的成因，说明"浏览器 6 连接"与"域名分片"是同一原因的两种表现；
2. 区分应用层 HOL 与 TCP 层 HOL，指出后者为什么是 HTTP/3 的唯一动机；
3. 画出 TLS 1.2（2-RTT）与 TLS 1.3（1-RTT、0-RTT）握手时序，逐条说出 TLS 1.3 砍掉了什么、为什么能省一个 RTT；
4. 判断 `301/302/307/308`、`401/403`、`502/504` 的语义差异，并指出"是否允许改变请求方法"这一判定轴；
5. 解释 `SNI`、`HSTS`、`OCSP Stapling` 各自解决的具体问题，而不是背缩写。

### 前置知识检查

| 你需要知道 | 在哪一节补齐 |
|---|---|
| TCP 三次握手、`TIME_WAIT`、半连接队列 | 见第 16 章 16.1~16.4 节 |
| `cwnd`/`ssthresh` 演化、快速重传与 SACK | 见第 17 章 17.4~17.6 节 |
| MTU/MSS 与分层封装解封装 | 见第 15 章 15.3 节 |
| TLS 记录层与密码套件命名读法 | 本章 18.7 节给出最小必要背景 |

## 18.1 HTTP 的定位与无状态性

HTTP（HyperText Transfer Protocol）是**应用层**的请求-响应协议，只约定报文的语义与格式，不约定连接怎么建、怎么复用、怎么保证可靠——这些全部下推给传输层。本章的版本演进都发生在这条分界线的夹缝里。

**无状态（stateless）**要说得比课本精确：服务端处理一个请求时，**不依赖此前任何请求留下的、协议层自带的状态**，每个请求必须自带全部上下文。好处是工程性的：

| 好处 | 机制上的原因 |
|---|---|
| 服务端可任意水平扩缩 | 任一请求可落到任意实例，无需 sticky 路由 |
| 单点故障不影响会话 | 实例崩溃不丢协议层状态，天然可重启 |
| 中间层可缓存、可重放、可重试 | 请求自含语义，代理无需回放历史 |
| 协议实现简单 | 无需在服务端维护每连接的会话状态机 |

代价是应用状态必须外置。**Cookie**（RFC 6265）是客户端存储加自动回带（按域、路径、`Max-Age`、`Secure`、`HttpOnly`、`SameSite` 约束），本身不含语义；**Session** 才是真正的状态，放在服务端内存／Redis／DB，Cookie 里只放一个不可猜测的 ID。于是无状态协议上重新引入了状态，代价立刻显现：会话粘滞（sticky session）、会话存储成为新单点、跨机房同步延迟、`SameSite` 与 CSRF 的耦合。

> [!IMPORTANT]
> 面试标准落点：**无状态是协议设计的正确取向，Cookie/Session 是应用层补偿，代价是"服务端又变成有状态了"。** 只说"HTTP 无状态所以用 Cookie"是半句话，面试官会追问"Session 存哪里、跨机房怎么办"。

### 18.1.1 HTTP 报文格式

报文格式必须能默画，因为它解释了后面几乎所有演进：HTTP/1.1 的每个请求都要在同一条字节流上与前后请求首尾相接，**格式本身携带了边界信息**。

```text
GET /api/v1/users?page=2 HTTP/1.1    ← 起始行：方法 + 目标 + 版本
Host: api.example.com                ← 头部字段（header fields），0..N 行
Accept: application/json
                          ← 空行：CRLF，格式上的强制分隔符
{"id":1}                             ← 正文（body），可缺省
```

四个部分：**起始行（start-line）+ 头部字段 + 空行（CRLF）+ 正文（body）**。三个会被追问的点：

1. **空行是格式的一部分，不是排版**。没有它接收方无法知道头部在哪结束；这也是头部能逐行解析、而正文只能用长度或分块定界的原因。
2. **正文定界有三条路**：`Content-Length` 定长、`Transfer-Encoding: chunked` 分块（长度未知时）、由连接关闭隐式定界。第三条正是流水线无法工作的直接原因之一。
3. **`Host` 是 HTTP/1.1 的强制头**，它是"一个 IP 承载多域名"的基础；HTTPS 里同样的需求由 `SNI` 在 TLS 层完成（见 18.9.3）。

## 18.2 HTTP/1.1：持久连接、连接池与管线化

### 18.2.1 持久连接与连接池

HTTP/1.0 默认每请求一条 TCP 连接，一次页面几十个资源就要几十次"三次握手 + 慢启动"。成本是实打实的：同机房 RTT 约 0.1~0.5 ms，跨大洲 RTT 约 150 ms，而 `cwnd` 只从 1~10 个 MSS 起跳。

HTTP/1.1 默认**持久连接（persistent connection）**（HTTP/1.1 中 `Connection: keep-alive` 其实冗余，缺省即持久；`Connection: close` 才是否定）。工程上真正的重点是**连接池**：客户端按"每源 6 条"限流；服务端把建连成本摊销到每 N 个请求；而 NAT 网关、四层负载均衡通常有 60~900 s 的空闲回收，于是 `keepalive_timeout` 必须**小于**上游的回收时间，否则会出现"连接看着是活的、一写就 RST"的半开（half-open）现象——这是线上最常见的 502 来源之一。

> [!TIP]
> `keepalive_timeout` 的取值原则是"比谁都短"：让服务端成为主动关闭方——只有主动关闭方才走完整 `FIN` 流程，而不是撞上对端已消失的 `RST`（`TIME_WAIT` 的归属见第 16 章 16.5 节）。

### 18.2.2 管线化为什么在实践中失败

**管线化（pipelining）**的设想很自然：既连接是持久的，客户端为什么不能一次把请求 A、B、C 全写出去，再按顺序读响应？三个原因必须分开讲：

1. **响应必须严格按请求顺序返回**（RFC 7230 要求）。服务端不能先回 B 再回 A，因为响应里**没有请求 ID**；于是 A 慢（比如一次重查询），B、C 早就处理完了也只能等——这就是应用层队头阻塞。
2. **中间设备兼容性**：早期代理对"一个连接里连续到达多个请求"处理不一致，有的直接关连接，有的串行化，有的缓存出错。协议允许不等于部署环境支持。
3. **出错后无法确定应答归属**：一旦服务端提前关连接或某个响应出错，客户端无法判断剩余字节属于哪个请求，只能整条连接作废重发。**这是"缺少请求标识符"的直接后果**，也解释了 HTTP/2 为什么必须引入 `Stream ID`。

```text
HTTP/1.1 管线化的失败（应用层 HOL），时间 →
客户端 ──┬─ req A ─┬─ req B ─┬─ req C ───────────────────────►
服务端   │ 处理A（慢，200 ms） │ 处理B（快，5 ms）→ 完成但只能等待
         └─ resp A ─────────────────────► resp B ─► resp C
            └─── 这 200 ms 里 B、C 的响应被"堵"在 A 后面 ───┘
```

## 18.3 队头阻塞：应用层 vs 传输层

"队头阻塞"被过度使用，面试最容易被抓的是**不区分层次**。

**HTTP/1.1 的 HOL 在应用层**：同一连接上请求-响应严格串行，它甚至不需要管线化就已经存在——不开管线化时客户端必须等上一个响应读完才发下一个，串行度 100%。**绕过的办法是开更多连接**：浏览器对每个源（origin）默认限制 6 条并发连接（HTTP/1.1 时代的事实标准），并发度从 1 变成 6。这是绕过而非解决：第 7 个请求仍要排队。

**域名分片（domain sharding）**正是顺着这个限制做的工程妥协：把 `static.example.com` 拆成 `s1..s2.example.com`，因为限流单位是"源"，2 个域名就能拿到 `2 × 6 = 12` 条连接。代价同样具体：

| 代价 | 机制 |
|---|---|
| 多出 DNS 解析 | 每个新域名一次解析，冷启动额外 RTT（见第 19 章 19.2 节） |
| 多出 TLS 握手 | 每域名独立证书与握手，HTTPS 下代价翻倍 |
| 连接池碎片化 | 每域名连接利用率下降，空闲连接仍占内存与 fd |
| 破坏拥塞控制效率 | 24 条连接各自从慢启动爬，总吞吐与丢包敏感度都更差 |
| 与 HTTP/2 直接冲突 | 分片会破坏单连接多路复用前提，属负优化 |

> [!CAUTION]
> 把"HTTP/1.1 的队头阻塞"和"HTTP/2 的队头阻塞"混为一谈，是这章最容易把面试答崩的地方。前者是**应用层串行**、可用多连接绕过；后者是**TCP 字节流的有序交付**、无法用多连接绕过（HTTP/2 的本意就是不许多开连接）。层次说错，HTTP/3 的动机就全错了。

## 18.4 HTTP/2：二进制分帧与多路复用

HTTP/2（RFC 7540）**没有改语义**（方法、状态码、头部字段含义全部沿用 HTTP/1.1），只改了两件事：报文怎么编码、报文怎么在连接上复用。

### 18.4.1 二进制分帧层

HTTP/2 把报文切成**帧（frame）**，每帧带 9 字节固定头；`Length` 24 位决定单帧载荷上限，默认 `SETTINGS_MAX_FRAME_SIZE` 是 `16 384`（2¹⁴）字节；`Stream Identifier` 31 位是**复用的全部秘密**——接收方靠它把交错的帧归位到各自的响应上，这正是 HTTP/1.1 缺的那个"请求标识符"。

```text
HTTP/2 帧头（9 字节）: | Length(24) | Type(8) | Flags(8) |R| StreamID(31) |
Payload 长度 0 .. 2²⁴−1 字节；Type 决定这是 HEADERS / DATA / SETTINGS / ...
```

**二进制而非文本**的收益有两块：解析不必再处理文本边界与大小写变体；帧可以在**同一连接上交错（interleave）**，因为边界判定不再依赖"行"和"顺序"。

### 18.4.2 流、消息与帧的三层关系

必考的结构题：

```text
一条 HTTP/2 连接（TCP）= 多个流；Stream 1 (ID=1, 客户端发起) ← 流：请求-响应的
逻辑通道，双向、有状态；Message（请求）= HEADERS + DATA 帧 ← 消息：一次完整
请求或响应；Message（响应）= HEADERS + 2×DATA 帧(END_STREAM)
├── Stream 3 (ID=3) └── Message: HEADERS / HEADERS + DATA 帧
└── Stream 5 (ID=5) └── Message: HEADERS + DATA 帧(END_STREAM)
线上字节流交错到达，接收方按 Stream ID 重组：[H1][H3][H1][D3][H5][D1]...
```规则：**流是逻辑通道**（并发数由 `SETTINGS_MAX_CONCURRENT_STREAMS` 约束，实践常为 100+）；**消息是完整语义单元**；**帧是最小传输单位**。客户端发起的流 ID 为奇数，服务端发起（推送）的为偶数，ID 单调递增且不能重用，耗尽后需 `GOAWAY` 或新建连接。

### 18.4.3 头部压缩 HPACK

HTTP/1.1 的头部是纯文本且大量重复（Cookie、`User-Agent` 每请求带一遍）。HTTP/2 用 **HPACK**（RFC 7541）压缩，三个机制协同：

| 机制 | 做法 | 省字节的原理 |
|---|---|---|
| 静态表（static table） | 61 条预定义的字段名+常见值（`:method: GET`、`:status: 200`） | 常见头部压成 1 个索引字节，`:method: GET` 从约 14 字节降到 1 字节 |
| 动态表（dynamic table） | 连接级 FIFO 表，本次出现的字段名/值可被后续索引引用 | 第二个请求的 `user-agent` 变成 1 个索引，**跨请求去重**是大头 |
| 哈夫曼编码（Huffman coding） | 对未命中表的字面量按 RFC 7541 静态哈夫曼表编码 | 字面量再省约 30% |

动态表是**连接级、有状态的**，两个约束随之而来：压缩上下文不能跨连接复用；表容量需由 `SETTINGS_HEADER_TABLE_SIZE` 与 `dynamic table size update` 协商同步。也正因为有状态，HPACK 需要用表大小上限与重置机制限制解码侧信道的影响面。

### 18.4.4 服务端推送、优先级与它们的结局

**服务端推送（server push）**允许服务端用偶数 ID 的流主动推资源（典型是 `index.html` 的 CSS/JS），省掉"解析 HTML 再发请求"的一次往返。它在实践中基本失败了：服务端很难知道客户端缓存里已有什么，容易推重复白耗带宽；推送与 `Cache-Control` 交互复杂；且与浏览器自身的预加载与优先级逻辑冲突。**结果是 HTTP/3（RFC 9114）不再有 `PUSH_PROMISE`**，改用 `103 Early Hints` 让服务端"提示"客户端去请求。

**流优先级与依赖树**允许客户端给流指定权重（1~256）与依赖关系，构成一棵依赖树；这套复杂树模型难以被正确实现，后来被 RFC 9218 的**可扩展优先级方案**取代，简化为 `urgency`（0~7）与 `incremental` 两个参数。

> [!NOTE]
> HTTP/2 是"语义不变、传输层重写"的一次升级，且明确要求客户端**不要**再为绕开并发限制而开多条连接，否则多路复用与 HPACK 的收益互相抵消。记住这条"不许多开连接"，18.5 节才讲得通。

## 18.5 HTTP/2 仍存在 TCP 层队头阻塞

这是 HTTP/2 最容易被考到的缺点，也是 HTTP/3 存在的唯一原因：**多路复用解决了应用层串行，却把并发放进了同一条 TCP 连接，而 TCP 的交付语义是"字节流按序交付"。**

```text
TCP 层 HOL：一个包丢了，全部流一起等
Stream 1   ██████████████████  ← 帧早已到达
Stream 3   ██████████████████  ← 帧早已到达
Stream 5   ██████░░░░░░░░░░░░  ← 第 3 个包丢了
                     ↑
TCP 接收缓冲区：[ ..... gap ..... ][后续字节全部就绪，但应用层读不到]
                     ↓ 内核不把 gap 之后的字节交给应用层（按序交付），HPACK 解码器也无法推进
```

关键在于**这个等待是 RTT 量级的**：同机房 RTT 约 0.1~0.5 ms 尚可忍，跨大洲 RTT 约 150 ms 时一次丢包就让**所有**流各停 150 ms 以上。丢包率越高、RTT 越大，HTTP/2 相对 HTTP/1.1 越吃亏——HTTP/1.1 时代一次丢包只影响其中一条连接上的一个请求，另外 5 条照常前进。

> [!IMPORTANT]
> 一句话标准答案：**HTTP/2 用一条 TCP 连接承载所有流，TCP 的按序交付使任一报文段丢失阻塞全部流；多开连接救不了，因为多路复用与 HPACK 的收益都建立在"单连接"上。** 要解决它，必须把"流的独立重传"下沉到传输层——这就是 QUIC。

## 18.6 HTTP/3 与 QUIC

**QUIC**（RFC 9000）是跑在 UDP 之上的**用户态传输协议**，HTTP/3（RFC 9114）就是把 HTTP 语义映射到 QUIC 上。三个问题必须答清。

### 18.6.1 为什么基于 UDP

不是因为 UDP 更好，而是因为**中间设备僵化（ossification）**：大量 NAT、防火墙、负载均衡只认 TCP/UDP 的既有行为，新传输协议若占用新的 IP 协议号（如 SCTP）会被直接丢弃；封装成 UDP 载荷则能以很高概率穿过这些设备。既然用 UDP，TCP 提供过的一切都得在**用户态**重建，而重建带来两个副产品：**可快速迭代**（不必等内核升级与操作系统普及，随浏览器/服务更新即可上线新特性），以及**连接内的流独立可控**。

### 18.6.2 QUIC 的流与拥塞控制

| 能力 | TCP + TLS（HTTP/2 栈） | QUIC（HTTP/3 栈） |
|---|---|---|
| 丢包影响范围 | 阻塞整条连接的所有流 | 只阻塞该流，其他流照常交付 |
| 握手 | TCP 三次握手 + TLS 握手串行叠加 | 传输握手与 TLS 握手合并 |
| 加密范围 | TLS 只加密应用数据，TCP 头明文 | 除少数字段外连传输层头也加密 |
| 连接标识 | 四元组 `(源IP, 源端口, 目的IP, 目的端口)` | `Connection ID` |
| 拥塞控制位置 | 内核 | 用户态，可插拔 |
| 队头阻塞层次 | 应用层已解决，传输层仍在 | 跨流 HOL 消除 |

QUIC 内部有独立的流级流控与连接级流控，流的字节偏移独立。但要说准一个点：**同一流内部仍然有序交付**，所以 QUIC 消除的是"跨流 HOL"，不是"流内 HOL"。头部压缩在 HTTP/3 中换成 **QPACK**（RFC 9204），专门为"跨流乱序到达"重新设计——这正是 HPACK 那种"一个头部帧丢了就卡住所有流"的机制不能在 QUIC 上直接复用的原因。

### 18.6.3 0-RTT、重放风险与连接迁移

- **1-RTT 建连**：首次连接时 QUIC 的传输握手（含 TLS 1.3 密钥协商）在一个往返内完成；而 TCP + TLS 1.2 通常要 3 个往返（1 建连 + 2 握手）。
- **0-RTT 与会话复用**：客户端用上次会话的票据（session ticket）直接加密并发送应用数据，做到"第一个包就带业务数据"。代价是 **0-RTT 数据没有前向保密且可被重放**——攻击者抓到这段密文就能重复发送。因此 0-RTT 只适用于**幂等且无副作用**的请求（如 `GET` 静态资源），绝不能用于支付、提交订单；服务端还必须自行做重放防护（一次性票据、时间窗、请求去重）。
- **连接迁移（connection migration）**：`Connection ID` 取代四元组作为连接身份，所以手机从 Wi-Fi 切到 4G、或 NAT 重新映射端口时连接不中断，也不必重新握手。

- **弱网优势的量化直觉**：设 RTT = 150 ms、丢包率 2%。HTTP/2 下每次丢包让所有并发流各等一个 RTT 量级；HTTP/3 下只有丢包的那个流等。这就是"弱网下 HTTP/3 的 P99 明显好于 HTTP/2"的来源——**不是带宽更高，而是尾延迟抖动更小**。

> [!WARNING]
> 常见错误回答："QUIC 用 UDP 所以更快，因为 UDP 不握手、不重传。"QUIC **自己实现了可靠性、重传、拥塞控制与流控**，比 TCP 更复杂，不是退回不可靠。另一个错误是把 0-RTT 当纯收益——**0-RTT 有重放风险，必须限制在幂等请求**。

## 18.7 HTTPS 与 TLS 的位置：四种密码学原语的分工

**TLS（Transport Layer Security）位于传输层之上、应用层之下**，给应用层留下一个"看起来像可靠字节流、但已加密并校验过完整性"的接口，因此 HTTP 本身完全不必改就能被保护——这也是 HTTPS 只是 "HTTP over TLS" 的原因。四种原语解决四个不同问题，最容易混：

| 原语 | 解决什么 | 在 TLS 中的用途 | 性能量级（相对） |
|---|---|---|---|
| 对称加密（symmetric） | 大批量数据的机密性 | `AES-GCM`/`ChaCha20-Poly1305` 加密应用数据 | 极快，有硬件加速时可达 GB/s |
| 非对称加密（asymmetric） | 密钥在不安全信道上的分发、身份鉴别 | `ECDHE` 密钥交换、`RSA`/`ECDSA` 签名 | 慢 2~3 个数量级 |
| 消息认证码（MAC/AEAD） | 完整性：数据被改过没有、来源是否持有密钥 | `GCM` 的认证标签、TLS 1.2 `CBC` 套件的 `HMAC` | 快，通常与加密一起 |
| 数字签名（digital signature） | 不可否认的身份：证明消息确由持私钥者发出 | 证书签名、`CertificateVerify`、`ServerKeyExchange` 签名 | 慢，但只用几次 |

**为什么非对称只用来协商密钥、数据用对称传**：非对称是模幂／椭圆曲线点乘这类大数运算，比 AES 慢 2~3 个数量级，一次响应几百 KB 用 RSA 逐块加密不可行。所以握手阶段用非对称（或基于 DH 的密钥交换）**只协商出一个小秘密**，之后全部用对称密钥。

> [!NOTE]
> 面试官会追问的下一层：**"对称密钥不能直接用非对称加密传过去吗？"** TLS 1.2 的 `RSA` 密钥交换套件确实如此——客户端生成预主密钥，用服务端 RSA 公钥加密后发过去。TLS 1.3 **删掉了静态 RSA 密钥交换**，全部改为 (EC)DHE 的**前向保密（forward secrecy）**：即使服务端私钥日后泄露，也无法解密此前录下的流量，因为会话密钥由双方临时私钥共同决定、用完即弃。

## 18.8 TLS 1.2 与 TLS 1.3 握手对比

### 18.8.1 完整握手时序

```text
TLS 1.2 = 2 RTT                       TLS 1.3 = 1 RTT
──────────────────────────────        ─────────────────────────────────
C ─ ClientHello ───────────► S        C ─ ClientHello ──────────────► S
   client_random(32B), cipher_suites,    client_random, cipher_suites,
   extensions                            key_share(猜定的 ECDHE 公钥)
C ◄ ServerHello/Certificate/ ──►      C ◄ ServerHello ─────────────── S
   ServerKeyExchange ──────── S           server_random, key_share
   (server_random 32B、选定套件、          ── 双方已能算出握手密钥 ──
    ECDHE 参数 + 私钥签名)            C ◄ {EncryptedExtensions}, {Certificate},
C ◄ ServerHelloDone ─────── S             {CertificateVerify}, {Finished} ── S
 ══ 第 1 RTT：拿到随机数、             C ─ {Finished} + {HTTP 请求} ──► S
    套件、证书、DH 参数 ══              C ◄ {HTTP 响应} ─────────────── S
C ─ ClientKeyExchange ────► S            ══ 1 RTT 后即可发数据 ══
C ─ ChangeCipherSpec, Finished ► S
C ◄ ChangeCipherSpec, Finished S
 ══ 第 2 RTT 后才发数据 ══
```

**为什么 TLS 1.3 能少一个 RTT**：TLS 1.2 的客户端必须先看到服务端的 `ServerHello` 才知道用哪个组，DH 公钥只能放到第 2 个往返；TLS 1.3 让客户端在 `ClientHello` 里**先猜一个组并带上 `key_share`**（通常是 X25519），服务端若接受该组立刻就能算出共享秘密，于是第 1 个往返里服务端就能把证书与 `Finished` 一起加密发回。猜错时服务端回 `HelloRetryRequest`，退回 2-RTT——这是 TLS 1.3 唯一的额外往返。

**TLS 1.3 砍掉了什么**：

| 被砍掉的东西 | 砍掉的原因 |
|---|---|
| 静态 RSA 密钥交换 | 无前向保密：服务端私钥泄露即可解密历史流量 |
| 重协商（renegotiation） | 历史漏洞多（重协商前缀攻击），且与 PSK/0-RTT 模型冲突 |
| 压缩（compression） | CRIME 类攻击利用压缩率泄露明文 |
| `CBC` 模式套件 | 填充预言（padding oracle）类攻击反复出现，只保留 AEAD |
| 自定义 DH 参数 | 小群/非安全群导致降级；只允许几个标准命名组 |
| `ChangeCipherSpec` | 握手状态可隐式推导，少一个可被利用的消息 |
| 出口级弱套件、`RC4`、`SHA-1` | 已知不安全 |

TLS 1.3 还把**证书本身也加密**（放在 `EncryptedExtensions` 之后），顺带修掉了"证书明文暴露访问域名"的隐私问题。

### 18.8.2 会话复用与 0-RTT

| 模式 | 往返数 | 机制 | 风险 |
|---|---|---|---|
| TLS 1.2 session ID / ticket | 1-RTT | 服务端缓存或客户端持票据，复用主密钥 | 票据泄露即可解密；无前向保密 |
| TLS 1.3 PSK 复用（`pre_shared_key`） | 1-RTT | 票据承载 PSK，跳过证书与密钥交换 | 依赖票据保密性 |
| TLS 1.3 0-RTT（early data） | 0-RTT | 第一个包就用 PSK 加密并携带应用数据 | **可重放**；0-RTT 数据无前向保密 |

```text
一次全新 HTTPS 请求，发 HTTP 数据前花掉多少往返？RTT 口径：同机房 0.1~0.5 ms、跨大洲约 150 ms
  DNS 解析（无缓存） 1~2 RTT → TCP 三次握手 1 RTT → TLS 1.2 握手 2 RTT → 合计约 4~5 RTT
  改用 TLS 1.3：1 + 1 = 3~4 RTT（省 1 RTT）；用 0-RTT（仅幂等请求）再省 1 RTT
跨大洲每省 1 RTT ≈ 省 150 ms，这才是 TLS 1.3 与 0-RTT 的真正价值（HTTP 分层排查见第 19 章 19.2 节）。
```

**0-RTT 的适用边界**：只允许幂等、无副作用、可重放的请求（服务端应拒绝非 `GET`/`HEAD` 的 0-RTT，或只用于静态资源，并做一次性票据校验）。**把"0-RTT 快"和"0-RTT 可被重放"一起说，才完整。**

## 18.9 证书与验证链

### 18.9.1 证书里有什么

X.509 证书的关键字段：**使用者（Subject，含 CN 与 SAN）**、**颁发者（Issuer）**、**有效期**、**公钥**、**签名算法**、**签名值**及若干扩展。两个必考点：**SAN（Subject Alternative Name）已取代 CN**，现代实现只认 `SAN`，多域名靠 `SAN` 列表或通配符 `*.example.com`（只匹配一级子域）；另外**证书本身不加密通信，它只绑定"公钥 ↔ 域名/组织"**，并用 CA 签名让任何人可验证这一绑定，真正的加密在握手后由对称密钥完成。

### 18.9.2 CA 层级与链式验证

```text
根证书（Root CA，自签名，预装于操作系统/浏览器信任库，私钥离线）
  └── 中间 CA（Intermediate CA，由根 CA 签名；日常签发者，泄露可吊销而不伤根）
        └── 叶子证书（Leaf，服务端部署）
              Subject: CN=api.example.com  SAN=[api.example.com, *.example.com]

客户端本地验证（不需要联网）：
  1. 用【中间 CA 公钥】验证【叶子证书】签名 → 叶子未被篡改
  2. 用【根 CA 公钥】验证【中间 CA 证书】签名 → 中间 CA 可信
  3. 根 CA 证书在本地信任库 → 信任锚，链条闭合
  4. 逐层检查有效期、SAN 是否匹配目标域名、用途扩展（Key Usage / EKU）
  5. 检查吊销状态（CRL / OCSP / Stapling）
```

**服务端必须发送完整链（叶子 + 中间 CA），但不发根证书**（根在客户端本地已有）；"链不完整（missing intermediate）"是线上最常见的 TLS 报错之一，浏览器可能靠 AIA 扩展自动补链，但移动 SDK、Java 客户端、curl 等往往不会，于是直接失败。

### 18.9.3 SNI、吊销与 HSTS

**SNI（Server Name Indication）**是 `ClientHello` 中的扩展，明文携带客户端想访问的域名，解决**一个 IP 上部署多个 HTTPS 域名**的问题：没有 SNI，服务端在握手时必须先决定出示哪张证书，而此时它还不知道客户端要访问哪个域名（HTTP 的 `Host` 还没发过来，且整个 HTTP 层都在加密之后）。SNI 的隐私缺陷也很实际：它是明文，**暴露你要访问的域名**；`ECH`（Encrypted Client Hello）就是在补这个洞。

| 吊销机制 | 做法 | 问题 |
|---|---|---|
| CRL | 定期下载 CA 的全量吊销列表 | 列表可能很大、更新有延迟，客户端可能干脆不检查（fail-open） |
| OCSP | 客户端向 CA 实时查询单张证书状态 | 多一次网络往返；CA 可见用户访问行为；CA 不可用时可用性下降 |
| OCSP Stapling | **服务端**定期取回带时间戳的 OCSP 响应，握手时"钉"给客户端 | 省掉客户端往返并保护隐私；缺点是响应有有效期，服务端必须能成功刷新 |

**HSTS（HTTP Strict Transport Security）**用 `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload` 告诉浏览器"此后一段时间内对我只用 HTTPS"。它解决**降级攻击**：用户输入 `example.com` 或点某个 `http://` 链接时首次请求是明文的，中间人可劫持并阻止跳转（SSL Strip）；HSTS 让浏览器在本地就把该域名的 `http://` 改写成 `https://`，不发明文请求。`preload` 进一步把域名硬编码进浏览器，补上"第一次访问还没收到 HSTS 头"的窗口。

**中间人攻击（MITM）与自签证书**：MITM 的本质是"攻击者用自己的证书冒充服务端"。若客户端信任库里有攻击者的根证书（企业内网代理、被装过根证书的设备），验证就会通过——**TLS 无法防御"客户端自己信任了错误根"**。自签证书靠同一机制失效：验证链找不到本地信任锚，于是浏览器报警；用 `curl -k` 跳过校验"解决"，在生产环境等于放弃身份鉴别。

> [!CAUTION]
> 不要说"HSTS 加密了请求"或"OCSP Stapling 帮客户端省了握手"。HSTS 只做**强制跳转与降级防护**（前提是已有 HTTPS）；OCSP Stapling 省的是**吊销状态查询的那次往返**，与握手的密码学无关。

## 18.10 常见状态码的语义与陷阱

状态码分 5 类（`1xx` 信息、`2xx` 成功、`3xx` 重定向、`4xx` 客户端错、`5xx` 服务端错），本科都讲过；这里只写面试会追问语义差异的部分。

### 18.10.1 重定向：永久/临时 × 是否允许改方法

| 状态码 | 语义 | 方法是否可变 | 缓存 | 典型用途 |
|---|---|---|---|---|
| `301 Moved Permanently` | 永久移动 | **历史实现允许改成 `GET`** | 可缓存（浏览器常长期缓存） | 换域名、`http→https` |
| `302 Found` | 临时移动 | **历史实现允许改成 `GET`** | 不缓存 | 登录后跳首页、临时活动页 |
| `303 See Other` | 临时，且明确要求用 `GET` 取 | 必须改为 `GET` | 不缓存 | `POST` 提交后跳转（PRG 模式） |
| `307 Temporary Redirect` | 临时移动 | **必须保持原方法** | 不缓存 | 需要保方法的临时跳转 |
| `308 Permanent Redirect` | 永久移动 | **必须保持原方法** | 可缓存 | 需要保方法的永久跳转 |

记忆骨架：**`301`/`302` 是老规范留下的模糊地带，`307`/`308` 是把 `302`/`301` 的语义说清楚的版本**，判定轴只有一个——是否允许把 `POST` 改成 `GET`；`303` 则反过来，明确要求改成 `GET`。

> [!WARNING]
> 陷阱在 `301`/`302` 的历史行为：RFC 7231 允许（但不强制）客户端收到 `301`/`302` 后把非 `GET` 方法改写为 `GET`。所以 `POST` 到 `301` 的接口很可能被浏览器以 `GET` 重放，而 `GET` 没有 body——**参数丢失、语义错误，且极难排查**。要保方法就用 `307`/`308`。

### 18.10.2 认证与授权、网关错误的归属

- **`401 Unauthorized`（名字起错了，语义是 Unauthenticated）**：你没有证明你是谁，响应必须带 `WWW-Authenticate` 头告诉客户端如何认证。
- **`403 Forbidden`（语义是 Unauthorized）**：我们知道你是谁，但你无权访问；再认证一次也没用。一句话区分：**`401` 是"去登录"，`403` 是"登录了也没用"。**
- **`502 Bad Gateway`**：网关从上游拿到了**无效响应**（连上了但响应不合法、上游拒绝、连接被 RST），典型如后端进程崩溃、协议错乱、`keepalive` 半开连接。
- **`504 Gateway Timeout`**：网关**没等到上游响应就超时**，典型如后端慢查询、下游级联超时。一句话区分：**`502` 是"上游的回答不对"，`504` 是"上游根本没回答"**，排查方向分别是后端进程/协议 与 超时链路/下游耗时。

### 18.10.3 `200`/`204`/`206` 与 `Connection: close`

| 状态码 | 语义要点 | 陷阱 |
|---|---|---|
| `200 OK` | 有正文 | 用 `200` + 业务码表达错误（"HTTP 200 但 `code != 0`"）会让缓存、监控、重试全部失真 |
| `204 No Content` | 成功但无正文 | 若真带了 body，部分客户端/代理行为不一致；REST 中 `DELETE`/`PUT` 常用 |
| `206 Partial Content` | 响应范围请求 | 必须配 `Content-Range`；断点续传、视频拖动、多线程下载靠它 |

**`206` 的前提**：客户端发 `Range: bytes=0-1023`，服务端回 `206` + `Content-Range: bytes 0-1023/5000`；若服务端不支持范围请求，应回 `200` + 全量正文而不是 `206`，否则客户端会拼出错误文件。

**`Connection: close`** 在请求/响应结束后关闭连接：连接池复用失效，每请求重付一次握手与慢启动；更关键的是**正文定界方式改变**——`close` 语义下的响应可以不带 `Content-Length`，由"连接关闭"暗示正文结束。**这也解释了为什么它与连接复用不能共存、为什么流水线在 `close` 下彻底不可行。**

`1xx` 的现代用法：`100 Continue`（先发头、服务端同意后再发大 body）与 `101 Switching Protocols`（`WebSocket` 升级）；`103 Early Hints` 在最终响应前提示客户端预加载资源，也是 HTTP/3 里替代服务端推送的主要手段。

> [!NOTE]
> 这里的追问通常是"**`502` 和 `504` 你都怎么排查？**"标准答法要落到可观测面：`502` 先确认后端进程是否存活、是否在重启、代理与后端的协议是否一致（HTTP/1.1 vs `h2c`、`keepalive` 超时不匹配导致的半开）；`504` 先看网关超时阈值与后端 P99，再顺调用链看是哪一跳变慢。只答"重启一下"就是放弃这题。

## 本章小结

| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
| HTTP/1.1 的队头阻塞在应用层 | 请求-响应同连接串行，浏览器用 6 连接绕过，域名分片是这一限制的产物 | 说成"TCP 层丢包阻塞" |
| 管线化失败的根本原因是缺少请求标识 | 响应必须有序且无 ID，一错就整条连接作废 | 只说"因为队头阻塞"，说不出为什么队头阻塞 |
| HTTP/2 的复用靠 `Stream ID` | 帧头 31 位流标识让帧可交错可重组；HPACK 靠静态表+动态表+哈夫曼省字节 | 以为 HTTP/2 改了 HTTP 语义 |
| HTTP/2 仍有 TCP 层 HOL | 一个包丢失阻塞同连接所有流，等待时间是一个 RTT 量级 | 认为多开连接能解决，或认为 HOL 已彻底消除 |
| TLS 1.3 省一个 RTT 的关键是 `key_share` 前置 | 客户端在 `ClientHello` 里先猜 DH 组，服务端即可在同一往返算出共享秘密 | 说"因为算法更快所以省 RTT" |
| `307`/`308` 保方法，`301`/`302` 可变方法 | 判定轴是"是否允许把 `POST` 改成 `GET`" | 认为 `301` 和 `308` 只差永久/临时 |

## 自测题

**Q1**（推导）一个页面需要加载 30 个同源静态资源。说明 HTTP/1.1 下最多能并发几个，域名分片为什么能提高这个数字、代价是什么。

<details><summary>答案与解析</summary>

HTTP/1.1 下浏览器按**源（origin = scheme + host + port）**限流，事实标准是每源 6 条并发连接，因此最多 6 个资源在传；域名分片把资源拆到 `s1..s4.example.com`，因为限流按源计算，4 个域名 → `4 × 6 = 24` 条连接。

**代价**：每新增域名一次 DNS 解析与一次 TLS 握手（HTTPS 下尤其贵）；连接池碎片化、空闲连接占用 fd 与内存；多条连接各自从慢启动开始，拥塞控制效率下降；HTTP/2 下分片会让资源分散到多条连接，破坏单连接多路复用与 HPACK 跨请求压缩收益，是负优化。

**错误回答长什么样**：答"因为浏览器限制 6 个连接，分片能开更多连接"，但说不出"限流单位是源而不是 IP"，也说不出 HTTP/2 下分片有害。面试官会追问"HTTP/2 时代还需要分片吗"——不需要，且应把 CDN 的 `s1/s2` 合并回主域。

</details>

**Q2**（辨析）请分别说明 HTTP/1.1 与 HTTP/2 的队头阻塞**在哪一层**、由**什么**造成，以及 HTTP/2 为什么不能靠"多开连接"缓解。

<details><summary>答案与解析</summary>

- **HTTP/1.1 的 HOL 在应用层**：同一连接上请求-响应必须严格串行（响应按请求顺序返回，且响应里没有请求 ID 可用于重排），一个慢响应阻塞其后所有响应；启用管线化仍然存在，管线化甚至因为缺少请求标识而无法在出错时正确恢复。
- **HTTP/2 的 HOL 在传输层（TCP）**：所有流共享一条 TCP 连接，TCP 按序交付，因此一个报文段丢失后，即使后续字节已到达接收缓冲区，内核也不会交给应用层，**所有流一起等待重传**（RTT/RTO 量级）；HPACK 解码是有序的，头部帧丢失还会连带阻塞后续头部解析。多开连接也救不了：HTTP/2 的设计前提就是单连接多路复用，多开连接会把 HPACK 的连接级动态表拆成多份（跨请求去重失效），流控与优先级也无法统一调度，等于退回 HTTP/1.1 的思路。

**错误回答**："HTTP/2 也是队头阻塞，因为请求要排队。"——把两者混为一谈，层次说错后 HTTP/3 的动机就完全推不出来。面试官会追问"HTTP/3 彻底消除 HOL 了吗"——QUIC 消除的是**跨流** HOL，**同一流内部**仍有序交付，流内丢包照样阻塞该流。

</details>

**Q3**（画图与解释）画出 TLS 1.2 与 TLS 1.3 完整握手的主要消息，标出各自往返数，并解释 TLS 1.3 为什么能少一个 RTT。

<details><summary>答案与解析</summary>

TLS 1.2（2-RTT）：第 1 个往返 `ClientHello` → `ServerHello` + `Certificate` + `ServerKeyExchange` + `ServerHelloDone`；客户端此时才拿到服务端选定的组与参数，于是第 2 个往返发 `ClientKeyExchange` + `ChangeCipherSpec` + `Finished`，服务端回 `ChangeCipherSpec` + `Finished`。
TLS 1.3（1-RTT）：`ClientHello` 里就带 `key_share`（猜定的 DH 组公钥）；服务端 `ServerHello` 带回自己的 `key_share`，双方即可算出握手密钥，随后的 `EncryptedExtensions` + `Certificate` + `CertificateVerify` + `Finished` 全部加密发出；客户端紧接着就能发应用数据与自己的 `Finished`。

**少一个 RTT 的原因**：TLS 1.2 的密钥交换材料只能放在第 2 个往返，因为客户端必须先知道服务端选了哪个组；TLS 1.3 让客户端**先猜一个组并把 `key_share` 放进 `ClientHello`**，只要服务端接受该组，同一个往返就能确定共享秘密。猜错时服务端回 `HelloRetryRequest`，退回 2-RTT。
**TLS 1.3 砍掉的东西**：静态 RSA 密钥交换（改为 (EC)DHE，获得前向保密）、重协商、压缩、`CBC` 模式、自定义 DH 参数、`ChangeCipherSpec`。
**错误回答**："TLS 1.3 用的加密算法更快，所以少一个 RTT。"——省 RTT 是**协议消息编排**的改变，不是算力问题。面试官会追问"`Certificate` 为什么加密"——服务端在 `ServerHello` 之后已能算出握手密钥，把证书放在密钥之后既修掉了"证书明文暴露访问域名"的隐私问题，也减少了被中间设备干预的面。

</details>

**Q4**（计算）链路跨大洲，RTT 约 150 ms；DNS 已缓存；服务端支持 TLS 1.3。计算从 TCP 建连到收到 HTTP 响应首字节的最少往返数（忽略丢包与慢启动），并说明改用 0-RTT 能省多少。

<details><summary>答案与解析</summary>

逐段计：TCP 三次握手 `1 RTT`（约 150 ms）→ TLS 1.3 完整握手 `1 RTT`（约 150 ms）→ HTTP 请求到首字节 `1 RTT`（约 150 ms），共 **3 RTT ≈ 450 ms**。
若已持有有效会话票据并用 0-RTT：TLS 握手不占额外往返，客户端可在 `ClientHello` 后立刻附带应用数据，变成 TCP `1 RTT` + 应用数据 `1 RTT` = **2 RTT ≈ 300 ms**，省约 150 ms。

对比 TLS 1.2（2-RTT 握手）：总计 `1 + 2 + 1 = 4 RTT ≈ 600 ms`，即 TLS 1.3 相对 1.2 省约 150 ms，0-RTT 相对 1.2 省约 300 ms。

**错误回答**：把 DNS 也算进去（题目已说明已缓存），或把 TCP 建连与 TLS 握手合并成 1 个 RTT。另一个常见错误是认为 0-RTT 可用于任何请求——它可被重放且对 0-RTT 数据无前向保密，只能用于幂等请求。面试官会追问"PSK 复用（1-RTT）和 0-RTT 差在哪"——1-RTT 复用仍要等一个往返才发数据，但票据不承担"提前发送"的风险；0-RTT 把数据提前到第一个包，代价是可重放。

</details>

**Q5**（辨析）四个场景分别选哪个状态码并说明理由：① `POST /orders` 提交后跳转订单详情页；② 迁到新域名，以后都访问新域名，且该接口只接受 `POST`；③ 用户带合法 token 但无权访问该资源；④ 网关等待后端 3 s 无响应，自身超时阈值 2 s。

<details><summary>答案与解析</summary>

① **`303 See Other`**。典型 PRG（Post/Redirect/Get）模式：`POST` 成功后要让浏览器用 `GET` 取结果页，避免刷新重复提交表单；`303` 明确要求改成 `GET`，语义最贴切。

② **`308 Permanent Redirect`**。要求"永久"且"保持方法"；`301` 虽永久但历史实现允许把 `POST` 改成 `GET`，会丢 body；`307` 保方法但只是临时。

③ **`403 Forbidden`**。身份已由 token 证明，服务端知道"你是谁"，只是该身份无权访问，再认证也无济于事。若返回 `401` 会误导客户端去刷新 token 或重新登录，形成无意义循环。

④ **`504 Gateway Timeout`**。网关在 2 s 就超时放弃等待，属于"上游没有（及时）回答"；与 `502`（上游的回答无效）区分——这里根本还没拿到响应。

**错误回答**：① 答 `302`——虽能工作，但 `302` 的方法语义是模糊的，规范不保证改成 `GET`；② 答 `301`——最典型的踩坑，`POST` 会被改写成 `GET`，参数全丢；③ 答 `401`——混淆"未认证"与"未授权"；④ 答 `502`——混淆"回答不对"与"没回答"。面试官会追问"`401` 响应里必须有哪个头"——`WWW-Authenticate`，它告诉客户端应该用哪种认证方案；缺少它，`401` 的语义是不完整的。

</details>

## 面试高频问题与陷阱

| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应本章小节 |
|---|---|---|---|
| HTTP/1.1 的队头阻塞到底是什么？为什么浏览器要开 6 个连接？ | 应用层串行的成因 | 错误回答："因为 TCP 丢包要重传，所以后面的请求被堵住。"——把传输层机制当成应用层问题。加分点：指出响应无请求 ID，无法重排 | 18.3 |
| HTTP/1.1 的管线化看起来能提升性能，为什么实际没人用？ | 协议约束与部署现实 | 错误回答："因为服务端不支持。"——太笼统。要答出响应必须按序返回、中间设备兼容性差、缺少请求标识导致出错无法恢复 | 18.2.2 |
| 域名分片能提升并发，为什么 HTTP/2 下反而要避免？ | 限流单位与版本协同 | 错误回答："HTTP/2 也会分片，只是不需要了。"——没说出分片会让 HPACK 动态表与多路复用失效。加分点：答出应把域名收敛回主域 | 18.3 |
| HTTP/2 的多路复用是怎么实现的？帧、消息、流是什么关系？ | 分帧层的数据结构 | 错误回答："就是把多个请求打包一起发。"——没有 `Stream ID` 这个核心。要答出 9 字节帧头、31 位流标识、帧属于流、消息由帧组成 | 18.4.1、18.4.2 |
| HTTP/2 已经多路复用了，为什么还需要 HTTP/3？ | TCP 层 HOL | 错误回答："因为 HTTP/2 兼容性不好。"——完全答偏。核心是单条 TCP 连接上一个包丢失会阻塞所有流，且多开连接会破坏多路复用前提 | 18.5 |
| QUIC 为什么要基于 UDP？它不怕不可靠吗？ | 中间设备僵化与用户态实现 | 错误回答："UDP 更快、不用握手。"——QUIC 自己实现了重传、拥塞控制与流控，比 TCP 更复杂。加分点：答出协议号被中间设备丢弃的僵化问题 | 18.6.1 |
| 0-RTT 有什么风险？什么请求可以用？ | 重放攻击与前向保密 | 错误回答："0-RTT 没有风险，直接用就行。"要答出可重放、对 0-RTT 数据无前向保密，只能用于幂等无副作用请求 | 18.6.3、18.8.2 |
| SNI 是干什么的？它有什么隐私问题？ | TLS 扩展与虚拟主机 | 错误回答："就是告诉服务端用哪张证书。"——方向对但没说到"HTTP 的 `Host` 在加密之后，握手时看不到域名"，也没提 SNI 明文泄露访问域名、需要 ECH | 18.9.3 |

## 延伸阅读

- RFC 7230 / 7231 / 7232 / 7233 / 7234 / 7235：HTTP/1.1 报文语法、语义、条件请求、范围请求、缓存与认证（`301`/`302`/`307`/`308`、`206`、`401` 的权威定义）。
- RFC 7540（HTTP/2）与 RFC 7541（HPACK）：二进制分帧、流与优先级、头部压缩。
- RFC 9114（HTTP/3）、RFC 9000（QUIC）、RFC 9002（QUIC 丢包检测与拥塞控制）、RFC 9204（QPACK）。
- RFC 8446（TLS 1.3）、RFC 5246（TLS 1.2）、RFC 6066（SNI）、RFC 6797（HSTS）、RFC 6960（OCSP）。
- RFC 6265（Cookie）、RFC 9218（HTTP 可扩展优先级）、RFC 8297（`103 Early Hints`）；《HTTP/2 in ACTION》与《Bulletproof SSL and TLS》分别是帧与流、证书链与吊销机制的实务参考。
- 第 16 章 16.1~16.5 节（TCP 建连与关闭）、第 17 章 17.4~17.6 节（重传与拥塞控制）、第 19 章 19.2 节（DNS 递归与迭代、`curl -w` 分层排查）。


---

> 作者: [破戒僧](https://onlyblacktea.cn)  
> URL: https://onlyblacktea.cn/book/backend/ch18/  

