16 KiB
tags, create time, update time
| tags | create time | update time | |||||
|---|---|---|---|---|---|---|---|
|
2026-05-11 16:42 | 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 绝大部分内容是重复的:
// 第 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 的核心思想是:客户端和服务端各维护一份相同的字典,只传索引号,不传原文。
这个字典有两个来源:
- 静态字典 — RFC 标准预定义了 61 个常用 header 的映射关系。比如
:method对应索引号 2,content-type对应另一个固定索引号。双方都内置这份字典,不需要额外同步。 - 动态字典 — 运行过程中遇到新出现的 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 打爆内存。grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(10*1024*1024)) // 10MB
连接的生命周期
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
各阶段的关键点:
- Connecting — TCP 握手。DNS 解析失败、目标不可达都会在这步挂掉。
- TLS + ALPN — 证书过期或域名不匹配在这一步被发现。
- Settings exchange — 双方交换 window size、max concurrent streams 等参数。正式握手点。
- Ready — 握手完成,可以创建 Stream 了。
- Active — 正常干活阶段,随时创建和销毁 Stream。
- GOAWAY — 服务端要停机重启了,通知"这条连接不再接受新请求",但已经创建的请求还能继续完成。
- Closed — 连接彻底断开。gRPC 会自动重连(reconnect policy)。
[!note] GOAWAY vs RST_STREAM
- GOAWAY:连接级别的。"这条连接以后不能建新流了" → 优雅关闭
- RST_STREAM:流级别的。"这个流坏了,断了" → 不影响同连接上的其他流
对开发者的实际影响
1. 不需要手动建连接池
HTTP/1.1 时代开发者习惯手动维护连接池。但在 gRPC 里一个 Dial 就够了——内部的连接管理器会自动处理复用和重连。
conn, _ := grpc.Dial("target", grpc.WithTransportCredentials(...))
client1 := pb.NewService1Client(conn)
client2 := pb.NewService2Client(conn)
// 所有 client 共享这个 conn,底层自动管理
2. Keepalive 必须配置
生产环境不配 keepalive,负载均衡器或 K8s ingress 会认为你的连接是空闲的然后直接杀掉。
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. 常用调试工具
# 快速测试一个 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