vault backup: 2026-05-18 17:09:17
This commit is contained in:
@@ -1,297 +1,274 @@
|
||||
---
|
||||
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 带来的所有性能红利:多路复用、头部压缩、服务器推送。但也带来了一些新的心智负担——原来用 TCP 协议栈就能搞定的事,现在又多了一层帧(frame)和流(stream)的概念。理解底层原理才能调试那些"偶发性超时""连接无故断开"的问题。
|
||||
gRPC 跑在 HTTP/2 之上。这意味着两个结果:一是你**免费获得**了 HTTP/2 带来的所有性能红利——多路复用、头部压缩;二是你要接受一层新的抽象,理解 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
|
||||
把一次 gRPC 请求想象成寄快递:
|
||||
|
||||
## HTTP/2 vs HTTP/1.1 对比
|
||||
| 层级 | 类比 | 它的作用 |
|
||||
|------|------|----------|
|
||||
| **Protobuf** | 你要寄的物品 | 定义数据的格式。比如一个 User 对象:名字、年龄、邮箱 |
|
||||
| **gRPC** | 快递员 + 运单号 | 把物品打包,贴上一个"信封"(metadata),告诉收件方这是什么类型的请求 |
|
||||
| **HTTP/2 Frame** | 纸箱上的标签 | 把每个信封切成小箱子,贴上标签(类型、长度、属于哪个流) |
|
||||
| **Stream** | 快递站的分拣通道 | 一条逻辑通道上跑一个完整的请求-响应周期 |
|
||||
| **TCP** | 运送快递的卡车 | 保证每辆车安全到达,不丢包、不乱序 |
|
||||
|
||||
| 特性 | 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)
|
||||
```
|
||||
你调用 client.GetUser(request)
|
||||
→ Protobuf 把你的 request 序列化成字节 (装箱)
|
||||
→ gRPC 加上 metadata(超时时间、认证信息等) (贴运单)
|
||||
→ HTTP/2 把数据切分成 frame,打上 Stream ID (贴标签)
|
||||
→ TCP 可靠地发送到对方
|
||||
```
|
||||
|
||||
这不是什么代码层面的操作——gRPC 客户端内部自动处理。但你可以在 Wireshark 中看到这个 handshake 过程:Client 发 `PRI * HTTP/2.0`,Server 回复 `SETTINGS ACK`,然后双方才开始交换业务数据。
|
||||
回到最初的问题——**为什么 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] 常见坑
|
||||
> 某些老旧代理(如旧版 Squid / Nginx < 1.3.10)不支持 ALPN 或不认 Preface,会导致 HTTP/2 降级为 HTTP/1.1。遇到偶发的 "connection reset" 时可以检查一下中间件版本。
|
||||
> 一些老旧代理(旧版 Nginx < 1.3.10、旧版 Squid)不支持 ALPN 或不认 Preface,会导致 HTTP/2 偷偷降级为 HTTP/1.1。遇到"偶发的连接断开"时可以检查中间件版本。
|
||||
|
||||
### 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 的实际压缩效果
|
||||
>
|
||||
> [!tip] 完整流程一览
|
||||
> ```
|
||||
> // 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>
|
||||
> Step 1 : TCP 三次握手 —— 建道路
|
||||
> Step 2 : TLS + ALPN —— 确认都说 h2
|
||||
> Step 3 : HTTP/2 Preface —— 正式确认,交换参数
|
||||
> Step 4 : 🚀 开始通信
|
||||
> ```
|
||||
>
|
||||
> 注意第二次调用时,`:method=POST` 和 `content-type=application/grpc` 都可以直接从 static table 引用——不需要再发送这些字符串。
|
||||
|
||||
## Flow Control 流量控制
|
||||
---
|
||||
|
||||
HTTP/2 有两层 flow control,各自独立工作:
|
||||
## 什么是 Frame 和 Stream?
|
||||
|
||||
| 层级 | 范围 | 默认窗口 | 控制方式 |
|
||||
|------|------|----------|----------|
|
||||
| Connection Level | 整个 TCP 连接 | 65535 bytes | WINDOW_UPDATE frame |
|
||||
| Stream Level | 单个 stream | 继承 connection level | WINDOW_UPDATE frame |
|
||||
这是 HTTP/2 最核心的两个概念,理解了它们就理解了 gRPC 的工作方式。
|
||||
|
||||
当接收方缓冲区快满时,发送方会收到 WINDOW_UPDATE 被"堵住"——这就是 HTTP/2 的 **backpressure**。gRPC 在此基础上又加了一层自己的 flow control:
|
||||
### Frame(帧)— 最小传输单元
|
||||
|
||||
| gRPC 配置项 | 说明 | 默认值 |
|
||||
|-------------|------|--------|
|
||||
| `MaxReceiveMessageSize` | 单次可接收的最大消息 | **4 MB** |
|
||||
| `MaxSendMessageSize` | 单次可发送的最大消息 | math.MaxInt32 |
|
||||
HTTP/2 的二进制世界里,所有数据都以 frame 为单位传输。每个 frame 的结构非常固定:
|
||||
|
||||
> [!warning] 这是一个常见的坑!
|
||||
> 当你的 protobuf message 超过 4MB 时,你会看到类似这样的错误:
|
||||
```
|
||||
┌───────────────────────┬──────────────┬──────────────────┐
|
||||
│ 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) on XXX
|
||||
> grpc: received message larger than max (5242880 vs 4194304)
|
||||
> ```
|
||||
> 修复方式:在 dial 或 server 选项中设置更大的 limit。
|
||||
> 解决:调大 `MaxCallRecvMsgSize`,但别设为无上限——小心被恶意巨型 payload 打爆内存。
|
||||
> ```go
|
||||
> grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(10*1024*1024))
|
||||
> grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(10*1024*1024)) // 10MB
|
||||
> ```
|
||||
> 注意:这里设置的单位是 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
|
||||
@@ -299,20 +276,18 @@ stateDiagram-v2
|
||||
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
|
||||
TLSEncrypted --> SettingsSent: Send Preface + SETTINGS
|
||||
Plaintext --> SettingsSent: Send Preface + SETTINGS
|
||||
|
||||
SettingsSent --> Ready: Receive SETTINGS ACK
|
||||
SettingsSent --> ErrorSettingsTimeout: Timeout
|
||||
ErrorSettingsTimeout --> Closed
|
||||
|
||||
Ready --> Active: Create Streams
|
||||
Active --> HalfClose: Stream done (END_STREAM)
|
||||
Active --> HalfClose: Stream done
|
||||
HalfClose --> GoAwayReceived: Server sends GOAWAY
|
||||
GoAwayReceived --> DrainPending: Wait for active streams
|
||||
DrainPending --> Closed: All pending done
|
||||
@@ -325,154 +300,102 @@ stateDiagram-v2
|
||||
}
|
||||
|
||||
note right of ErrorFailed
|
||||
DNS 解析失败
|
||||
TCP connect timeout
|
||||
DNS 解析失败 /
|
||||
TCP connect timeout /
|
||||
TLS cert invalid
|
||||
end note
|
||||
|
||||
note right of ErrorSettingsTimeout
|
||||
对方未在规定时间内
|
||||
回复 SETTINGS ACK
|
||||
对方未在规定时间回复
|
||||
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)
|
||||
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**: connection-level,告诉对方"这条连接以后不能新建 stream 了",属于优雅关闭
|
||||
> - **RST_STREAM**: stream-level,仅终止单个 stream,不影响同连接上的其他 stream
|
||||
> [!note] GOAWAY vs RST_STREAM
|
||||
> - **GOAWAY**:连接级别的。"这条连接以后不能建新流了" → 优雅关闭
|
||||
> - **RST_STREAM**:流级别的。"这个流坏了,断了" → 不影响同连接上的其他流
|
||||
|
||||
---
|
||||
|
||||
## 对开发者的实际影响
|
||||
|
||||
### 1. 连接数管理
|
||||
### 1. 不需要手动建连接池
|
||||
|
||||
不需要手动连接池。gRPC 的 `grpc.ClientConn` 已经做了连接复用和自动重建:
|
||||
HTTP/1.1 时代开发者习惯手动维护连接池。但在 gRPC 里一个 `Dial` 就够了——内部的连接管理器会自动处理复用和重连。
|
||||
|
||||
```go
|
||||
// 一个 conn 对象就够了,内部自动管理连接数和重连
|
||||
conn, _ := grpc.Dial("target", grpc.WithTransportCredentials(...))
|
||||
// 所有 client share this conn
|
||||
client1 := pb.NewService1Client(conn)
|
||||
client2 := pb.NewService2Client(conn)
|
||||
// 所有 client 共享这个 conn,底层自动管理
|
||||
```
|
||||
|
||||
> [!tip] 连接池误区
|
||||
> 很多从 HTTP/1.1 转过来的开发者会习惯性建连接池。但在 gRPC 里一个 `Dial` 就够——gRPC 内部维护了一个连接管理器,会根据负载情况自动增减连接。
|
||||
### 2. Keepalive 必须配置
|
||||
|
||||
### 2. Keepalive 配置
|
||||
|
||||
生产环境务必调优 keepalive 参数,否则可能被负载均衡器或 K8s ingress 切断连接:
|
||||
生产环境不配 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, // 无响应则断开
|
||||
PermitWithoutStream: true, // ★ 关键:即使没有活跃请求也发 ping
|
||||
})
|
||||
```
|
||||
|
||||
> [!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 |
|
||||
| Nginx proxy_pass | 75s | 30s |
|
||||
| K8s kube-proxy | 根据 conntrack | 25s |
|
||||
|
||||
### 3. MaxMessageSize
|
||||
> [!warning] `PermitWithoutStream` 为什么重要?
|
||||
> 默认值是 `false`。当没有活跃 RPC 时(比如两次调用之间的等待期),gRPC **不会**发 keepalive ping。恰好此时中间设备判断连接空闲,直接把 TCP 连接断掉了——下次调用就报 `connection reset`。设成 `true` 就能保住连接。
|
||||
|
||||
遇到 `"message too large"` 错误时,第一反应应该是检查这个限制,而不是怀疑代码逻辑。
|
||||
### 3. 遇到错误时的排查思路
|
||||
|
||||
### 4. 调试技巧
|
||||
| 现象 | 可能的原因 | 第一反应检查什么 |
|
||||
|------|-----------|----------------|
|
||||
| 偶发 `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` |
|
||||
|
||||
| 工具 | 用途 | 常用命令 |
|
||||
|------|------|----------|
|
||||
| `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` |
|
||||
### 4. 常用调试工具
|
||||
|
||||
```bash
|
||||
# 快速测试一个 gRPC endpoint
|
||||
grpcurl -plaintext -d '{"id": 42}' localhost:50051 user.v1.UserService.GetUser
|
||||
|
||||
# 查看服务提供的全部方法
|
||||
# 查看服务提供了哪些方法
|
||||
grpcurl -plaintext localhost:50051 list
|
||||
|
||||
# 查看某个 service 的详细定义
|
||||
# 查看某个 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
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
---
|
||||
|
||||
## 关联笔记
|
||||
|
||||
|
||||
Reference in New Issue
Block a user