This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/gRPC/2. gRPC 核心篇/07-HTTP2 传输原理.md
T
2026-05-11 19:02:38 +08:00

485 lines
18 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
---
# HTTP/2 传输原理
## 概述
gRPC 跑在 HTTP/2 之上,这意味着你天然享受 HTTP/2 带来的所有性能红利:多路复用、头部压缩、服务器推送。但也带来了一些新的心智负担——原来用 TCP 协议栈就能搞定的事,现在又多了一层帧(frame)和流(stream)的概念。理解底层原理才能调试那些"偶发性超时""连接无故断开"的问题。
> [!question] 为什么 gRPC 不用 HTTP/1.1?
> HTTP/1.1 的串行阻塞模型在面对高并发微服务时效率太低——每增加一个并发都要付出一次 TCP 握手开销。而 HTTP/2 在一个 TCP 连接上就能搞定任意数量的并发请求。但代价是你得适应全新的二进制协议层。
> [!tip] 先搞清楚这层关系
> ```
> 应用层:Protobuf 消息 → 序列化为字节流
> gRPC 层:把字节流包装成 request/response/stream
> HTTP/2 层:把 gRPC 数据切分成 frame, multiplex 到 stream 上
> TCP 层:可靠的字节流传输
> ```
> gRPC = Protobuf wire format + HTTP/2 transport + gRPC semantics
## HTTP/2 vs HTTP/1.1 对比
| 特性 | HTTP/1.1 | HTTP/2 |
|------|----------|--------|
| 连接数 | 每主机通常 6 个 | 任意数量 |
| 传输格式 | 纯文本 | 二进制 |
| 多路复用 | ❌ (head-of-line blocking) | ✅ |
| 头部压缩 | ❌ | ✅ HPACK |
| 服务器推送 | ❌ | ✅ PushPromise |
| 头部顺序 | 明文逐行发送 | header block fragments |
**核心差异一句话:**HTTP/2 将一切变成了二进制帧(frame),用帧的组合来表达请求、响应和元数据。
> [!note] 为什么二进制更好?
> 纯文本协议(如 HTTP/1.1)需要靠 `\r\n` 分隔,解析容易出错且浪费带宽。二进制协议用 length + type 字段精确定位每个单元,解析更快也更健壮。代价是——你不能再直接用浏览器看明文了,必须用专门的抓包工具。
## HTTP/2 连接建立过程
### Connection Preface(连接前置声明)
HTTP/2 要求在真正的数据交换之前,双方先发一段 "preface" 来确认对方支持 HTTP/2:
```go
// Client Preface(客户端必须首先发送,固定字符串)
// PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n
// SETTINGS frame (length = 0)
// Server Preface(服务端确认后回复同样长度的 SETTINGS frame)
// SETTINGS frame (length = 0)
```
这不是什么代码层面的操作——gRPC 客户端内部自动处理。但你可以在 Wireshark 中看到这个 handshake 过程:Client 发 `PRI * HTTP/2.0`,Server 回复 `SETTINGS ACK`,然后双方才开始交换业务数据。
> [!warning] 常见坑
> 某些老旧代理(如旧版 Squid / Nginx < 1.3.10)不支持 ALPN 或不认 Preface,会导致 HTTP/2 降级为 HTTP/1.1。遇到偶发的 "connection reset" 时可以检查一下中间件版本。
### TLS Handshake + ALPN
如果是 HTTPS 连接(gRPC 生产环境的标配),完整的握手流程是:
```mermaid
sequenceDiagram
participant C as Client
participant T as TCP/TLS
participant S as Server
Note over C,S: Step 1 — TCP 三次握手
C->>T: SYN
T->>S: SYN-ACK
S->>C: ACK
Note over C,T: Step 2 — TLS 1.3 握手
C->>T: ClientHello (ALPN: h2)
T->>S: ServerHello (selected: h2)
S->>C: finished
Note over C,S: Step 3 — HTTP/2 Preface
C->>S: ClientPreface + SETTINGS
S->>C: SETTINGS ACK + ServerSettings
Note over C,S: Step 4 — 开始 multiplexing
C->>S: HEADERS + DATA (Stream 1)
C->>S: HEADERS + DATA (Stream 3)
S->>C: HEADERS + DATA (Stream 2)
```
关键点是 **ALPN(Application-Layer Protocol Negotiation)**:TLS 握手的 `ClientHello` 中会附带支持的协议列表(`h2` / `http/1.1`),服务端从中选择一个并在 `ServerHello` 中返回。如果协商失败,就不会走 HTTP/2。
## HTTP/2 帧(Frame)详解
gRPC 的数据以 frame 为单位在连接上传输。每种 frame 有特定的类型码和控制语义:
| Frame | Type Code | 作用 | gRPC 中的场景 |
|-------|-----------|------|---------------|
| DATA | 0x0 | 实际载荷 | Request / Response body |
| HEADERS | 0x1 | 头信息(含 HTTP/2 header block) | HTTP headers + gRPC metadata |
| PRIORITY | 0x2 | 设置 stream 优先级 | gRPC 不使用 |
| RST_STREAM | 0x3 | 异常终止 | Client/Server 主动中断流 |
| SETTINGS | 0x4 | 协商参数 | 握手阶段交换 max_concurrent_streams 等 |
| PUSH_PROMISE | 0x5 | 服务器推送 | gRPC 不使用 |
| PING | 0x6 | 保活探测 | keepalive ping |
| GOAWAY | 0x7 | 优雅关闭 | Server 准备停机 |
| WINDOW_UPDATE | 0x8 | 流量控制 | 调整接收窗口大小 |
| CONTINUATION | 0x9 | 续传 header block | 头部太长时的分片传输 |
每个 frame 的结构固定为 9 字节头部 + 有效载荷:
```
+-----------------------------------------------+
| Length (24 bits) |
+---------------+---------------+---------------+
| Type (8 bits) | R(1 bit) | Stream ID(31 bits) |
+---------------+---------------+---------------+
| Payload (... bytes) |
+-----------------------------------------------+
```
- **Length**: payload 长度,最大 2^24 - 1 ≈ 16MB
- **Type**: frame 类型(上述表格中的 Type Code)
- **R**: reserved bit,必须为 0
- **Stream ID**: 所属 stream 的 ID,0 表示 connection-level frame
- **Payload**: 随类型不同含义各异
```mermaid
flowchart TB
subgraph Connection ["HTTP/2 Connection"]
direction TB
Streams --> S1["Stream 1<br/>HEADERS → DATA → HEADERS → DATA"]
Streams --> S2["Stream 2<br/>HEADERS → DATA"]
Streams --> SN["Stream N<br/>HEADERS → DATA"]
end
ConnLevel -.-> Settings["SETTINGS / PING / GOAWAY<br/>(Stream ID = 0)"]
style ConnLevel fill:#A0AEC0,color:#fff
style Settings fill:#ED8936,color:#fff
style S1 fill:#00B6BC,color:#fff
style S2 fill:#4FC08D,color:#fff
style SN fill:#FFD43B
```
**关键概念:**
- 每个 connection 上有多个 stream,每个 stream 独立收发 data
- 所有的 frame 都属于某个 stream(除了 connection-level 的 SETTINGS/GOAWAY/PING/WINDOW_UPDATE)
- gRPC 只用了其中一小部分 frame 类型——它把复杂的 protocol 封装在了内部
## Stream 与 Multiplexing
这是 HTTP/2 最重要的特性,也是 gRPC 高性能的核心原因。
**机制:**
- 一个 TCP 连接上可以有多个 stream
- 每个 stream 有唯一的 stream ID(奇数 = client 发起,偶数 = server 发起,ID 从 1 开始递增)
- stream 之间互不干扰——解决了 HTTP/1.1 的队头阻塞(head-of-line blocking)
### Stream ID 分配规则
```mermaid
flowchart LR
Client["Client"] --- TCP[("TCP Connection")]
TCP --- Server["Server"]
subgraph Streams ["Stream IDs"]
S1["1<br/>Client→Server<br/>Unary call"]
S3["3<br/>Client→Server<br/>Another call"]
S5["5<br/>Client→Server<br/>Streaming"]
S2["2<br/>Server→Client<br/>Response to #1"]
S4["4<br/>Server→Client<br/>Response to #3"]
S6["6<br/>Server→Client<br/>Push error"]
end
Client --> S1
Client --> S3
Client --> S5
Server --> S2
Server --> S4
Server --> S6
style S1 fill:#00B6BC,color:#fff
style S3 fill:#00B6BC,color:#fff
style S5 fill:#00B6BC,color:#fff
style S2 fill:#4FC08D,color:#fff
style S4 fill:#4FC08D,color:#fff
style S6 fill:#4FC08D,color:#fff
```
**要点:**
- Stream ID 严格递增,Client 永远用奇数,Server 永远用偶数
- 即使一个 stream 已经完成(发送了 END_STREAM flag),它的 ID 也不会回收重用
- 理论上单条连接最多可以有 2^31 个 stream(受 Stream ID 字段限制)
### 代码示例
同一个 conn 上可以并发创建多个 stream,无需额外 TCP 连接:
```go
conn, _ := grpc.Dial("localhost:50051", ...) // 只有一个 TCP 连接
client := pb.NewUserServiceClient(conn)
// 这三个 call 在同一个连接的不同 stream 上并行执行
go client.GetUser(ctx1, &pb.GetUserRequest{Id: 1}) // → Stream 1
go client.GetUser(ctx2, &pb.GetUserRequest{Id: 2}) // → Stream 3
go client.GetUser(ctx3, &pb.GetUserRequest{Id: 3}) // → Stream 5
```
## HPACK 头部压缩
HTTP/2 使用 HPACK 算法对头部进行压缩,主要靠两个手段:
1. **静态字典** — RFC 预定义了 61 个常用 header(如 `:method`, `:path`, `content-type`),直接通过索引引用
2. **动态表** — 运行时新增的 key-value 对会被缓存,后续请求只需引用索引号
3. **Huffman 编码** — 对无法查表的字符串做变长编码
gRPC 强制要求 hpack table size >= 4096 bytes。好处是 gRPC 的请求头部通常只有几十个字节,相比 HTTP/1.1 每次都要带完整的 User-Agent/Content-Type 等大很多倍。
```mermaid
flowchart LR
A["原始 Header:<br/>:method=POST<br/>:path=/user.v1.UserService/CreateUser<br/>content-type=application/grpc<br/>grpc-timeout=30s"] --> B["HPACK Encoder"]
B -->|"索引引用"| D[":method → :POST (static table idx 2)"]
B -->|"动态表插入"| E[":path → full string<br/>(dynamic table idx 1)"]
B -->|"Huffman 编码"| F["grpc-timeout=30s<br/>(Huffman: 5 bytes)"]
D --> G["Header Block Fragment<br/>(~15 bytes)"]
E --> G
F --> G
style A fill:#FFD43B
style B fill:#00B6BC,color:#fff
style G fill:#4FC08D,color:#fff
```
> [!example] HPACK 的实际压缩效果
>
> ```
> // HTTP/1.1 头部(每次完整发送,约 200+ bytes)
> 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
>
> // HTTP/2 + HPACK(连续多次调用,压缩后可能只剩十几字节)
> HEADERS: {idx 2} {dynamic-table: :path=/user.v1.UserService/CreateUser} {huffman: 30s}
> DATA: <protobuf payload>
> ```
>
> 注意第二次调用时,`:method=POST` 和 `content-type=application/grpc` 都可以直接从 static table 引用——不需要再发送这些字符串。
## Flow Control 流量控制
HTTP/2 有两层 flow control,各自独立工作:
| 层级 | 范围 | 默认窗口 | 控制方式 |
|------|------|----------|----------|
| Connection Level | 整个 TCP 连接 | 65535 bytes | WINDOW_UPDATE frame |
| Stream Level | 单个 stream | 继承 connection level | WINDOW_UPDATE frame |
当接收方缓冲区快满时,发送方会收到 WINDOW_UPDATE 被"堵住"——这就是 HTTP/2 的 **backpressure**。gRPC 在此基础上又加了一层自己的 flow control:
| gRPC 配置项 | 说明 | 默认值 |
|-------------|------|--------|
| `MaxReceiveMessageSize` | 单次可接收的最大消息 | **4 MB** |
| `MaxSendMessageSize` | 单次可发送的最大消息 | math.MaxInt32 |
> [!warning] 这是一个常见的坑!
> 当你的 protobuf message 超过 4MB 时,你会看到类似这样的错误:
> ```
> grpc: received message larger than max (5242880 vs 4194304) on XXX
> ```
> 修复方式:在 dial 或 server 选项中设置更大的 limit。
> ```go
> grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(10*1024*1024))
> ```
> 注意:这里设置的单位是 bytes。设太大也有风险——对方可能发一个巨型 payload 打爆你的内存。
### 与 HTTP/2 Flow Control 的关系
很多人会把两层 flow control 混淆。简单理解:
- **HTTP/2 Window** 管的是「网络缓冲区」——防止发送方压垮接收方的 TCP 队列
- **gRPC Message Size** 管的是「应用层内存」——防止 protobuf unmarshal 时 OOM
两层都配好了才是真正的安全。
> [!tip] 调优建议
> - HTTP/2 Window:大多数场景不需要手动改,除非出现大量 WINDOW_UPDATE 延迟
> - MaxReceiveMessageSize:按业务需要设定,但不要设为无上限;配合上游 LB 的 payload limit 一起考虑
## 连接生命周期
```mermaid
stateDiagram-v2
[*] --> Idle
Idle --> Connecting: TCP connect
Connecting --> Connected: OK
Connecting --> ErrorFailed: Failed
ErrorFailed --> Closed
Connected --> TLSEncrypted: TLS + ALPN (if secure)
Connected --> Plaintext: Insecure mode
TLSEncrypted --> SettingsSent: Send HTTP/2 Preface + SETTINGS
Plaintext --> SettingsSent: Send HTTP/2 Preface + SETTINGS
SettingsSent --> Ready: Receive SETTINGS ACK
SettingsSent --> ErrorSettingsTimeout: Timeout
ErrorSettingsTimeout --> Closed
Ready --> Active: Create Streams
Active --> HalfClose: Stream done (END_STREAM)
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 三次握手。如果目标地址不可达或被防火墙拦截,会在这里报 `connection refused`
2. **TLS + ALPN** — 对于 secure 连接,先用 TLS 加密并协商出 `h2` 协议。证书过期或 host mismatch 会在这一步失败
3. **Settings exchange** — 双方交换 window size、max concurrent streams 等参数。这是 HTTP/2 的正式握手点
4. **Ready** — 可以开始创建 stream 了
5. **Active** — 正常业务阶段,stream 可随时创建和销毁
6. **GOAWAY** — 服务端通知即将关闭(如滚动重启)。已创建的 stream 还能继续完成,新 stream 不能再用这条连接
7. **Closed** — 连接彻底关闭,下次调用时 gRPC 会自动重连(reconnect policy)
> [!note] GOAWAY vs RST_STREAM 的区别
> - **GOAWAY**: connection-level,告诉对方"这条连接以后不能新建 stream 了",属于优雅关闭
> - **RST_STREAM**: stream-level,仅终止单个 stream,不影响同连接上的其他 stream
## 对开发者的实际影响
### 1. 连接数管理
不需要手动连接池。gRPC 的 `grpc.ClientConn` 已经做了连接复用和自动重建:
```go
// 一个 conn 对象就够了,内部自动管理连接数和重连
conn, _ := grpc.Dial("target", grpc.WithTransportCredentials(...))
// 所有 client share this conn
client1 := pb.NewService1Client(conn)
client2 := pb.NewService2Client(conn)
```
> [!tip] 连接池误区
> 很多从 HTTP/1.1 转过来的开发者会习惯性建连接池。但在 gRPC 里一个 `Dial` 就够——gRPC 内部维护了一个连接管理器,会根据负载情况自动增减连接。
### 2. Keepalive 配置
生产环境务必调优 keepalive 参数,否则可能被负载均衡器或 K8s ingress 切断连接:
```go
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 10 * time.Second,
Timeout: 5 * time.Second,
PermitWithoutStream: true, // 即使没有 active stream 也发 ping
})
grpc.KeepaliveParams(keepalive.ServerParameters{
Time: 10 * time.Second, // PING 间隔
Timeout: 20 * time.Second, // 无响应则断开
})
```
> [!warning] PermitWithoutStream 的作用
> 当这个值为 false 时(默认),如果当前没有任何活跃的 RPC(比如空闲等待期),gRPC 不会发 keepalive ping——此时中间设备恰好会杀掉空闲连接。生产环境建议设为 true。
生产环境的推荐配置取决于你的中间件策略:
| 组件 | 典型 idle timeout | 建议 keepalive Time |
|------|-------------------|---------------------|
| AWS ALB | 60s | 25s |
| Nginx proxy_pass | 75s (proxy_read_timeout) | 30s |
| K8s kube-proxy (iptables) | 根据 conntrack | 25s |
| Cloud Load Balancer | varies | 20–30s |
### 3. MaxMessageSize
遇到 `"message too large"` 错误时,第一反应应该是检查这个限制,而不是怀疑代码逻辑。
### 4. 调试技巧
| 工具 | 用途 | 常用命令 |
|------|------|----------|
| `grpcurl` | CLI 工具,支持直接调用 gRPC 方法 | `grpcurl -plaintext -d '{"id": 42}' localhost:50051 user.v1.UserService.GetUser` |
| `nghttp` | HTTP/2 抓包分析,能看到 frame 级别细节 | `nghttp -v http://localhost:50051` |
| Wireshark | 原始帧级别的诊断 | filter: `tcp.port == 50051 && http2` |
| `GRPC_VERBOSITY=DEBUG GRPC_TRACE=all` | Go 内置的 verbose trace,打印内部事件 | `export GRPC_VERBOSITY=DEBUG && export GRPC_TRACE=http2,transport,subchannel` |
```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
```
### 5. 常见问题排查速查表
| 现象 | 可能原因 | 排查方向 |
|------|---------|---------|
| 偶发性 `DEADLINE_EXCEEDED` | HTTP/2 Window 满了被 backpressure 堵住 | Wireshark 看 WINDOW_UPDATE 延迟 |
| 连接间歇性断开 | Keepalive 没开或时间太长,LB 杀了空闲连接 | 检查 `PermitWithoutStream` 和 LB timeout |
| `"message larger than max"` | Proto message 超过 4MB 限制 | 调大 `MaxCallRecvMsgSize` |
| `PROTOCOL_ERROR` / `INTERNAL` | 中间代理篡改了 HTTP/2 frame | 检查 Nginx / Envoy 配置,确保启用 http2 |
| 某个 stream 卡住但不报错 | Server 端 handler 阻塞或未回写 | 用 `GRPC_TRACE=stream,transport` 定位 |
| DNS 解析慢导致首次调用超时 | gRPC 内置 resolver 异步解析但首次调用不等 | 提前预热连接或用固定 IP |
## 附录:gRPC 协议分层速览
```mermaid
block-beta
columns 1
block:App
columns 1
A1["Protobuf Message<br/>(序列化后的 byte[])"]
end
space
block:GRPC
columns 1
B1["gRPC Frame<br/>(Wire type + Length + Payload)"]
end
space
block:HTTP2
columns 1
C1["DATA Frame"]
C2["HEADERS Frame"]
C3["WINDOW_UPDATE Frame"]
end
space
block:Transport
columns 1
D1["HTTP/2 Stream<br/>(multiplexed over one TCP)"]
end
space
D2["TCP Socket"]
A1 --> B1
B1 --> C1
B1 --> C2
B1 --> C3
C1 --> D1
C2 --> D1
C3 --> D1
D1 --> D2
style A1 fill:#FFD43B
style B1 fill:#00B6BC,color:#fff
style D1 fill:#4FC08D,color:#fff
```
## 关联笔记
- [[../1. Protobuf 基础篇/01-Protobuf 语法与消息定义]]
- [[../3. 服务端实现/08-Server 搭建与注册]]
- [[../3. 服务端实现/09-Streaming Handler]]
- [[../4. 客户端开发/11-Client 连接与 Dial]]
- [[../4. 客户端开发/12-Call Options 与 Context]]
- [[../6. 工程实践篇/20-性能优化与压测]]