42.3 gRPC (四种模式 / 流控 / 拦截器)
一句话
gRPC = HTTP/2 + Protobuf + 四种流模式:四种模式只是“谁先关流”的不同组合,真正决定能不能上生产的是另外两件事——流控把内存钉死在上界,拦截器把横切逻辑收在一处(顺序错了就全失效)。
四种模式:谁先关流
| 模式 | 客户端 | 服务端 | 典型场景 | 坑 |
|---|---|---|---|---|
| Unary | 1 请求 | 1 响应 | 普通 RPC(查详情、下单) | 与 HTTP 请求无异,重试语义最简单 |
| Server streaming | 1 请求 | N 响应 | 订阅、导出、大结果集分页推送 | 客户端读得慢 → 服务端内存积压(要靠流控) |
| Client streaming | N 请求 | 1 响应 | 上报、批量上传、聚合计算 | 服务端要限流单流大小,否则一条流打爆内存 |
| Bidirectional | N 请求 | N 响应 | 长连接推送、实时协作、双向心跳 | 双方都要处理“半关闭”;重连与去重最复杂 |
🔑 判断用哪种模式,只问一句:“谁的数据量不确定、谁的节奏不由自己控制”。服务端数据量大且节奏由客户端消费能力决定 → server streaming(配流控);双方都持续产生 → bidi(配心跳 + 重连)。
流控:内存上界的唯一保障
gRPC 跑在 HTTP/2 上,流控是两级窗口:
| 窗口 | 控制什么 | 默认量级 | 作用 |
|---|---|---|---|
| 连接级窗口 | 整个 TCP 连接上未确认的字节 | 1 MB 级(可调) | 防止单连接压垮接收方 |
| 流级窗口 | 单条 stream 上未确认的字节 | 64 KB 级(可调) | 防止一条流占满连接 |
发送方每发一段数据就扣窗口,接收方处理完回 WINDOW_UPDATE 补窗口。没有流控的“流”等于无界队列——下面这段程序把两种情况的积压量算出来。
package main
import "fmt"
// ---------- ① 拦截器:一条链,顺序决定谁先看到请求 ----------
type Handler func(req string) (string, error)
type Interceptor func(Handler) Handler
func auth(next Handler) Handler {
return func(r string) (string, error) {
fmt.Println(" auth 进入")
defer fmt.Println(" auth 退出")
if r == "" {
return "", fmt.Errorf("未认证")
}
return next(r)
}
}
func metrics(next Handler) Handler {
return func(r string) (string, error) {
fmt.Println(" metrics 进入")
defer fmt.Println(" metrics 退出")
res, err := next(r)
_ = res
return res, err
}
}
func chain(h Handler, is ...Interceptor) Handler {
for i := len(is) - 1; i >= 0; i-- { // 逆序包裹:第一个注册的在最外层
h = is[i](h)
}
return h
}
// ---------- ② 流控:生产快、消费慢,两种情况的积压 ----------
const (
producePerTick = 10 // 每毫秒生产 10 条 = 10,000 条/秒
consumePerTick = 3 // 每毫秒消费 3 条 = 3,000 条/秒
ticks = 5000
window = 100 // 流控窗口(未确认上限)
)
func main() {
fmt.Println("=== 拦截器链(注册顺序:auth → metrics → handler)===")
h := chain(func(r string) (string, error) {
fmt.Printf(" handler 处理 %q\n", r)
return "ok", nil
}, auth, metrics)
if _, err := h("q=1"); err != nil {
fmt.Println("err:", err)
}
fmt.Println("\n=== 无流控:发送方想发多少发多少 ===")
inflight, maxIn, produced := 0, 0, 0
for t := 0; t < ticks; t++ {
inflight += producePerTick
produced += producePerTick
inflight -= min(consumePerTick, inflight)
if inflight > maxIn {
maxIn = inflight
}
}
fmt.Printf("5 秒内生产 %d 条,消费 %d 条,积压 %d 条(最大在途 %d)\n",
produced, produced-inflight, inflight, maxIn)
fmt.Printf("按每条 1KB 估算:接收端要缓存 %.1f MB —— 这就是 OOM 的来源\n", float64(maxIn)/1024)
fmt.Printf("\n=== 有流控:窗口 %d ===\n", window)
inflight, maxIn, produced, consumed := 0, 0, 0, 0
for t := 0; t < ticks; t++ {
send := min(producePerTick, window-inflight) // 窗口满了就阻塞(背压)
inflight += send
produced += send
c := min(consumePerTick, inflight)
inflight -= c
consumed += c
if inflight > maxIn {
maxIn = inflight
}
}
fmt.Printf("5 秒内实际发送 %d 条,消费 %d 条,积压 %d 条(最大在途 %d)\n",
produced, consumed, inflight, maxIn)
fmt.Printf("发送方被阻塞:把 50000 条推完需要 %.1f 秒(吞吐被消费者限制)\n",
float64(50000)/float64(consumePerTick*1000))
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
本机实测(macOS,Apple M1,Go 1.27):
=== 拦截器链(注册顺序:auth → metrics → handler)===
auth 进入
metrics 进入
handler 处理 "q=1"
metrics 退出
auth 退出
=== 无流控:发送方想发多少发多少 ===
5 秒内生产 50000 条,消费 15000 条,积压 35000 条(最大在途 35000)
按每条 1KB 估算:接收端要缓存 34.2 MB —— 这就是 OOM 的来源
=== 有流控:窗口 100 ===
5 秒内实际发送 15097 条,消费 15000 条,积压 97 条(最大在途 97)
发送方被阻塞:把 50000 条推完需要 16.7 秒(吞吐被消费者限制)
读数:
| 现象 | 含义 |
|---|---|
| 无流控:积压 35,000 条(约 34 MB) | 生产 3.3 倍于消费,5 秒就攒出可观的缓冲区,且还在增长 |
| 有流控(窗口 100):最大在途 97 条 | 内存被钉死,代价是发送方被阻塞 |
| 推完 5 万条要 16.7 秒 | 吞吐被最慢的一环决定(消费者 3,000 条/秒),这是背压的本意 |
| 拦截器顺序:auth 在最外层 | 它最先拒绝请求(未认证不必进业务),也最后退出 |
🔑 流控不是“性能优化”,是不流控就一定 OOM:只要生产者比消费者快,缓冲区就会涨到内存上限。gRPC 的窗口是“自动的流控”,但业务侧的流式接口仍要自己限(单流最大条数、单次批量上限),否则窗口只是把 OOM 推迟到“窗口够大的时候”。
拦截器:横切逻辑唯一该放的地方
| 能力 | 放拦截器 | 为什么 |
|---|---|---|
| 认证/授权 | ✅ | 所有方法都要,且要最先执行 |
| 超时与 deadline | ✅ | 统一注入,避免每个方法各写一遍 |
| 重试 | ⚠️ 只在客户端且仅幂等方法 | 服务端重试会放大副作用 |
| 指标/日志/trace | ✅ | 统一埋点,字段一致 |
| 限流 | ⚠️ 服务端入口 | 要按方法/租户维度,逻辑较重(落点见 30.4 服务治理 (注册、发现、熔断、限流)) |
| 业务校验 | ❌ | 属于 handler,不该藏在拦截器里 |
顺序(从外到内):
认证 → 限流 → 超时(deadline) → 重试 → 指标/trace → handler
- 认证最先:未认证的请求不该消耗限流配额与超时预算。
- 超时在重试外:否则每次重试都拿一份新的完整超时,总耗时 = 次数 × 超时(35.5 超时、重试与退避 的“超时预算”);跨跳如何传递这个预算见 42.5 超时、重试与幂等。
- 指标在最内:只统计真正打到 handler 的调用,否则重试会被重复计数(或者你想统计总尝试次数——那要明确写在指标名里)。
gRPC 与 HTTP/2 的关系(三个直接后果)
| 特性 | 后果 |
|---|---|
| 多路复用(一个连接跑多条 stream) | 连接数少、握手少;但一条流的应用层大消息仍可能拖慢同连接其他流(TCP 层队头阻塞未消失) |
| 头部压缩(HPACK) | 元数据便宜;但压缩状态是有状态的,代理/网关要正确处理 |
| 二进制帧 | 调试要靠抓包工具与 IDL,不能像 JSON 那样肉眼看 |
⚠️ 常见误解:“gRPC 用了 HTTP/2,所以没有队头阻塞”。HTTP/2 解决了 HTTP/1.1 的应用层队头阻塞(一个连接同时只能有一个请求在等响应),但 TCP 层的队头阻塞仍在——丢一个包,整条连接上所有 stream 都要等重传。协议层的横向对比见 42.6 协议对比 (gRPC / Thrift / HTTP)。
反模式
| 反模式 | 后果 |
|---|---|
| 用 bidi 流做“请求-响应”(发一个等一个) | 比 unary 复杂,却没有收益(还多了半关闭处理) |
| server streaming 不设单流上限 | 客户端读得慢,服务端内存涨 |
| client streaming 不限单条流大小 | 一条流传 10 GB,服务端 OOM |
| 拦截器里做重试且不判幂等 | 非幂等方法被重复执行(重复下单) |
| 超时拦截器在重试内层 | 每次重试重新计时,总耗时放大 |
| 把业务逻辑写进拦截器 | 所有方法都被迫经过它,行为不可预期 |
| 用 gRPC 对外暴露给浏览器 | 浏览器不支持 gRPC 帧,需要 grpc-web 或网关转换 |
自检
Q1 四种模式里,哪一种最容易被误用成“看起来能流式、实际是同步等待”?为什么?
▸ 答案:Bidirectional。很多团队用它实现“请求-响应对”,但 bidi 的语义是“双方独立地发与收”,没有“一问一答”的配对关系;如果业务需要配对,就要自己在消息里加关联 ID,并处理乱序与超时。真正的 unary 请求-响应更简单、重试语义清晰。判断标准:如果客户端发完一个请求就等一个响应、期间不发别的,那就该用 unary。
Q2 为什么“流控窗口调大”能提升吞吐,却可能让内存问题更严重?
▸ 答案:窗口是“允许在途未确认的数据量”。调大窗口 → 发送方可以连续发更多,吞吐上升(尤其高延迟链路,带宽时延积大);但接收方要缓存这些未处理的数据,内存随之上升。而且窗口只约束“单流/单连接”,如果有 1,000 条并发流、每条窗口 1 MB,总内存仍是 1 GB。所以窗口要按“接收方处理能力 × 延迟”来定,并且并发流数要有上限。
Q3 拦截器顺序里,为什么“超时”要在“重试”外层?
▸ 答案:因为超时是整次调用的总预算,重试是这个预算内的多次尝试。如果超时在内层,每次重试都会获得一份完整超时(例如每次 1s、重试 3 次 = 最坏 4s),上游设置的 1s 预算被突破;外层超时则让所有重试共享同一个 deadline,到点直接放弃。这也是 35.5 的“重试预算”在框架层的落地方式。
Q4 一条 server streaming 的流,客户端处理很慢,会发生什么?该在哪里修?
▸ 答案:流控会让服务端在窗口耗尽后阻塞发送(这是正确行为),表现为“服务端 goroutine 卡在 Send”。如果服务端把每条消息都缓存在自己的队列里(为了实现“异步发送”),就会绕开流控,内存持续增长。修法:① 不要在服务端做无界队列,直接用 Send 的阻塞语义;② 给单流设最大消息数与最大字节数;③ 客户端处理慢是业务问题,考虑“拉模式”(客户端按需请求下一页)而不是推模式。