gRPC Scan 超大数据报错:message too large(>4GB)的根因分析与解决方案

摘要

在进行 Scan 性能测试时,当 Scan 范围较大(如 1,000,000 条记录)且单条 value 较大(如 16KB)时,gRPC 会直接报错:

rpc error: code = ResourceExhausted desc = grpc: message too large

即使在 Go 层将 MaxRecvMsgSize 设置为 20GB,该问题依然存在。
本文结合 实际压测场景 + gRPC 协议规范,分析该问题的根本原因,并给出两种可落地的解决方案。


一、问题背景

测试条件

  • Scan 长度:1,000,000
  • 单条 value 大小:16KB
  • 理论返回数据量:约 15~16GB
  • 测试时机:
    • 已写入约 100GB 数据
    • Full GC 执行完成两轮之后
  • 测试工具(自己写的):
    benchmark/scan_pro/scan_pro.go
    

报错信息

rpc error: code = ResourceExhausted desc = grpc: message too large (16014487524 bytes)

并且可以稳定复现一个现象:

当 scan_length × value_size 超过约 4GB 时,必然报错


二、根因分析:gRPC 协议层的 4GB 硬性限制

1️⃣ gRPC 消息 framing 机制(协议级)

gRPC 并不是直接“裸传” protobuf 数据,而是对每条消息进行 framing。

在 gRPC 官方协议文档中明确说明:

Each gRPC message is prefixed with a 5 byte header:

  • 1 byte: compressed flag
  • 4 bytes: unsigned integer message length (big-endian)

官方协议说明:
https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md

也就是说:

| 1 byte | 4 bytes |     N bytes     |
| flag   | length  | message payload |

2️⃣ 为什么单条 gRPC 消息无法超过 4GB

  • 4 字节无符号整数最大可表示:
    2^32 - 1 = 4,294,967,295 bytes ≈ 4GB
    
  • 这是 协议 framing 层的限制
  • Go 层的:
    grpc.MaxRecvMsgSize(...)
    grpc.MaxSendMsgSize(...)
    
    只是 额外校验
  • 无法突破协议对 message length 的表达上限

因此:

❌ 单条 gRPC Response 永远不可能超过 4GB
❌ 调大 MaxRecvMsgSize 不能解决问题

Scan 请求一次性返回约 16GB 数据,本质上是 一次性构造了一个超过 4GB 的 gRPC 消息,因此必然失败。


三、解决方案一:使用 gRPC 流式传输(推荐)

这是 gRPC 官方推荐的 大数据传输方式

3.1 修改 Proto 定义

将普通 RPC 改为 Server Streaming RPC

service KV {
  rpc ScanRangeInRaft (ScanRangeRequest)
      returns (stream ScanRangeResponse);
}

message ScanRangeResponse {
  map<string, string> key_value_pairs = 1;
  int32 LeaderId = 2;
  string Err = 3;
}

3.2 服务端:边 Scan 边 Send

func (kvs *KVServer) ScanRangeInRaft(
    req *kvrpc.ScanRangeRequest,
    stream kvrpc.KV_ScanRangeInRaftServer,
) error {

    batchSize := 1000
    batch := make(map[string]string)

    for iter.Seek(start); iter.Valid(); iter.Next() {
        batch[key] = value

        if len(batch) >= batchSize {
            if err := stream.Send(&kvrpc.ScanRangeResponse{
                KeyValuePairs: batch,
            }); err != nil {
                return err
            }
            batch = make(map[string]string)
        }
    }

    if len(batch) > 0 {
        _ = stream.Send(&kvrpc.ScanRangeResponse{
            KeyValuePairs: batch,
        })
    }
    return nil
}

3.3 客户端:循环 Recv

stream, err := client.ScanRangeInRaft(ctx, req)
if err != nil {
    return err
}

for {
    resp, err := stream.Recv()
    if err == io.EOF {
        break
    }
    if err != nil {
        return err
    }

    for k, v := range resp.KeyValuePairs {
        // 处理或统计
    }
}

优势:

  • 避免 4GB 限制
  • 极大降低内存峰值
  • 更符合真实 Scan 吞吐模型

四、解决方案二:应用层分页 Scan(非流式)

如果短期内无法修改 proto,可采用分页方式:

  • 每次 Scan 限制返回条数
  • 记录最后一个 key
  • 多轮 Scan 拼接结果

但该方案:

  • 客户端逻辑复杂
  • 吞吐测试不如 Streaming 真实
  • 仍需人工控制单次返回大小

五、总结

  • gRPC 单条消息存在不可突破的 4GB 协议上限
  • Scan / Batch / Snapshot 等场景 必须拆分
  • Streaming RPC 是最优解
  • Benchmark 中一次性返回超大结果并不符合真实系统设计

参考资料

  • gRPC Protocol over HTTP/2
    https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md
  • gRPC 官方 Streaming 文档
    https://grpc.io/docs/what-is-grpc/core-concepts/#streaming-rpc
  • gRPC message too large issue
    https://github.com/grpc/grpc/issues/13841

温馨提示

本文基于实际项目与 gRPC 协议规范整理而成,如文中存在理解偏差或更优实现方式,欢迎私信或评论指出。

也希望本文能为后续遇到 gRPC 大规模 Scan / 批量数据传输问题的同学提供一些参考。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐