一、为什么 gRPC 必须用 HTTP/2
| HTTP/1.1 的痛 | HTTP/2 的解药 |
|---|---|
| 一连接一请求(队头阻塞) | Stream 多路复用:一条 TCP 跑千百条并发 RPC |
| 文本协议开销 | 二进制帧(Frame)分帧 |
| 头部重复冗长 | HPACK 头压缩(字典+哈夫曼) |
| 无服务端主动推送语义 | 双向流天然匹配 gRPC 四种模式 |
二、目标检验③:一次一元调用的 HTTP/2 报文图
协议依据 :官方 PROTOCOL-HTTP2.md(源码走读时以它对照 http_util.go 的常量)。
连接建立(一次)
swift
TCP 连接
◄──────────────────────────────────────────────►
Client Server
│ magic "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" ──► │ 连接前言
│ ◄─────── SETTINGS 帧(初始窗口/MaxFrameSize) ─ │ 双方交换参数
│ ──────── SETTINGS(ACK) ────────────────────► │
请求方向(客户端 → 服务端)
ini
── HEADERS 帧 (Stream ID=1, END_HEADERS) ──────────────────────────►
伪头部 + 元数据(HPACK 压缩后):
:method POST
:scheme http (TLS 时是 https)
:path /helloworld.Greeter/SayHello ← 02 篇讲的全名!
:authority localhost:50051
content-type application/grpc ← 必须,grpc 的"身份证"
user-agent grpc-go/1.85.0
grpc-timeout 3000m ← 05 篇的超时预算(3 秒)
authorization bearer xxx ← 06 篇的 metadata
── DATA 帧 (Stream ID=1) ─────────────────────────────────────────►
┌─────────┬─────────────┬───────────────────────────┐
│ 压缩标志 │ 消息长度 │ Protobuf 编码的消息体 │
│ 1 byte │ 4 bytes │ N bytes │
│ (0/1) │ uint32 大端 │ (压缩时先 compress 再计长度) │
└─────────┴─────────────┴───────────────────────────┘
★ gRPC 自定义的"长度前缀帧"------protobuf 不自报长度,靠这 5 字节分界
END_STREAM 标志:一元调用随 DATA 置位;流式调用由客户端发空 DATA 时置位
### 响应方向(服务端 → 客户端)
◄── HEADERS 帧 (Stream ID=1) ────────────────────────────────────
:status 200 (HTTP 层 OK;gRPC 错误不看这个!)
content-type application/grpc
grpc-encoding gzip (若压缩)
◄── DATA 帧 (Stream ID=1) ───────────────────────────────────────
同样的 5 字节前缀 + 响应消息体
◄── HEADERS 帧(trailer, END_STREAM) ─────────────────────────────
grpc-status 0 ← gRPC 真正的状态码在这!
grpc-message xxx (失败时的描述)
★ 这就是 08 篇"多收一次 RecvMsg 等 trailer"的物理对应物
一图记住 :请求 = HEADERS + DATA;响应 = HEADERS + DATA + trailer(HEADERS);gRPC 状态在 trailer;消息靠 5 字节前缀分界。流式调用只是 DATA 帧可以出现 N 次而已。
三、grpc-go 的传输层实现(自研,非 net/http)
internal/transport/ 的核心文件分工:
| 文件 | 职责 |
|---|---|
transport.go |
接口定义:ClientTransport(:714)/ServerTransport(:756),帧类型、错误类型 |
http2_client.go |
客户端:建连握手、reader goroutine、keepalive PING 定时器、GOAWAY 处理 |
http2_server.go |
服务端:连接握手、逐帧解析开流、流数配额、GOAWAY(MaxConnectionAge 触发) |
controlbuf.go |
loopyWriter(:522):每连接唯一的写 goroutine + 优先级发送队列 |
flowcontrol.go |
流控窗口账本:trInFlow/outFlow/outStreamContext |
bdp_estimator.go |
BDP 带宽×延迟积估计:动态调大窗口,吃满长肥管道 |
http_util.go |
帧读写工具、头部编码、grpc-timeout/状态解析 |
用的是 golang.org/x/net/http2 的 Framer 和 HPACK (编码层复用),但连接管理、流调度、应用层逻辑全部自研。原因:net/http 的抽象无法提供 gRPC 需要的精细流控、GOAWAY 语义、keepalive 策略与连接级调优。
(例外:Server.ServeHTTP(server.go:1141) 允许把 gRPC 挂到 net/http 服务器上与他人共享端口,注释明确标注 EXPERIMENTAL 且性能/特性受限。)
四、单写者模型:loopyWriter(controlbuf.go:522)
scss
应用侧(任意多条流,任意多 goroutine)
│ SendMsg → 写入 controlBuf(并发安全队列)
▼
┌─────────── controlBuf(按优先级组织) ───────────┐
│ 高优先级:流控帧/SETTINGS ACK/PING 回应 │
│ 中优先级:HEADERS │
│ 普通优先级:各流的 DATA │
└───────────────────────┬────────────────────────┘
│ 唯一消费者
loopyWriter.run() (controlbuf.go:595,每连接 1 个 goroutine)
loop {
取当前可发的最高优先级项
检查目标流的 HTTP/2 发送窗口余额
├─ 够: 聚合成帧(可合并多个小消息) → framer 写出
└─ 不够: 记账挂起,先发别的;收到 WINDOW_UPDATE 再唤醒
处理 keepalive PING 到期(客户端)
}
设计收益:
- 无锁写 socket------多流并发不再竞争连接;
- 优先级调度------流控记账、PING 回应永远插队在大块数据之前,避免"队头的大文件饿死控制帧";
- 小消息聚合------多次 SendMsg 的数据可合进一个帧,减少系统调用。
五、流控:HTTP/2 窗口 + BDP
两级窗口
scss
连接级窗口(整条 TCP 的在途字节上限)
└── 流级窗口(单条 RPC 的在途字节上限,默认初始 64KB,可配)
发送方视角: 每发 DATA,窗口余额减少
接收方视角: 应用读走数据后发 WINDOW_UPDATE 帧"还款"
背压传导链 :服务端 handler 处理慢 → 不读 RecvMsg → 不发 WINDOW_UPDATE → 客户端 loopyWriter 窗口耗尽挂起 → 客户端 SendMsg 开始阻塞 → 上游收不到推送压力自然减速。整条链路无需任何额外代码,这就是 03 篇"流式铁律"背后的机制。
BDP 估计器(bdp_estimator.go)
高延迟大带宽链路(跨机房)默认 64KB 窗口严重吃不满管道。BDP 估算器通过给 ACK 打时间戳测 RTT 与吞吐,动态向对端申请放大窗口 (WINDOW_UPDATE 发大增量)。这解释了一个现象:抓包能看到窗口越涨越大。
六、keepalive 在传输层的样子
- 客户端
http2_client.go:定时器到期 → 无活动则发 PING(带数据) → 等 PONG;Timeout内无回应对应keepalive.ClientParameters.Timeout,判死连接;
- 服务端
http2_server.go:收到 PING → 立即 PONG;同时执法 ------两次 PING 间隔 <MinTime且无豁免 → 发 GOAWAY 断连;
MaxConnectionAge到期 → 服务端主动 GOAWAY → 客户端收到后把该 transport 标记退役,重连会走 balancer 重新 pick------滚动重启的优雅感来源。