--- 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
HEADERS → DATA → HEADERS → DATA"] Streams --> S2["Stream 2
HEADERS → DATA"] Streams --> SN["Stream N
HEADERS → DATA"] end ConnLevel -.-> Settings["SETTINGS / PING / GOAWAY
(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
Client→Server
Unary call"] S3["3
Client→Server
Another call"] S5["5
Client→Server
Streaming"] S2["2
Server→Client
Response to #1"] S4["4
Server→Client
Response to #3"] S6["6
Server→Client
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:
:method=POST
:path=/user.v1.UserService/CreateUser
content-type=application/grpc
grpc-timeout=30s"] --> B["HPACK Encoder"] B -->|"索引引用"| D[":method → :POST (static table idx 2)"] B -->|"动态表插入"| E[":path → full string
(dynamic table idx 1)"] B -->|"Huffman 编码"| F["grpc-timeout=30s
(Huffman: 5 bytes)"] D --> G["Header Block Fragment
(~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: > ``` > > 注意第二次调用时,`: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
(序列化后的 byte[])"] end space block:GRPC columns 1 B1["gRPC Frame
(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
(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-性能优化与压测]]