本节目标:知道一条 gRPC 消息在线上长什么样、压缩在哪一层生效、4 MiB 上限是默认值还是本卷配置,以及超限时客户端会收到什么状态。
适用版本:JDK 25 + gRPC-Java 1.76.0(grpc-netty-shaded)+ Protobuf 4.33.2。
承接的问题
4.1 的 GetBook 只返回一本书,消息体几百字节;4.2 的 metadata 也只有几十字节。到了 SearchBooks 和批量导入,一条消息可能是几百 KB 的 Book 列表,或者一段压缩过的全文。这时三个问题会同时出现:对象怎么变成字节、要不要压缩、超过上限时谁先失败。
gRPC 把这三件事分在相邻但不同的层:MethodDescriptor.Marshaller 负责对象与 InputStream 的互转,压缩层在消息前面加一个标志并按需 gzip,传输层再给消息加 5 字节的长度前缀。任何一层配置错了,表现都可能是「调用失败但业务日志没有异常」,因为失败发生在业务方法之前或之后。
这一节先看分帧格式和 Marshaller,再看压缩的开启位置与协商,最后用本机探针读出 1.76.0 的默认上限,并复现一次超限失败。流式消息的背压和窗口在第 6 章、第 8 章展开,本节只讨论单条消息的编码与上限。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,gRPC-Java 1.76.0,Protobuf 4.33.2。
核心机制一:5 字节长度前缀与 Marshaller
gRPC 的每条消息(unary 的请求、响应,streaming 的每个 onNext)都按下面的格式传输:
+----------------+----------------+-----------------------------+
| Compressed-Flag| Message-Length | Message-Bytes |
| 1 byte | 4 bytes (BE) | N bytes |
+----------------+----------------+-----------------------------+
Compressed-Flag 为 0 表示消息体未压缩,为 1 表示用当前协商的压缩算法压缩过。Message-Length 是后面消息体的长度,大端 4 字节,所以单条消息的理论上限是 4 GiB 减一,实际由 maxInboundMessageSize 限制。Message-Bytes 是 Marshaller.stream(...) 写出的字节,protobuf 消息就是序列化后的 wire format。
Marshaller 是一个只有两个方法的接口:stream(T) 把对象写成 InputStream,parse(InputStream) 把字节读回对象。生成的 stub 已经为每个消息类型配好 protobuf marshaller;手写 MethodDescriptor 时用 ProtoUtils.marshaller(defaultInstance),它内部复用 protobuf 的解析器,不复制字节。
package com.plume.linkd.v3.api;
import com.google.protobuf.Message;
import io.grpc.MethodDescriptor;
import io.grpc.ServerServiceDefinition;
import io.grpc.protobuf.ProtoUtils;
import io.grpc.stub.ServerCalls;
public final class LibraryMethods {
public static final MethodDescriptor<GetBookRequest, Book> GET_BOOK =
MethodDescriptor.<GetBookRequest, Book>newBuilder()
.setType(MethodDescriptor.MethodType.UNARY)
.setFullMethodName(MethodDescriptor.generateFullMethodName(
"plume.linkd.v3.Library", "GetBook"))
.setRequestMarshaller(ProtoUtils.marshaller(
GetBookRequest.getDefaultInstance()))
.setResponseMarshaller(ProtoUtils.marshaller(
Book.getDefaultInstance()))
.build();
public static ServerServiceDefinition service() {
return ServerServiceDefinition.builder("plume.linkd.v3.Library")
.addMethod(GET_BOOK, ServerCalls.asyncUnaryCall((request, observer) -> {
Book book = Book.newBuilder()
.setIsbn(request.getIsbn())
.setTitle("通信工程")
.setAuthor("Leeting Yan")
.setStock(42)
.build();
observer.onNext(book);
observer.onCompleted();
}))
.build();
}
private LibraryMethods() {
}
}
三个容易忽略的点:
setFullMethodName必须和 proto 生成的一致。 生成代码用ServiceName/MethodName的格式,手写成plume.linkd.v3.Library.GetBook时服务端会返回UNIMPLEMENTED,因为路由表里没有这个方法。Marshaller不负责压缩。 它只负责对象与字节的互转;压缩标志和长度前缀由 gRPC 的编解码层加,业务代码看不到。parse可能被调用多次。 流式场景下每个消息都会走一次parse,实现要避免共享可变状态;ProtoUtils.marshaller是线程安全的,自定义 marshaller 要自己保证。
protobuf 的字段号和类型决定了字节数,但和 gRPC 的上限是两套规则。字段号、packed、string 与 bytes 的差异在 2.1、2.3 讨论;本节只把「序列化后的字节数」当作上限计算的输入。
核心机制二:压缩的开启位置与协商
gRPC 的压缩是按消息生效的,不是按连接:同一条 HTTP/2 连接上,一条消息可以压缩,下一条可以不压缩。发送方在 Compressed-Flag 里标记,接收方按当前协商的算法解压。协商通过 HTTP/2 的 grpc-accept-encoding 头完成,客户端在请求头里列出自己能解压的算法,服务端在响应头里回自己的列表。
三个开启位置:
| 位置 | API | 作用范围 |
|---|---|---|
| 单次调用(客户端) | CallOptions.withCompression("gzip") 或 stub.withCompression("gzip") | 这次调用的请求 |
| 服务端响应 | ServerCall.setCompression("gzip") | 这个 call 的响应 |
| 注册表 | CompressorRegistry / DecompressorRegistry | 进程内可用的算法集合 |
CompressorRegistry.getDefaultInstance() 默认包含 gzip 和 identity。本机探针输出 gzip-registered=true,所以不需要额外注册就能用;如果项目用 CompressorRegistry.newEmptyInstance() 自建注册表,必须手动 register,否则 withCompression("gzip") 会在调用时失败。
服务端可以在拦截器里统一开启响应压缩,这样业务方法不用关心:
package com.plume.linkd.v3.core.codec;
import io.grpc.ForwardingServerCall;
import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
public final class GzipServerInterceptor implements ServerInterceptor {
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call, Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
call.setCompression("gzip");
return next.startCall(call, headers);
}
}
setCompression 必须在发送响应头之前调用;在拦截器开头调用最稳妥。它只影响响应,不影响请求解压:客户端发来的请求如果标了 gzip,服务端仍按 DecompressorRegistry 自动解压,业务方法拿到的已经是解压后的对象。
客户端按调用开启:
LibraryGrpc.LibraryBlockingStub stub = LibraryGrpc.newBlockingStub(channel)
.withCompression("gzip")
.withDeadlineAfter(800, TimeUnit.MILLISECONDS);
Book book = stub.getBook(GetBookRequest.newBuilder().setIsbn("978-7-111-00001-1").build());
压缩不是免费的。gzip 对文本、JSON、重复字段多的 protobuf 有效,对已经压缩过的图片、视频、加密数据几乎没有收益,反而多一次 CPU 往返。linkd-v3 的 Book 列表和 SearchBooks 适合 gzip;二进制附件走对象存储,不塞进 gRPC 消息。是否压缩要按消息类型和大小决定,不要全局无脑打开。
核心机制三:消息上限的四个配置点
gRPC 对单条消息有入站和出站两个方向的上限,配置点分布在服务端、channel 和单次调用:
| 配置点 | API | 方向 | 默认值(1.76.0) |
|---|---|---|---|
| 服务端 | NettyServerBuilder.maxInboundMessageSize(int) | 入站 | 4 MiB(4194304) |
| 客户端 channel | ManagedChannelBuilder.maxInboundMessageSize(int) | 入站 | 4 MiB(4194304) |
| 单次调用 | CallOptions.withMaxInboundMessageSize(int) | 入站 | 继承 channel |
| 单次调用 | CallOptions.withMaxOutboundMessageSize(int) | 出站 | 继承 channel/默认 |
本机用反射读取 1.76.0 的字段,输出如下:
client.maxInboundMessageSize=4194304
server.maxMessageSize=4194304
server.maxHeaderListSize=8192
client.maxInboundMessageSize 是 NettyChannelBuilder 的字段,server.maxMessageSize 是 NettyServerBuilder 中由 maxInboundMessageSize 写入的字段。两者都等于 4 MiB,所以4 MiB 是 gRPC-Java 1.76.0 的默认值,不是本书另设的配置。linkd-v3 在 9093 上显式写成 4 MiB,是为了让配置可审查,而不是改变默认行为。
服务端和客户端都显式配置:
Server server = NettyServerBuilder.forPort(9093)
.maxInboundMessageSize(4 * 1024 * 1024)
.maxInboundMetadataSize(8 * 1024)
.addService(LibraryMethods.service())
.build()
.start();
ManagedChannel channel = NettyChannelBuilder
.forAddress("libraryd.internal", 9093, InsecureChannelCredentials.create())
.maxInboundMessageSize(4 * 1024 * 1024)
.build();
maxInboundMetadataSize 限制的是请求头,不是消息体,默认 8192 字节;不要把它和 maxInboundMessageSize 混用。服务端没有 maxOutboundMessageSize 这个配置项:服务端能发多大的响应,最终由客户端声明的入站上限决定。客户端可以在单次调用上收紧出站:
CallOptions options = CallOptions.DEFAULT
.withDeadlineAfter(800, TimeUnit.MILLISECONDS)
.withCompression("gzip")
.withMaxInboundMessageSize(4 * 1024 * 1024)
.withMaxOutboundMessageSize(512 * 1024);
超过上限时,客户端收到 RESOURCE_EXHAUSTED。本机把客户端入站上限设成 1 字节,服务端返回 7 字节的 book:42,输出:
code=RESOURCE_EXHAUSTED desc=gRPC message exceeds maximum size 1: 7
这个错误发生在消息进入业务代码之前,服务端日志里不一定有异常;排查时先看客户端状态码和 description 里的两个数字(上限、实际长度),再确认是哪个方向、哪个配置点生效。
核心机制四:压缩与上限的组合规则
压缩和上限是两套独立的机制,组合时有几条规则:
- 上限判断看的是消息长度,不是压缩后的字节数。 压缩后的长度和展开后的长度不是同一个数;按展开后的大小估算上限,并单独防范压缩膨胀,不要把不可信的大压缩消息直接交给 gzip。
- 压缩标志按消息设置。 同一次 streaming 调用里,前一条消息可以压缩,后一条可以不压缩;接收方按每条消息的标志解压,不存在「整条连接解压一次」。
- 上限不区分请求和响应。 客户端和服务端各自只检查自己的入站方向;客户端限制入站,不会阻止服务端发出大响应,只会在客户端解包时失败。
- 流式消息逐条检查。
SearchBooks的每个Book单独算一条消息,不是整个流的总大小;一个流里有很多小消息时,上限不是瓶颈,流控窗口才是。
withMaxOutboundMessageSize 是客户端侧的发送前检查。它适合保护「本进程不要发出超大请求」,但不能替代服务端的入站限制:恶意客户端可以绕过自己的配置。服务端要独立配置 maxInboundMessageSize,并且不要为了「兼容大消息」把它调到无界。
在 linkd-v3 里落地:9093 的编码配置
linkd-v3 的 9093 只承载 Library 服务。服务端显式配置入站上限、元数据上限和响应压缩;客户端在 LibraryClient 里配置同样的入站上限,并对大响应调用单独收紧。配置集中在一个类里,避免每个调用点各写一份:
package com.plume.linkd.v3.server;
import io.grpc.Server;
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
import java.io.IOException;
public final class LibraryGrpcServer {
public static Server start(int port, LibraryServiceImpl service) throws IOException {
return NettyServerBuilder.forPort(port)
.maxInboundMessageSize(4 * 1024 * 1024)
.maxInboundMetadataSize(8 * 1024)
.addService(service)
.intercept(new GzipServerInterceptor())
.build()
.start();
}
}
客户端侧:
public final class LibraryClientConfig {
public static ManagedChannel channel(String host, int port) {
return NettyChannelBuilder
.forAddress(host, port, InsecureChannelCredentials.create())
.maxInboundMessageSize(4 * 1024 * 1024)
.defaultLoadBalancingPolicy("round_robin")
.build();
}
}
为了验证压缩链路,本机用一个自包含探针:服务端拦截器 setCompression("gzip"),客户端 withCompression("gzip") 发 42,服务端回 book:42;同一进程再发一次带 1 KiB 入站/出站上限的调用。输出:
gzip-registered=true
compressed-reply=book:42
limited-reply=book:42
第一次证明 gzip 在 1.76.0 的默认注册表里可用,且压缩后的 unary 调用能正常往返;第二次证明 1 KiB 上限对 7 字节的消息不构成限制,上限是按消息长度判断的,不是按连接累计。
linkd-v3 的 HTTP 网关 9094 有另一套 JSON 大小限制。不要把 gRPC 的 4 MiB 直接套到 HTTP 请求体上:网关还要解析 JSON、做字段裁剪,过大的请求应该在网关层用更小的上限拒绝,避免把内存压力传导到 9093。
常见错误与边界
错误一:只配客户端,不配服务端。 客户端把 maxInboundMessageSize 调到 16 MiB,服务端仍然按 4 MiB 拒绝入站请求;反过来,服务端调到 16 MiB,客户端仍然在 4 MiB 处解包失败。两侧要按同一个消息规格配置,并用契约测试覆盖上限。
错误二:把 metadata 上限当成消息上限。 maxInboundMetadataSize 管的是 header,默认 8 KiB;请求 ID、token、traceparent 都在这里。把它调到 4 MiB 不会让大消息通过,只会让恶意请求占用更多内存。
错误三:对所有消息无条件 gzip。 小消息压缩后可能更大,CPU 白花;已压缩的二进制再 gzip 几乎没有收益。按消息类型和大小决定,或者先测量再开启。
错误四:用 withMaxOutboundMessageSize 当服务端保护。 它是客户端的自我约束,恶意客户端可以绕过。服务端的 maxInboundMessageSize 才是边界。
错误五:把 RESOURCE_EXHAUSTED 当成服务端过载。 这个状态既可能是消息超限,也可能是限流、并发上限或流控。看 description 和错误详情,不要只看状态码就扩容。
边界:上限不是越大越好。 4 MiB 的默认值是在「支持常见批量响应」和「防止单条消息吃光内存」之间的折中。把上限调到 64 MiB,等于允许一个请求占用几十 MB 堆内存;并发一高,GC 先崩。确实需要传大对象时,改用流式分片或对象存储引用。
边界:压缩不改变语义。 gzip 只是编码,消息内容、字段号、错误码都不变;调试时不要因为「压缩了」就怀疑序列化结果。需要看原始字节时,用测试里的 Marshaller.stream 直接读,不要抓包后手工解 gzip。
小结
- 每条 gRPC 消息由 1 字节压缩标志、4 字节大端长度和消息体组成;
Marshaller只负责对象与字节的互转。 ProtoUtils.marshaller(defaultInstance)是手写MethodDescriptor时的标准 marshaller;方法全名必须和 proto 生成的一致。- 压缩按消息生效,客户端用
withCompression("gzip"),服务端用ServerCall.setCompression("gzip");gzip 在 1.76.0 默认注册表中可用。 - 1.76.0 的客户端和服务端默认入站上限都是 4 MiB(4194304),
linkd-v3显式配置为同一个值以便审查。 - 超限时客户端收到
RESOURCE_EXHAUSTED,description 里带上限和实际长度;失败发生在业务代码之前。 - 服务端没有
maxOutboundMessageSize,响应大小最终受客户端入站上限约束;流式消息逐条检查,不是整个流的总量。
下一节从单条消息转到「调用发往哪个实例」:注册中心里存什么、怎么表示一个端点、租约和健康状态如何影响解析结果。
阅读导航:上一节 4.2 Interceptor 与 Metadata | 下一节 5.1 注册中心模型
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。