Files
cs-note/hhs/gRPC/2. gRPC 核心篇/07-HTTP2 传输原理.md
T
2026-05-24 11:42:38 +08:00

408 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [gRPC, HTTP/2, Protocol, Go, Networking]
create time: 2026-05-11 16:42
update time: 2026-05-18
---
# HTTP/2 传输原理
## 概述
gRPC 跑在 HTTP/2 之上。这意味着两个结果:一是你**免费获得**了 HTTP/2 带来的所有性能红利——多路复用、头部压缩;二是你要接受一层新的抽象,理解 frame(帧)和 stream(流)的概念才能调试那些"偶发性超时""连接无故断开"的问题。
### 先建立直觉:数据是怎么层层包装的?
把一次 gRPC 请求想象成寄快递:
| 层级 | 类比 | 它的作用 |
|------|------|----------|
| **Protobuf** | 你要寄的物品 | 定义数据的格式。比如一个 User 对象:名字、年龄、邮箱 |
| **gRPC** | 快递员 + 运单号 | 把物品打包,贴上一个"信封"(metadata),告诉收件方这是什么类型的请求 |
| **HTTP/2 Frame** | 纸箱上的标签 | 把每个信封切成小箱子,贴上标签(类型、长度、属于哪个流) |
| **Stream** | 快递站的分拣通道 | 一条逻辑通道上跑一个完整的请求-响应周期 |
| **TCP** | 运送快递的卡车 | 保证每辆车安全到达,不丢包、不乱序 |
这四层的关系是:**内层的数据被外层包裹**。发送时从内到外逐层封装,接收时从外到内逐层拆解:
```
你调用 client.GetUser(request)
→ Protobuf 把你的 request 序列化成字节 (装箱)
→ gRPC 加上 metadata(超时时间、认证信息等) (贴运单)
→ HTTP/2 把数据切分成 frame,打上 Stream ID (贴标签)
→ TCP 可靠地发送到对方
```
回到最初的问题——**为什么 gRPC 选择 HTTP/2?**
HTTP/1.1 下,每个请求都要走一次 TCP 连接(三次握手)。高并发场景下光握手就耗死了。
HTTP/2 用**一条 TCP 连接承载任意数量的并发请求**,这就是多路复用(multiplexing)。代价是你得理解这一套全新的二进制协议层。
---
## 连接怎么建立的?
每次发起 RPC 调用前,客户端和服务端必须先"打招呼"。分三步完成:
### 第一步:建路 — TCP 握手
就是普通的 TCP 三次握手,不再展开。如果这步失败了,你会看到 `connection refused` 或 `connect timeout`。
### 第二步:对暗号 — TLS + ALPN
如果用的是 HTTPS(生产环境标配),还要过 TLS 加密这一步。关键在这里:
TLS 握手的 `ClientHello` 消息里,客户端会声明自己支持的协议:
> "我会 h2(HTTP/2)和 http/1.1,你选一个吧。"
服务端回复 `ServerHello`,选定一个协议:
> "好,我们走 h2。"
这个机制叫 **ALPN(Application-Layer Protocol Negotiation)**。就像两个人见面先用英语打招呼——如果说不到一块儿去(协商失败),整条连接就不能走 HTTP/2。
### 第三步:确认身份 — HTTP/2 Preface
双方都确定要用 HTTP/2 后,还要互发一段固定格式的字符串来最终确认:
- **客户端**先发一段固定文本 `PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n` 和一个空的 SETTINGS 帧
- **服务端**收到后,回复一个 ACK 和它自己的 SETTINGS
至此握手完成,可以开始收发业务数据了。
> [!warning] 常见坑
> 一些老旧代理(旧版 Nginx < 1.3.10、旧版 Squid)不支持 ALPN 或不认 Preface,会导致 HTTP/2 偷偷降级为 HTTP/1.1。遇到"偶发的连接断开"时可以检查中间件版本。
> [!tip] 完整流程一览
> ```
> Step 1 : TCP 三次握手 —— 建道路
> Step 2 : TLS + ALPN —— 确认都说 h2
> Step 3 : HTTP/2 Preface —— 正式确认,交换参数
> Step 4 : 🚀 开始通信
> ```
---
## 什么是 Frame 和 Stream?
这是 HTTP/2 最核心的两个概念,理解了它们就理解了 gRPC 的工作方式。
### Frame(帧)— 最小传输单元
HTTP/2 的二进制世界里,所有数据都以 frame 为单位传输。每个 frame 的结构非常固定:
```
┌───────────────────────┬──────────────┬──────────────────┐
│ Length (24 bits) │ Type(8bit) │ R + StreamID(31) │ ← 9 字节的表头
├───────────────────────┴──────────────┴──────────────────┤
│ Payload (实际数据) │
└─────────────────────────────────────────────────────────┘
```
五个字段的意思:
| 字段 | 含义 | 备注 |
|------|------|------|
| **Length** | Payload 有多长 | 最大约 16MB |
| **Type** | 这个帧是什么类型 | 见下表 |
| **R** | 保留位 | 始终为 0 |
| **Stream ID** | 属于哪条流 | 0 表示这条帧不属于任何流(属于整条连接) |
| **Payload** | 数据内容 | 由 Type 决定含义 |
常见的帧类型有哪些?
| 帧类型 | Type Code | 什么时候用到 |
|--------|-----------|-------------|
| **DATA** | 0x0 | 传输实际的请求/响应数据 |
| **HEADERS** | 0x1 | 携带 HTTP 头和 gRPC 元数据 |
| **SETTINGS** | 0x4 | 握手阶段交换配置参数 |
| **PING** | 0x6 | 保活探测(判断连接是否还活着) |
| **WINDOW_UPDATE** | 0x8 | 流量控制(调整接收窗口) |
| **GOAWAY** | 0x7 | 优雅关闭("这条连接以后不能再建新流了") |
| **RST_STREAM** | 0x3 | 异常中断某个特定的流 |
gRPC 实际上只用了其中一小部分——复杂的细节都被封装在内部了。
### Stream(流)— 一条逻辑通道
**Stream 是一趟完整的请求-响应旅程。** 每条流有唯一编号,规则很简单:
| 谁发起 | Stream ID | 举例 |
|--------|-----------|------|
| 客户端 | **奇数** | 1, 3, 5, 7… |
| 服务端 | **偶数** | 2, 4, 6, 8… |
| 起始值 | **1** | 第一条流永远是 Stream 1 |
| 用完之后 | **不复用** | 即使流已结束,ID 也不会回收 |
### Multiplexing(多路复用)— 一条连接干 N 份活
这才是重点。**一条 TCP 连接上可以同时存在多个 Stream,它们的帧交错着在网络中传输。**
以 HTTP/1.1 的方式同时查三个用户,需要三个 TCP 连接,串行执行:
```
连接 1: ━━[UserA 请求]━━[UserA 响应]━━⏱等待━━[UserB 请求]━━[UserB 响应]━━...
连接 2: ━━[UserB 请求]━━[UserB 响应]━━
连接 3: ━━[UserC 请求]━━[UserC 响应]━━
总耗时 = A + B + C (串行加起来)
```
HTTP/2 只需要一个连接,三个 Stream 并行:
```
Stream 1(UserA): ━REQUEST_A━━RESPONSE_A━
Stream 3(UserB): ━REQUEST_B━━RESPONSE_B━
Stream 5(UserC): ━REQUEST_C━━RESPONSE_C━
(同一个 TCP 连接内交错发送)
总耗时 ≈ max(A, B, C) (并行取最大值)
```
那接收方怎么知道收到的帧属于哪个请求?靠的就是 **Stream ID**。
具体过程:
```
客户端发出请求的帧按这个顺序传过去:
S1_HEADERS → S3_HEADERS → S5_HEADERS → S1_DATA → S3_DATA → S5_DATA
服务端收到后,根据 Stream ID 重新组装:
Stream 1:S1_HEADERS + S1_DATA → "这是查 UserA 的请求"
Stream 3:S3_HEADERS + S3_DATA → "这是查 UserB 的请求"
Stream 5:S5_HEADERS + S5_DATA → "这是查 UserC 的请求"
服务端响应时走对应的偶数 Stream:
S2 回给 Stream 1(UserA 的响应)
S4 回给 Stream 3(UserB 的响应)
S6 回给 Stream 5(UserC 的响应)
客户端收到后照样按 Stream ID 匹配回来就行。
```
> [!note] 一句话总结
> Stream 就像是高速公路上的车道,Frame 就是在车道上跑的车。多条车道的车可以并行前进,收费站(接收方)只要看车牌号(Stream ID)就知道把这辆车分到哪个车位去。
---
## HPACK:头部为什么要压缩?
HTTP/1.1 有个缺点:**每个请求都得附带完整的 header**,而且这些 header 绝大部分内容是重复的:
```http
// 第 1 次请求,约 200+ 字节
POST /user.v1.UserService/CreateUser HTTP/1.1
Host: localhost:50051
Content-Type: application/grpc
Grpc-Timeout: 30s
User-Agent: grpc-go/1.50.0
Te: trailers
// 第 2 次请求,又发了一遍一模一样的东西
POST /user.v1.UserService/CreateUser HTTP/1.1
Host: localhost:50051
Content-Type: application/grpc
Grpc-Timeout: 30s
User-Agent: grpc-go/1.50.0
Te: trailers
```
同一个服务,几百个请求,`Host`、`Content-Type`、`User-Agent` 每次都原封不动地再发一遍——白白浪费带宽。
### HTTP/2 的做法:共享词典
HPACK 的核心思想是:**客户端和服务端各维护一份相同的字典,只传索引号,不传原文。**
这个字典有两个来源:
1. **静态字典** — RFC 标准预定义了 61 个常用 header 的映射关系。比如 `:method` 对应索引号 2,`content-type` 对应另一个固定索引号。双方都内置这份字典,不需要额外同步。
2. **动态字典** — 运行过程中遇到新出现的 key-value 对,两边各自存下来,后续请求直接引用。
第一次请求还是要发完整内容的(顺便把新条目加入动态字典):
```
:method = POST → 查静态字典 → 找到索引号 2 → 发 {idx 2}
:path = /user.v1... → 不在字典里 → 存进动态表 + 发完整路径
content-type = ... → 查静态字典 → 找到索引号 → 发 {idx ?}
grpc-timeout = 30s → Huffman 编码后只占 5 字节
```
第二次请求就轻松了——`:method`、`content-type` 都在静态字典里,直接报索引号就行,一个字都不用多传。
### 压缩效果
| 版本 | 单次请求头部大小 | 说明 |
|------|------------------|------|
| HTTP/1.1 | ~200 bytes | 每次都原样发送 |
| HTTP/2 + HPACK(首次) | ~50 bytes | 新条目要多发一点 |
| HTTP/2 + HPACK(后续) | 10~20 bytes | 大部分都能索引到 |
> [!tip] 为什么 gRPC 强制要求 hpack table size >= 4096?
> gRPC 的请求头部大部分是固定的(`:method`, `:path`, `content-type`, `grpc-timeout`),非常适合缓存。table 越大,命中率越高,压缩效果越好。
---
## Flow Control:谁来管流量?
这里有一个容易混淆的点——**HTTP/2 自带一层流量控制,gRPC 又叠加了一层**,它们分工不同:
| 层级 | 管什么 | 默认值 | 失控会怎样 |
|------|--------|--------|------------|
| **HTTP/2 Window** | 网络缓冲区(TCP 队列) | 65535 bytes | 缓冲区满时发送方被 backpressure 堵住,表现为超时 |
| **gRPC Message Size** | 应用层内存(unmarshal 用的堆空间) | **4 MB** | proto message 超过限制时报 `message larger than max` |
类比一下:
- **HTTP/2 Window** 像是门口通道的宽度——太宽了接不住,得慢慢放
- **gRPC Message Size** 像是仓库的容量——货到了太多放不下,会 OOM
两层都配好才算真正的安全。
> [!warning] 踩坑指南
> 你的 protobuf message 超过 4MB 时会报错:
> ```
> grpc: received message larger than max (5242880 vs 4194304)
> ```
> 解决:调大 `MaxCallRecvMsgSize`,但别设为无上限——小心被恶意巨型 payload 打爆内存。
> ```go
> grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(10*1024*1024)) // 10MB
> ```
---
## 连接的生命周期
```mermaid
stateDiagram-v2
[*] --> Idle
Idle --> Connecting: TCP connect
Connecting --> Connected: OK
Connecting --> ErrorFailed: Failed
Connected --> TLSEncrypted: TLS + ALPN (if secure)
Connected --> Plaintext: Insecure mode
TLSEncrypted --> SettingsSent: Send Preface + SETTINGS
Plaintext --> SettingsSent: Send Preface + SETTINGS
SettingsSent --> Ready: Receive SETTINGS ACK
SettingsSent --> ErrorSettingsTimeout: Timeout
Ready --> Active: Create Streams
Active --> HalfClose: Stream done
HalfClose --> GoAwayReceived: Server sends GOAWAY
GoAwayReceived --> DrainPending: Wait for active streams
DrainPending --> Closed: All pending done
state Active {
StreamOpen --> Sending
StreamOpen --> Receiving
Sending --> Done
Receiving --> Done
}
note right of ErrorFailed
DNS 解析失败 /
TCP connect timeout /
TLS cert invalid
end note
note right of ErrorSettingsTimeout
对方未在规定时间回复
SETTINGS ACK
end note
```
各阶段的关键点:
1. **Connecting** — TCP 握手。DNS 解析失败、目标不可达都会在这步挂掉。
2. **TLS + ALPN** — 证书过期或域名不匹配在这一步被发现。
3. **Settings exchange** — 双方交换 window size、max concurrent streams 等参数。正式握手点。
4. **Ready** — 握手完成,可以创建 Stream 了。
5. **Active** — 正常干活阶段,随时创建和销毁 Stream。
6. **GOAWAY** — 服务端要停机重启了,通知"这条连接不再接受新请求",但已经创建的请求还能继续完成。
7. **Closed** — 连接彻底断开。gRPC 会自动重连(reconnect policy)。
> [!note] GOAWAY vs RST_STREAM
> - **GOAWAY**:连接级别的。"这条连接以后不能建新流了" → 优雅关闭
> - **RST_STREAM**:流级别的。"这个流坏了,断了" → 不影响同连接上的其他流
---
## 对开发者的实际影响
### 1. 不需要手动建连接池
HTTP/1.1 时代开发者习惯手动维护连接池。但在 gRPC 里一个 `Dial` 就够了——内部的连接管理器会自动处理复用和重连。
```go
conn, _ := grpc.Dial("target", grpc.WithTransportCredentials(...))
client1 := pb.NewService1Client(conn)
client2 := pb.NewService2Client(conn)
// 所有 client 共享这个 conn,底层自动管理
```
### 2. Keepalive 必须配置
生产环境不配 keepalive,负载均衡器或 K8s ingress 会认为你的连接是空闲的然后直接杀掉。
```go
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 10 * time.Second,
Timeout: 5 * time.Second,
PermitWithoutStream: true, // ★ 关键:即使没有活跃请求也发 ping
})
```
推荐配置取决于你的中间件策略:
| 组件 | 典型 idle timeout | 建议 keepalive Time |
|------|-------------------|---------------------|
| AWS ALB | 60s | 25s |
| Nginx proxy_pass | 75s | 30s |
| K8s kube-proxy | 根据 conntrack | 25s |
> [!warning] `PermitWithoutStream` 为什么重要?
> 默认值是 `false`。当没有活跃 RPC 时(比如两次调用之间的等待期),gRPC **不会**发 keepalive ping。恰好此时中间设备判断连接空闲,直接把 TCP 连接断掉了——下次调用就报 `connection reset`。设成 `true` 就能保住连接。
### 3. 遇到错误时的排查思路
| 现象 | 可能的原因 | 第一反应检查什么 |
|------|-----------|----------------|
| 偶发 `DEADLINE_EXCEEDED` | HTTP/2 Window 满了被 backpressure 堵住 | Wireshark 看 WINDOW_UPDATE 延迟 |
| 连接间歇性断开 | Keepalive 没开,LB 杀了空闲连接 | 检查 `PermitWithoutStream` |
| `"message larger than max"` | Proto message 超过 4MB | 调大 `MaxCallRecvMsgSize` |
| `PROTOCOL_ERROR` | 中间代理篡改了 frame | 检查 Nginx / Envoy 是否启用了 `http2` |
| 某个流卡住不报错 | Server handler 阻塞或未回写 | `GRPC_TRACE=stream,transport` |
### 4. 常用调试工具
```bash
# 快速测试一个 gRPC endpoint
grpcurl -plaintext -d '{"id": 42}' localhost:50051 user.v1.UserService.GetUser
# 查看服务提供了哪些方法
grpcurl -plaintext localhost:50051 list
# 查看某个 Service 的详细接口定义
grpcurl -plaintext localhost:50051 describe user.v1.UserService
# 看 frame 级别的网络细节
nghttp -v http://localhost:50051
# Wireshark 过滤 gRPC 流量
tcp.port == 50051 && http2
# Go 内置 debug 日志
GRPC_VERBOSITY=DEBUG GRPC_TRACE=http2,transport,subchannel ./your-app
```
---
## 关联笔记
- [[../1. Protobuf 基础篇/01-Protobuf 语法与消息定义]]
- [[../3. 服务端实现/08-Server 搭建与注册]]
- [[../3. 服务端实现/09-Streaming Handler]]
- [[../4. 客户端开发/11-Client 连接与 Dial]]
- [[../4. 客户端开发/12-Call Options 与 Context]]
- [[../6. 工程实践篇/20-性能优化与压测]]