承接的问题
卷一和卷二解决的是「一条连接里怎么把字节变成消息」。到了服务之间,问题多了一层:调用方写的是 libraryClient.getBook(isbn),网络上跑的是 HTTP/2 的 HEADERS、DATA 和 trailers。中间这层映射如果说不清,后面所有治理手段都会失去落点——不知道 deadline 放在哪,就不知道超时该在哪一层算;不知道错误放在哪,就只能从响应体里猜。
这一节先把 RPC 的语义拆成五个要素,再逐一看它们在线格式里的位置,最后用 MethodDescriptor 在 9093 上跑通一次调用。读完之后,你应该能回答:一次 GetBook 从方法调用到字节流,中间经过了哪些结构,每个结构负责哪部分语义。
RPC 语义的五个要素
把「远程调用」和「本地调用」放在一起对比,差异集中在五件事上:
| 要素 | 本地调用 | 远程调用需要额外回答的问题 |
|---|---|---|
| 方法标识 | 类名 + 方法名 + 签名 | 对端怎么知道调的是哪个方法、哪个版本 |
| 参数与返回值 | 直接传对象引用 | 怎么序列化、字段怎么编号、类型怎么对齐 |
| 关联 | 同步栈帧天然关联 | 响应怎么找回对应的请求,并发时怎么不串 |
| 时间 | 调用会一直等到返回 | 等多久、超时后谁负责取消、剩余时间怎么传 |
| 错误 | 抛异常,栈信息完整 | 错误怎么编码、哪些能重试、原因怎么表达 |
这五件事缺一不可。很多「RPC 用起来别扭」的问题,本质是只解决了前两件:方法能调、参数能传,但关联靠一个自增 ID,时间靠 socket.setSoTimeout,错误靠返回体里的 code 字段。单机测试都能过,一上并发和故障就暴露。
gRPC 的选择是把这五件事分别放到协议的不同层,而不是塞进一个「请求对象」里:
| 要素 | gRPC 中的落点 |
|---|---|
| 方法标识 | HTTP/2 的 :path,格式 /package.Service/Method |
| 参数与返回值 | DATA 帧里的 Protobuf 消息,前面带 5 字节前缀 |
| 关联 | HTTP/2 的 stream ID,一个流一次调用 |
| 时间 | grpc-timeout 头,服务端还原成 Context 里的 deadline |
| 错误 | trailers 里的 grpc-status 与 grpc-message,可附 grpc-status-details-bin |
这张表是本节的主线。后面每一节讲治理,实际上都是在某一层上加规则:1.2 细化错误层,3.2 细化时间层,4.2 在 metadata 层加请求 ID 和租户。
从方法调用到 HTTP/2 流
一次 unary 调用在 HTTP/2 上是一条完整的流。客户端先发 HEADERS,再发一个 DATA,最后发 END_STREAM;服务端回 HEADERS、DATA、trailers。把中间的结构按顺序列出来:
客户端 服务端
| -- HEADERS (:method POST, :path /plume.linkd.v3.Library/GetBook,
| content-type application/grpc, te trailers,
| grpc-timeout 800m, x-request-id ...) -->|
| -- DATA (00 00 00 00 04 0a 02 34 32) ------------->|
| -- END_STREAM ------------------------------------->|
| <-- HEADERS (:status 200, content-type application/grpc) --|
| <-- DATA (00 00 00 00 0f 0a 02 34 32 12 07 ...) ----------|
| <-- trailers (grpc-status 0) -----------------------------|
几个容易忽略的点:
第一,HTTP 状态码几乎总是 200。业务成功失败看 trailers 里的 grpc-status,不看 :status。:status 非 200 通常意味着「请求根本没到 gRPC 层」——代理拒绝、路径写错、协议不匹配。把 200 当成成功、非 200 当成失败,会把「书不存在」误判成「服务挂了」。
第二,te: trailers 是 HTTP/1.1 时代的遗留要求,HTTP/2 里仍然要带。它声明的不是「我会发 trailers」,而是「我理解 trailers 这种帧」;有些代理看到它才愿意透传 trailers。
第三,grpc-timeout 是相对时间,不是绝对时间戳。它解决的是「两端时钟不一致」的问题:客户端说「还剩 800 毫秒」,服务端收到后按自己的时钟减,不需要同步时钟。代价是网络传输时间会被算进服务端的剩余时间里,这通常是想要的行为。
第四,一个 stream 只承载一次调用。HTTP/2 的多路复用让多条调用共享一条 TCP 连接,但流与流之间互不阻塞(除了连接级流控)。这一点在 6.1 展开。
消息帧:五字节前缀
HTTP/2 的 DATA 帧本身不保证消息边界。一次 DATA 可能包含半条消息,也可能包含三条。gRPC 在消息前面加了固定 5 字节:
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 1 | compressed | 0 表示未压缩,1 表示按 grpc-encoding 压缩 |
| 1 | 4 | message length | 大端无符号,只算消息体,不含这 5 字节 |
| 5 | N | message | Protobuf 序列化后的字节 |
长度字段只算消息体,这一点和卷二自定义帧的口径一致:解码器用 5 + length 算整帧,分配缓冲区时直接用 length。上限 4 MiB,超过就返回 RESOURCE_EXHAUSTED,而不是把连接撑爆。
下面这段代码只依赖 JDK,把「加前缀」和「拆前缀」写成两个方法,方便在没有 gRPC 依赖的测试里验证边界:
package com.plume.linkd.v3.wire;
import java.util.Arrays;
public final class GrpcMessageFraming {
public static final int PREFIX_LENGTH = 5;
public static final int MAX_MESSAGE_SIZE = 4 * 1024 * 1024;
public static byte[] frame(byte[] message) {
if (message.length > MAX_MESSAGE_SIZE) {
throw new IllegalArgumentException("message too large: " + message.length);
}
byte[] framed = new byte[PREFIX_LENGTH + message.length];
framed[0] = 0;
framed[1] = (byte) (message.length >>> 24);
framed[2] = (byte) (message.length >>> 16);
framed[3] = (byte) (message.length >>> 8);
framed[4] = (byte) message.length;
System.arraycopy(message, 0, framed, PREFIX_LENGTH, message.length);
return framed;
}
public static byte[] unframe(byte[] framed) {
if (framed.length < PREFIX_LENGTH) {
throw new IllegalArgumentException("incomplete prefix: " + framed.length);
}
int compressed = framed[0] & 0xFF;
if (compressed != 0 && compressed != 1) {
throw new IllegalArgumentException("bad compressed flag: " + compressed);
}
int length = ((framed[1] & 0xFF) << 24)
| ((framed[2] & 0xFF) << 16)
| ((framed[3] & 0xFF) << 8)
| (framed[4] & 0xFF);
if (length > MAX_MESSAGE_SIZE) {
throw new IllegalArgumentException("message too large: " + length);
}
if (framed.length != PREFIX_LENGTH + length) {
throw new IllegalArgumentException("length mismatch: " + length + " vs " + framed.length);
}
return Arrays.copyOfRange(framed, PREFIX_LENGTH, framed.length);
}
public static void main(String[] args) {
byte[] request = {0x0a, 0x02, 0x34, 0x32};
byte[] framed = frame(request);
System.out.println("framed=" + toHex(framed));
System.out.println("roundtrip=" + Arrays.equals(request, unframe(framed)));
}
private static String toHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
if (!sb.isEmpty()) {
sb.append(' ');
}
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}
预期输出(请求是 GetBookRequest{isbn="42"} 的序列化结果,4 字节):
framed=00 00 00 00 04 0a 02 34 32
roundtrip=true
0a 是字段 1、wire type 2(length-delimited),02 是长度,34 32 是 ASCII 的 42。这个字节序列在 2.1 会再拆一次,现在只需要确认前缀和长度口径。
一个常见反例是把 length 当成「整帧长度」。如果编码器写 5 + message.length,解码器按自己的口径读 length 字节,就会多读 5 字节,把下一条消息的前缀当成当前消息的尾巴。错误不会立刻报,通常要等到后面某条消息解析失败才暴露。
在 linkd-v3 里落地:一个完整的 MethodDescriptor
MethodDescriptor 是客户端与服务端共享的「方法身份证」。它包含四类信息:方法类型、完整方法名、请求/响应编组器,以及可选的幂等/安全标记。
package com.plume.linkd.v3.api;
import com.plume.linkd.v3.api.Book;
import com.plume.linkd.v3.api.GetBookRequest;
import io.grpc.MethodDescriptor;
import io.grpc.protobuf.ProtoUtils;
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()))
.setIdempotent(true)
.build();
private LibraryMethods() {
}
}
setFullMethodName 的格式是 包.服务/方法,斜杠前是服务名,斜杠后是方法名。写成 plume.linkd.v3.Library.GetBook 或 /GetBook,客户端能发出去,服务端会返回 UNIMPLEMENTED,因为路由按完整名字精确匹配。
setIdempotent(true) 不改变线格式,它只是告诉框架「这个方法重复调用是安全的」,供负载均衡、重试和追踪使用。GetBook 是只读查询,标 true 合理;CreateOrder 这类方法不能标,是否重试要靠 3.3 的幂等键判断。
服务端用 ServerServiceDefinition 把方法名和实现绑起来,客户端用 ClientCalls.blockingUnaryCall 调用:
package com.plume.linkd.v3.demo;
import com.plume.linkd.v3.api.Book;
import com.plume.linkd.v3.api.GetBookRequest;
import com.plume.linkd.v3.api.LibraryMethods;
import io.grpc.CallOptions;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.Server;
import io.grpc.ServerBuilder;
import io.grpc.ServerServiceDefinition;
import io.grpc.stub.ClientCalls;
import io.grpc.stub.ServerCalls;
public final class WireRoundTrip {
public static void main(String[] args) throws Exception {
ServerServiceDefinition service = ServerServiceDefinition.builder("plume.linkd.v3.Library")
.addMethod(LibraryMethods.GET_BOOK, ServerCalls.asyncUnaryCall((request, observer) -> {
observer.onNext(Book.newBuilder()
.setIsbn(request.getIsbn())
.setTitle("book:" + request.getIsbn())
.setStock(1)
.build());
observer.onCompleted();
}))
.build();
Server server = ServerBuilder.forPort(0).addService(service).build().start();
ManagedChannel channel = ManagedChannelBuilder
.forAddress("127.0.0.1", server.getPort())
.usePlaintext()
.build();
try {
Book book = ClientCalls.blockingUnaryCall(
channel, LibraryMethods.GET_BOOK, CallOptions.DEFAULT,
GetBookRequest.newBuilder().setIsbn("42").build());
System.out.println("unary=" + book.getTitle());
} finally {
channel.shutdownNow();
server.shutdownNow();
}
}
}
ServerBuilder 与 ManagedChannelBuilder 会按 classpath 选择传输 provider;本机只引入 grpc-netty-shaded,选中它内部的 Netty 实现,端口传 0 时由系统分配空闲端口,避免与 9093 冲突。
本机验证时用的是 shaded 的 NettyServerBuilder.forPort(0);上面的代码用 ServerBuilder,两者在只引入 shaded 的 classpath 上选中同一个 provider。本机(Temurin 25.0.5+8,grpc-netty-shaded 1.76.0)输出:
unary=book:42
这段代码只用了 ServerServiceDefinition 和 ClientCalls,没有生成 stub。真实项目里 comm-api 会由 protoc 生成 LibraryGrpc,用法等价:LibraryGrpc.newBlockingStub(channel).getBook(request)。手工写一遍的价值在于看清 stub 只是 MethodDescriptor + Channel + CallOptions 的封装,不是黑盒。
语义层与线格式层的边界
分清两层之后,很多设计问题就有了判断依据:
| 问题 | 归属层 | 判断依据 |
|---|---|---|
| 新增一个可选字段 | 线格式 + 契约 | 只要字段号新、类型兼容,旧端能忽略 |
把 int32 改成 string | 契约 | 线格式类型不兼容,必须新字段 |
| 超时设多少 | 语义 | 取决于调用链预算,不是协议常量 |
| 错误怎么表达 | 语义 | 用 Status + ErrorInfo,不要塞进响应消息 |
| 请求 ID 怎么传 | 语义(metadata) | 用 x-request-id,不要新增消息字段 |
| 消息太大怎么办 | 线格式 | 4 MiB 上限,超出走分页或对象存储 |
最容易犯的错是把语义塞进消息体。比如在 Book 里加 bool success 和 string error_message,看起来自洽,实际上有三个代价:第一,错误无法被拦截器统一处理;第二,重试逻辑拿不到「可重试」标记;第三,HTTP 网关无法把错误映射成状态码。1.2 会给出正确的错误模型。
反过来,把线格式问题当语义问题也会出错。比如为了「让错误更明确」把 grpc-status 改成自定义数字,代理和客户端都不认识,最后只能自己实现一套协议栈。
常见错误与边界
错误一:把 :status 200 当成功。 前面说过,gRPC 的业务状态在 trailers。只看 HTTP 状态码,会把 NOT_FOUND 当成成功,把响应里的默认 Book 当成真实数据。
错误二:长度字段含前缀。 五字节前缀里,长度只算消息体。含前缀会让两端各差 5 字节,错误延迟暴露。
错误三:忽略 compressed 标志。 客户端可能按 grpc-encoding: gzip 压缩,服务端必须先看 compressed 位再决定要不要解压。直接按未压缩解析,得到的是乱码而不是明确的错误。
错误四:方法名大小写或包名不一致。 plume.linkd.v3.Library/GetBook 与 plume.linkd.v3.library/GetBook 在字符串层面不同,路由不会匹配。生成代码能避免这类问题,手工写 MethodDescriptor 时最容易踩。
错误五:把大对象塞进 metadata。 metadata 是 HTTP/2 头,ASGI 代理和网关对头大小有限制,而且头不参与流控。超过几 KB 的内容应该放消息体,metadata 只放标识和少量上下文。
边界:4 MiB 是单条消息上限,不是单次调用上限。 流式调用可以发很多条消息,每条都受 4 MiB 限制;总大小由 deadline、流控和业务分页共同约束。服务端配置的上限如果小于客户端,客户端会在发送时收到 RESOURCE_EXHAUSTED,而不是等到业务处理。
边界:grpc-timeout 的单位是纳秒到小时。 常见写法是 800m(毫秒)、1S(秒)、100u(微秒)。解析时不要假设只有毫秒一种单位;跨语言调用时,单位写错会导致超时被放大或缩小三个数量级。
小结
- RPC 语义的五个要素:方法标识、参数与返回值、关联、时间、错误,分别落在
:path、DATA、stream ID、grpc-timeout和 trailers。 - gRPC 的 HTTP 状态码几乎总是 200,业务结果看
grpc-status;非 200 通常意味着请求没到 gRPC 层。 - 消息前缀是 5 字节:1 字节压缩标志 + 4 字节大端长度,长度只算消息体,上限 4 MiB。
MethodDescriptor是方法身份证,包含类型、完整方法名、编组器和幂等标记;stub 只是它的封装。- 语义问题不要塞进消息体,线格式问题不要自定义协议字段;两层分开,后面的治理才有落点。
- 本机最小闭环的输出是
unary=book:42,它证明的是调用链闭合,不包括超时、重试和错误处理。
调用能跑通之后,下一个问题是「失败时对端到底收到了什么」。1.2 从 Status 的十六个状态码讲到 ErrorInfo 的原因码,把「书不存在」和「下游挂了」分开。
阅读导航:上一节 目录与学习路径 | 下一节 1.2 错误模型与状态码
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。