408 lines
16 KiB
Markdown
408 lines
16 KiB
Markdown
---
|
||
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-性能优化与压测]]
|