1. 问题现象:gRPC Scan 一碰大数据就崩
先说结论:gRPC Scan 超大数据报错message too large,本质是 gRPC 框架默认限制单条消息不超过 4MB,只要一次 Scan 返回的数据量超过这个上限,就会直接中断调用,报错信息类似grpc: received message larger than max (xxxx vs. 4194304)或者grpc_message too large。
我最早踩到这个坑,是在做工业设备的数据采集服务时。那时用 gRPC 写了一个 Scan 接口,用来批量拉取一批 Modbus 设备的寄存器数据。单台设备数据量不大,几百个寄存器撑死也就几 KB,但一旦把几百台设备的数据一次性聚合返回,响应体轻轻松松就能超过几十 MB。上线后第一个压测就把服务打挂了,客户端日志里清一色的message too large,服务端日志却干干净净,连个 error 级别的东西都没有。当时第一反应是代码写崩了,排查半天才发现是 gRPC 的默认限制在作祟。
这个报错并不是某一种语言特有的。Go、Java、Python、C++ 的 gRPC 实现里全都有这道坎。它也不会区分你用的是普通一元调用还是服务端流式调用,只要一条消息的体积超过限制,就会触发同样的错误。更麻烦的是,如果你们用的是 Higress、Envoy 这类网关来转发 gRPC 请求,网关层还有一份独立的限制,改完客户端和服务端,网关那关过不去,照样报错。
这篇文章会把这个问题从现象到根因到解决方案完整拆一遍。包括 gRPC 为什么要有 4MB 限制、限制到底卡在哪一层、不同语言怎么调、网关层怎么处理、以及从架构层面怎么避免硬扛大消息。适合正在排查message too large的开发者,也适合在设计 gRPC 接口时想提前避开这个坑的人。
2. 根因拆解:gRPC 限制消息大小到底在限制什么
2.1 4MB 限制从哪来
gRPC 默认把单条消息的体积上限定在 4MB(精确说是 4194304 字节),这个值写死在各个语言的 gRPC 核心库里。
为什么要有这个限制?核心原因是防内存被打爆。gRPC 的调用模型是一来一回,服务端要把完整消息读进内存,客户端也要把完整响应内容读进内存。如果不对消息大小做任何限制,攻击者或者失控的客户端可以发一个几百 GB 的超大请求,服务端还没来得及处理,内存就先被撑爆了。4MB 是 gRPC 官方权衡后的默认值,对大多数 RPC 业务来说足够用,又能挡住最粗暴的内存耗尽攻击。
这里要厘清一个概念:message too large报错的对象是“单个消息”,不是“整个调用过程的总流量”。gRPC 没有对一次调用整体传输的数据量做累计限制,只对承载业务数据的 Protobuf 消息体积做上限检查。所以如果你用服务端流式返回 1 万条小消息,每条 10KB,总流量 100MB,只要单条消息不超过 4MB,完全没问题。反过来,如果一次返回一条 5MB 的 Protobuf 消息,哪怕总共就这一条,也会直接报错。
2.2 服务端和客户端的双向限制
很多人以为只调服务端配置就够了,实际上客户端和服务端各自维护一套独立的限制,需要分别设置。
服务端有两个参数:MaxRecvMsgSize和MaxSendMsgSize。字面意思很直白:服务端允许接收的最大消息大小,和服务端允许发送的最大消息大小。单个 Scan 接口返回大数据时报message too large,触发的是服务端的MaxSendMsgSize——服务端在发送响应时发现消息超过 4MB,直接拒绝发送。但这里有个容易误判的细节:如果请求体本身超大,服务端收到请求时同样会因为MaxRecvMsgSize的 4MB 限制而报错,错误信息却可能长得一模一样。
客户端也有两个对应参数:MaxRecvMsgSize和MaxSendMsgSize。客户端请求数据很大时,需要调大客户端的MaxSendMsgSize;客户端接收大数据时,需要调大MaxRecvMsgSize。
我用一张表格把这四个参数对应关系捋清楚,排查时照着这张表对照就能快速定位该改哪边:
| 参数/方向 | 服务端 | 客户端 | 场景 |
|---|---|---|---|
| 发送限制 | MaxSendMsgSize | MaxSendMsgSize | 主动发大消息时受此限制 |
| 接收限制 | MaxRecvMsgSize | MaxRecvMsgSize | 接收大消息时受此限制 |
Scan 接口返回大数据,实际链路是:服务端发(受服务端MaxSendMsgSize限制)→ 客户端收(受客户端MaxRecvMsgSize限制)。所以这个场景至少要调服务端的MaxSendMsgSize和客户端的MaxRecvMsgSize。如果你只是把服务端配置加到了 64MB,客户端不动,那客户端依然会在接收时用默认的 4MB 做检查,报错照旧。这就是很多人“改了配置却没用”的第一大原因。
2.3 报错信息到底是谁抛出来的
message too large这个错误字符串在不同语言里略有差异。Go 的 gRPC 库会输出类似grpc: received message larger than max (5242880 vs. 4194304),括号里前一个是实际收到的字节数,后一个是当前限制值。Java 的报错一般是io.grpc.StatusRuntimeException: RESOURCE_EXHAUSTED: gRPC message exceeds maximum size。Python 则是grpc.RpcError: <_InactiveRpcError of RPC terminated with StatusCode.RESOURCE_EXHAUSTED: Received message larger than max.
注意 Go 报错信息里已经给出了实际消息大小和当前限制值,这两个数字是排查的第一手线索。如果你的报错信息里没有大小数值,可以用 tcpdump 或者在服务端加拦截器把消息体长度打出来,判断是不是真的超过限制,还是消息在传输过程中被网关截断导致的。
3. 解决方案:从改配置到改架构,逐层递进
3.1 方案一:直接调大限制(Go 示例)
最直接的做法是把限制调大,比如调到 64MB 或 256MB。下面的代码是 Go 语言 gRPC 服务端的设置方法。
服务端改造,在创建 gRPC Server 时传入grpc.MaxSendMsgSize和grpc.MaxRecvMsgSize两个参数:
import ( "google.golang.org/grpc" ) const maxMsgSize = 64 * 1024 * 1024 // 64MB func main() { server := grpc.NewServer( grpc.MaxSendMsgSize(maxMsgSize), // 服务端能发送的最大消息大小 grpc.MaxRecvMsgSize(maxMsgSize), // 服务端能接收的最大消息大小 ) // 注册服务并启动 pb.RegisterScanServiceServer(server, &scanServiceImpl{}) lis, err := net.Listen("tcp", ":50051") if err != nil { panic(err) } server.Serve(lis) }客户端改造,在 Dial 时指定grpc.WithDefaultCallOptions:
import ( "google.golang.org/grpc" ) const maxMsgSize = 64 * 1024 * 1024 func main() { conn, err := grpc.Dial( "localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()), // 生产环境请用TLS grpc.WithDefaultCallOptions( grpc.MaxCallRecvMsgSize(maxMsgSize), // 客户端能接收的最大消息大小 grpc.MaxCallSendMsgSize(maxMsgSize), // 客户端能发送的最大消息大小 ), ) if err != nil { panic(err) } defer conn.Close() client := pb.NewScanServiceClient(conn) // 调用 Scan 接口 resp, err := client.Scan(ctx, req) if err != nil { // 处理错误 } }这里有个经验值供参考:不要一上来就设个 1GB。调大限制是一柄双刃剑,限制越宽松,单次调用占用内存就越不可控。我一般先按业务预估峰值的 2 倍来设,比如 Scan 接口正常情况下最多返回 20MB 数据,那就设置成 64MB,留足余量但不至于失控。
Go 的 gRPC 库有个特点:grpc.MaxCallRecvMsgSize和grpc.MaxCallSendMsgSize是调用级别的选项,除了在Dial时全局设置,也可以在某一次具体调用时单独覆盖。这在接口混布的场景下很实用,只有 Scan 接口需要大消息,其他接口保持默认值就行,能显著降低内存风险:
resp, err := client.Scan(ctx, req, grpc.MaxCallRecvMsgSize(64*1024*1024), grpc.MaxCallSendMsgSize(64*1024*1024), )3.2 方案二:Java / Python 怎么调
Java 的 Netty 版本,在创建 Server 和 Channel 时设置:
import io.grpc.Server; import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; int maxMsgSize = 64 * 1024 * 1024; // 服务端 Server server = NettyServerBuilder.forPort(50051) .maxInboundMessageSize(maxMsgSize) // 服务端接收上限 .maxOutboundMessageSize(maxMsgSize) // 服务端发送上限 .addService(new ScanServiceImpl()) .build() .start(); // 客户端 ManagedChannel channel = NettyChannelBuilder.forAddress("localhost", 50051) .usePlaintext() .maxInboundMessageSize(maxMsgSize) // 客户端接收上限 .maxOutboundMessageSize(maxMsgSize) // 客户端发送上限 .build();Java 的maxOutboundMessageSize这个配置项在不同的 gRPC 版本里有过名称调整,老版本里可能叫maxMessageSize或者是通过MessageMarshaller来控制的。如果你用的版本找不到这个方法,直接设置.maxInboundMessageSize(maxMsgSize)也能解决大多数问题,因为服务端发送前会先做一次体积检查,maxInboundMessageSize在部分实现里也会参与发送侧的校验。
Python 的 grpc 包,在服务端初始化时设置:
import grpc from concurrent import futures max_msg_size = 64 * 1024 * 1024 # 服务端 server = grpc.server( futures.ThreadPoolExecutor(max_workers=10), options=[ ('grpc.max_send_message_length', max_msg_size), ('grpc.max_receive_message_length', max_msg_size), ], ) server.add_insecure_port('[::]:50051') server.start() # 客户端 channel = grpc.insecure_channel( 'localhost:50051', options=[ ('grpc.max_send_message_length', max_msg_size), ('grpc.max_receive_message_length', max_msg_size), ], )Python 走的是options列表的方式,参数名是字符串,容易写错。常见的坑是把grpc.max_send_message_length拼成grpc.max_send_size,或者把receive写成recv,一旦拼错 gRPC 会默默忽略,不报错但也不生效。建议设置后立刻跑一个大数据联调接口验证一下。
3.3 方案三:网关层别漏了(Higress / Envoy)
如果你的 gRPC 服务前面挂了网关(Higress、Envoy、Traefik 都算),网关层也有自己的消息大小限制。客户端 → 网关 → 服务端,这三段链路中任何一段的限制没放开,都会报同一个错。
以 Envoy 为例,在 gRPC 路由配置里有一个max_request_bytes字段,很多版本默认情况下对上行的 HTTP/2 DATA frame 大小有限制。Higress 基于 Envoy 实现,同样有类似的限制逻辑。如果你在 K8s 里用 Higress 做 gRPC 网关,需要在 VirtualService 或者 Higress 的 HTTP 路由配置中显式放开:
apiVersion: networking.istio.io/v1beta1 kind: VirtualService metadata: name: scan-grpc spec: hosts: - scan-grpc-service http: - match: - port: 50051 route: - destination: host: scan-grpc-service retries: - attempts: 0上面这个只是路由配置,网关层的消息大小限制通常要下沉到 EnvoyFilter 或者 Higress 的插件配置里。实际操作时,先确认网关日志里有没有出现message too large或者RESOURCE_EXHAUSTED级别的报错,有的话就去查网关侧的限制参数。网关配置这块比较绕,我遇到过不少团队是客户端、服务端都调好了,最后查半天发现是网关挡了一道。
3.4 架构层:分页、流式与压缩,治本的三板斧
调大限制只是把阈值抬高,不代表问题消失。如果 Scan 接口的数据量会持续增长,比如从 20MB 涨到 200MB,总有一次会冲破你设的阈值。真正治本的做法有三个方向。
第一个方向是分页。Scan 接口改成传游标或者分页参数,每次只返回一部分数据。这个方案改动最小,兼容性最好。客户端第一次调用拿到一页数据和下一页的游标,循环拉取直到游标为空。代价是调用次数变多,延迟有所增加,但每一响应的内存占用可控。
第二个方向是服务端流式。把一次返回一个超大 ScanResult 改成一次返回多条 ScanResultChunk。gRPC 的服务端流式本来就是为了解决这类问题设计的,单条消息可以维持较小的体积,不受 4MB 限制影响。客户端通过Recv()循环读取,需要一个收集器把这些 chunk 聚合起来。这个方案对 API 的改动比分页大一点,但数据延迟更低,客户端不用等全部数据攒完才开始处理。
第三个方向是压缩。gRPC 支持 gzip 压缩,配置压缩后,数据在传输前先压缩,体积通常能降到原来的 10% 到 30%。压缩有 CPU 成本,但和内存被打爆相比,这点成本完全可接受。Go 客户端开启 gzip 的方式:
conn, err := grpc.Dial( "localhost:50051", grpc.WithDefaultCallOptions( grpc.UseCompressor(gzip.Name), grpc.MaxCallRecvMsgSize(64*1024*1024), grpc.MaxCallSendMsgSize(64*1024*1024), ), )需要注意,gzip 压缩发生在序列化之后,也就是说 Protobuf 序列化后的大消息在内存里还是会先产生一份,再执行压缩。所以压缩能缓解网络传输和对方内存的压力,但本端的内存峰值还是按原体积计算的,限流措施不能省。
我个人的组合拳是:Scan 接口默认开分页,大数据量场景换服务端流式,传输过程中开启 gzip,三层叠加,彻底告别message too large。
4. 常见问题与排查实录
4.1 改了配置还是报错?八成是对称性问题
调大了服务端的MaxSendMsgSize,没调客户端的MaxRecvMsgSize,这是最常见的对称性问题。记住前面那张四个参数的对应表:一方发送,一方接收,两边都要放开才能通得过。我见过有一种排列组合是,服务端发送限制设成了 128MB,客户端接收限制忘了改还是 4MB,压测时客户端直接拒绝接收,报错信息指向客户端,服务端完全不知道发生了什么。
4.2 客户端报错但服务端日志干干净净
message too large在服务端不一定留日志。因为 gRPC 服务端发送大消息被拦下来时,错误直接以 Status 形式返回给客户端,服务端自身业务 handler 根本没有执行到,日志自然什么都没写。这不是 bug,是 gRPC 的拦截机制决定的。
排查时不要只看服务端日志,应该在客户端把错误信息的Code和Message完整打出来。Go 里可以用status.Code(err)和status.Convert(err).Message()拿到结构化错误;Java 里StatusRuntimeException的getStatus()会返回具体的 code 和 description;Python 抛出RpcError后,e.code()和e.details()也会有对应的值。正常情况下这类报错的错误码是RESOURCE_EXHAUSTED,看到这个枚举基本就能锁定是大小超限的问题。
4.3 限制已经设很大了,Scan 还是报错
有一种隐蔽的情况是消息在序列化之前没有超限,但序列化之后超了。Protobuf 对字段做了 varint 编码和字段号标记,字段数很多的时候序列化膨胀率可能达到 10% 到 30%。比如 Scan 返回的数据里有个 repeated 字段,里面有 100 万个整数,你在代码层面统计 []int 的长度是 100 万个,但序列化成二进制后是 5MB,恰好超过 4MB。
这种情况的排查方式是在服务端 handler 里把proto.Marshal后的len()打出来,对比一下最终实际发送的体积,确认是不是序列化膨胀导致的。对策还是那三板斧,压缩或者流式都能解决。
4.4 速查表:遇到 message too large 就这么查
| 排查步骤 | 操作 |
|---|---|
| 确认报错方向 | 根据报错信息判断是发送方还是接收方超限 |
| 确认当前限制值 | 查看日志中的实际字节数和限制字节数 |
| 调整服务端限制 | 设置MaxSendMsgSize/MaxRecvMsgSize |
| 调整客户端限制 | 设置MaxCallSendMsgSize/MaxCallRecvMsgSize |
| 检查网关限制 | 查看网关日志是否也有 RpcError / RESOURCE_EXHAUSTED |
| 验证序列化体积 | 在 handler 里打点毫秒数后打印序列化长度 |
| 长期治理 | 考虑分页、流式、gzip 压缩 |
说回我那个 Modbus 采集项目。第一次碰到message too large时我花了整整半天才定位到问题,当时报错信息里明确写了4194304这个数字,我却一直以为是某个字段长度算错了。后来把四个方向的限制全部调成 64MB,问题立刻解决。但上线运行两周以后,我主动把接口改成了服务端流式,因为采集设备的数量还在涨,单次聚合的返回体从刚上线的 20MB 又涨到了 60MB。调大限制只是给了你缓冲时间,真正要面对的是数据规模还会不会继续涨。Scan 这类接口,尤其要做好这个预判。