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 的阻塞语义;② 给单流设最大消息数与最大字节数;③ 客户端处理慢是业务问题,考虑“拉模式”(客户端按需请求下一页)而不是推模式。

关联