--- 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-性能优化与压测]]