流控与限额:HTTP/2 窗口、消息大小与服务端限流

服务端被打挂的原因往往不是"请求太多",而是三道闸门缺了一道:HTTP/2 流控窗口决定一条连接在途能有多少未确认字节,消息大小与并发流上限决定单请求与单连接的规模边界,服务端限流决定超出容量时是排队、拒绝还是等死。本章给出这三层的机制、参数与建议值。

HTTP/2 流控:两级窗口

HTTP/2 的流量控制是"接收方给发送方发额度":初始窗口 65535 字节(64KB),接收方通过 WINDOW_UPDATE 追加。gRPC 在此之上做带宽延迟积(bandwidth-delay product,BDP)估算并动态调窗,所以高延迟链路不会因 64KB 窗口而吞吐受限。

两级窗口必须分清:

窗口参数约束对象
流级(stream)grpc.InitialWindowSize单条 RPC 的在途未确认字节
连接级(conn)grpc.InitialConnWindowSize该连接上所有流合计的在途字节
srv := grpc.NewServer(
	grpc.InitialWindowSize(1<<20),     // 每流 1MB(默认 64KB)
	grpc.InitialConnWindowSize(4<<20), // 连接级 4MB,建议为流级的 2~4 倍
	grpc.MaxRecvMsgSize(16<<20),       // 单条消息接收上限 16MB
	grpc.MaxSendMsgSize(16<<20),       // 与接收上限一致,否则"我发得出、对方收不下"
	grpc.MaxConcurrentStreams(1000),   // 单连接并发流上限,也是客户端建池的估算依据
)

内存估算必须算清:连接级窗口是常驻的,而"流级窗口 × 并发流"才是大头。1MB/流 × 1000 并发流 = 1GB,所以调窗前先算"单机可用内存 ÷(每流窗口 × 目标并发流)"。默认参数不是性能上限,而是"不会 OOM"的保守值,按上面的账自己调,别照抄网上的大数字。

客户端侧的调用级上限:

conn, _ := grpc.NewClient(target,
	grpc.WithDefaultCallOptions(
		grpc.MaxCallRecvMsgSize(16<<20), // 默认 4MB
		grpc.MaxCallSendMsgSize(16<<20),
	),
)

服务端限流:三级令牌桶 + 明确拒绝语义

用拦截器加令牌桶(golang.org/x/time/rate),按"全局 → 方法 → 用户"三级限:

type Limiter struct {
	global  *rate.Limiter            // 整机保护,最后一道闸
	method  map[string]*rate.Limiter // 按 FullMethod 分配配额
	perUser *sync.Map                // 按 uid 限速,防单用户刷爆
}

func limitUnaryServer(l *Limiter) grpc.UnaryServerInterceptor {
	return func(ctx context.Context, req any, info *grpc.UnaryServerInfo,
		handler grpc.UnaryHandler) (any, error) {
		if isHealthCheck(info.FullMethod) { // 健康检查与指标必须豁免
			return handler(ctx, req)
		}
		if !l.allow(ctx, info.FullMethod) {
			// 明确告诉客户端"是你太快了,不是服务坏了",并给出建议重试间隔
			st := status.New(codes.ResourceExhausted, "rate limit exceeded")
			st, _ = st.WithDetails(&errdetails.RetryInfo{
				RetryDelay: durationpb.New(200 * time.Millisecond),
			})
			return nil, st.Err()
		}
		return handler(ctx, req)
	}
}
  • 返回 ResourceExhausted 而不是 Unavailable:第 10 章的重试策略默认不会重试它,避免"限流 → 重试 → 更严重限流"的正反馈;
  • 带上 RetryInfo 细节,客户端才有依据做"遵守服务端建议"的退避;
  • 单机配额要按副本数折算:单机 1000 QPS、10 个副本就是对下游 10000 QPS,需要精确全局限额时用集中式配额(Redis 令牌桶)在拦截器里统一判断。

并发上限:别让排队变成雪崩

QPS 限流挡不住"慢请求堆积"——每个请求都合法,但都在等下游,goroutine 与内存无限增长。用有界并发兜底:

sem := make(chan struct{}, 512) // 单机在途 RPC 上限

func inflightUnaryServer() grpc.UnaryServerInterceptor {
	return func(ctx context.Context, req any, info *grpc.UnaryServerInfo,
		handler grpc.UnaryHandler) (any, error) {
		select {
		case sem <- struct{}{}:
			defer func() { <-sem }()
			return handler(ctx, req)
		default:
			return nil, status.Error(codes.ResourceExhausted, "server busy") // 满即快速失败
		}
	}
}

上限建议按 目标 QPS × 平均耗时 再乘 48 倍余量:2000 QPS × 20ms = 40 个在途,取 256512 就够。超过上限宁可拒绝,也不要让整机因为 goroutine 与内存爆炸而彻底不可用。

流式调用的背压

  • 服务端流式:不要在服务端写无界队列。Send 会因 HTTP/2 窗口耗尽而阻塞,这个阻塞就是天然背压,让它阻塞即可;但要配合第 07 章的 ctx 取消,否则一个不消费的客户端会长期占住 goroutine;
  • 双向流:客户端要控制发送速率并周期性 Recv,否则窗口互锁(双方都在等对方读)会死锁;
  • 大消息优先切 stream 传输,而不是靠调大 MaxRecvMsgSize 硬塞一个几百 MB 的 message。

参数建议值

参数(服务端)默认建议值说明
InitialWindowSize64KB256KB~1MB大消息/跨机房调大,注意内存
InitialConnWindowSize64KB1~4MB取流级窗口的 2~4 倍
MaxRecvMsgSize4MB4~16MB按最大业务消息定,不要设成 -1
MaxSendMsgSize无限制与接收上限相同防发出对手收不下的消息
MaxConcurrentStreams百级500~2000同时决定客户端连接池大小
MaxHeaderListSize偏大8~16KBmetadata 的边界,见第 09 章
在途 RPC 上限无QPS × 耗时 × 4有界 semaphore,超限快速失败
单用户 QPS无压测峰值的 2~3 倍防单用户刷爆,超限返回 ResourceExhausted
允许排队时长无≤ 50ms只允许极短排队,长排队要拒绝

常见坑

  • received message larger than max:发送端没超限、接收端 MaxRecvMsgSize 太小,报 ResourceExhausted。收发两侧上限要成套设置并写进接口文档。
  • 只调窗口不算内存:InitialWindowSize 设 4MB、并发 2000 流,等于每连接预留 8GB,先 OOM 再谈性能。
  • 拒绝用错状态码:返回 Unavailable 会让客户端按第 10 章策略重试,把限流打成雪崩;ResourceExhausted + RetryInfo 才是正确姿势。
  • 健康检查被限流:探针失败导致 Pod 反复重启,故障率反升。所有限流器与并发闸门都要留白名单。
  • 单机限额当全局限额:下游按单机容量算就会被打穿,多副本场景要按副本数折算或集中式配额。
  • WaitForReady(true) 配限流:调用方排队等就绪,请求堆在客户端,超时集中爆发;限流场景应保持 fail-fast。
  • 无界排队:任何"先塞进 channel 慢慢处理"的设计都必须带上限与超时,否则限流就变成了延迟放大器。

小结:HTTP/2 是两级窗口流控,InitialWindowSize 管单流、InitialConnWindowSize 管整连接,调窗前按"每流窗口 × 并发流"算内存;消息规模用 MaxRecvMsgSize/MaxSendMsgSize(建议 4~16MB,收发两侧一致);服务端用令牌桶做"全局/方法/用户"三级限流并返回 ResourceExhausted + RetryInfo,再用有界 semaphore 卡住并发(上限约为 QPS × 耗时 × 4),超出即快速失败;流式调用让 Send 阻塞传导背压,不要自己加无界队列。

笔记加载中…