本节目标:把 gRPC 拆成 HTTP/2 传输、protobuf 消息和生成代码三层,能在 linkd-v2 里启动 9092 并用阻塞 stub 调通
Ping。
适用版本:JDK 25 + Netty 4.2.19.Final + gRPC-Java 1.76.0 + Protobuf 4.33.2。
承接的问题
11.2 的 9091 已经能处理 HTTP/2 请求,但业务代码仍然在拼 Http2HeadersFrame、判断 :path、手工写 JSON。对内部服务之间的调用,这些工作重复且容易出错:方法名是字符串,参数是手写序列化,超时和错误码各写各的。gRPC 要解决的就是这一层,它把「调用哪个方法、参数是什么、返回什么、多久超时」写进 IDL,由工具生成强类型代码。
linkd-v2 的 9092 只放一个 LinkdService.Ping 用于演示,不承载网关主链路。这个定位很重要:gRPC 不是 Netty 的替代品,也不是「更快的 HTTP」。它是协议 + 序列化 + 代码生成的组合,底层可以跑在 Netty 上,也可以跑在别的 HTTP/2 实现上。把 gRPC 当成一个黑盒框架,遇到超时、流控、版本冲突时就无从下手;拆开三层之后,每一层都能对应到前两节学过的概念。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Netty 4.2.19.Final。gRPC 1.76.0 与 Protobuf 4.33.2 的依赖可解析,本节不贴吞吐数字。
核心机制一:三层结构与各自的职责
gRPC 的一次调用从下往上经过三层:
业务代码
└── 生成代码(LinkdServiceGrpc):强类型方法、stub、序列化入口
└── gRPC 核心(io.grpc):方法路由、状态码、deadline、metadata、流控语义
└── 传输层(Netty / OkHttp / in-process):HTTP/2 帧、连接、TLS
└── protobuf:消息的二进制编码
最下面两层经常被混为一谈。传输层负责把字节从 A 送到 B,HTTP/2 的 HEADERS、DATA、WINDOW_UPDATE、GOAWAY 都在这一层;protobuf 只负责「一个 PingRequest 对象怎么变成字节」,它不关心连接、stream 和超时。gRPC 核心层把两者粘起来:一次 unary 调用对应一个 HTTP/2 stream,请求消息放在 HEADERS 之后的 DATA 里,响应消息放在反方向的 DATA 里,状态码放在 trailers 里。
NettyServerBuilder 只属于传输层。它做的事情和 11.2 的 ServerBootstrap 类似:建 EventLoopGroup、装 HTTP/2 codec、绑定端口、管理连接生命周期。换成 ServerBuilder.forPort(9092) 时,gRPC 通过 ServiceLoader 找可用的传输实现;classpath 里只有 grpc-netty-shaded 时最终仍然落到 Netty。换成 in-process 传输,连 TCP 都没有,但业务代码和生成代码不变。
这也解释了为什么「gRPC 只能跑在 Netty 上」是错的。gRPC-Java 提供 grpc-netty、grpc-netty-shaded,历史上还有 grpc-okhttp 客户端传输,测试里常用 in-process 传输;其他语言的实现也各有自己的传输。gRPC 的线协议要求 HTTP/2,但传输实现是可替换的。 linkd-v2 选 Netty,是因为服务端已经在跑 Netty,线程模型、原生传输和诊断工具都能复用。
核心机制二:pom 依赖与 linkd.proto
9092 需要四个运行时依赖。grpc-netty-shaded 是传输,grpc-protobuf 负责 protobuf 与 gRPC 的桥接,grpc-stub 提供 stub 基类,protobuf-java 是消息运行时。
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-netty-shaded</artifactId>
<version>1.76.0</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
<version>1.76.0</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
<version>1.76.0</version>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>4.33.2</version>
</dependency>
grpc-protobuf 会传递依赖一个 protobuf-java 版本,这里显式写 4.33.2 是为了和生成代码使用的 protoc 对齐。生成代码里有运行时版本校验,protoc 是 4.33.2、运行时却是更老的 4.x 时,类加载阶段就可能失败;两者版本对齐比「取最新」更重要。改完依赖后用 mvn dependency:tree 确认没有第二个 protobuf-java 被传递进来。
IDL 放在 linkd-server/src/main/proto/linkd.proto:
syntax = "proto3";
package linkd.v2.grpc;
option java_package = "com.plume.linkd.v2.grpc";
option java_multiple_files = true;
option java_outer_classname = "LinkdProto";
message PingRequest {
string client_id = 1;
int64 sent_at_millis = 2;
}
message PingReply {
string client_id = 1;
int64 server_at_millis = 2;
string node = 3;
}
service LinkdService {
rpc Ping(PingRequest) returns (PingReply);
}
java_multiple_files = true 让 PingRequest、PingReply 各自生成顶层类,LinkdServiceGrpc 仍然是单独一个类,里面包含 LinkdServiceImplBase、newBlockingStub、newStub 等静态入口。java_package 决定生成类的包名,Java 代码里的 import 必须和它一致;改包名等于改所有调用方,不能只改 proto 不改代码。
生成代码由构建期的 protoc 插件完成,常用组合是 protobuf-maven-plugin 加 protoc-gen-grpc-java。本节只列运行时依赖,插件版本按项目的 BOM 对齐;手工执行 protoc 时,--java_out 生成消息类,--grpc-java_out 生成 LinkdServiceGrpc,两个输出目录都要加到源码根。
核心机制三:服务端与 NettyServerBuilder
LinkdServiceImpl 继承生成的 LinkdServiceGrpc.LinkdServiceImplBase,覆写 ping。方法签名由生成代码固定:两个参数,一个是请求消息,一个是 StreamObserver<PingReply>,返回值是 void。
package com.plume.linkd.v2.grpc;
import io.grpc.Status;
import io.grpc.stub.StreamObserver;
public final class LinkdServiceImpl extends LinkdServiceGrpc.LinkdServiceImplBase {
private final String node;
public LinkdServiceImpl(String node) {
this.node = node;
}
@Override
public void ping(PingRequest request, StreamObserver<PingReply> responseObserver) {
if (request.getClientId().isBlank()) {
responseObserver.onError(Status.INVALID_ARGUMENT
.withDescription("client_id is required")
.asRuntimeException());
return;
}
PingReply reply = PingReply.newBuilder()
.setClientId(request.getClientId())
.setServerAtMillis(System.currentTimeMillis())
.setNode(node)
.build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
}
}
onNext 发响应,onCompleted 结束 stream,两个调用缺一不可。只调 onNext 不调 onCompleted,客户端会一直等到 deadline,最终收到 DEADLINE_EXCEEDED,服务端日志却没有任何异常。出错时调 onError,并且不要再调 onCompleted;Status.INVALID_ARGUMENT 会变成客户端看到的 gRPC 状态码,withDescription 放在 trailers 里。
服务端启动只要三行:
Server server = NettyServerBuilder.forPort(9092)
.addService(new LinkdServiceImpl("linkd-a"))
.build()
.start();
NettyServerBuilder 的包名是 io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder,不是 io.netty 下的类。build() 之前还可以加 maxInboundMessageSize、keepAliveTime 等参数;这些参数作用在传输层,和 11.2 的 Http2FrameCodec 设置是两套入口。start() 抛 IOException,绑定失败时调用方要能区分「端口被占用」和「权限不足」。
LinkdGrpcServer 把 server 包起来,负责启动和优雅停机:
package com.plume.linkd.v2.grpc;
import io.grpc.Server;
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
public final class LinkdGrpcServer {
private final Server server;
public LinkdGrpcServer(int port, LinkdServiceImpl service) {
this.server = NettyServerBuilder.forPort(port)
.addService(service)
.build();
}
public void start() throws IOException {
server.start();
}
public void stop() {
server.shutdown();
try {
if (!server.awaitTermination(5, TimeUnit.SECONDS)) {
server.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
server.shutdownNow();
}
}
}
shutdown() 停止接受新调用,让在途调用跑完;awaitTermination 等一个期限;超时才 shutdownNow()。这个顺序和 11.2 的 GOAWAY 收尾是同一个思路,只是 gRPC 把细节藏在 Server 接口后面。stop() 不要在 EventLoop 线程里调,awaitTermination 会阻塞当前线程。
核心机制四:客户端、deadline 与 streaming
客户端用 NettyChannelBuilder 建连接,usePlaintext() 对应 9092 的明文端口。不调 usePlaintext() 时客户端会按 TLS 握手,对一个明文服务端表现为连接失败或 SSL 异常。
ManagedChannel channel = NettyChannelBuilder.forAddress("127.0.0.1", 9092)
.usePlaintext()
.build();
try {
LinkdServiceGrpc.LinkdServiceBlockingStub stub =
LinkdServiceGrpc.newBlockingStub(channel)
.withDeadlineAfter(500, TimeUnit.MILLISECONDS);
PingReply reply = stub.ping(PingRequest.newBuilder()
.setClientId("linkd-cli")
.setSentAtMillis(System.currentTimeMillis())
.build());
LOG.log(System.Logger.Level.INFO,
"reply node={0} serverAt={1}", reply.getNode(), reply.getServerAtMillis());
} finally {
channel.shutdownNow();
}
withDeadlineAfter 返回一个新的 stub,不修改原 stub。deadline 是整次调用的总预算,从调用发起开始计时,包含排队、传输、服务端处理;超过之后客户端取消请求,调用方收到 DEADLINE_EXCEEDED。它通过 grpc-timeout 请求头传给服务端,服务端可以据此判断剩余时间,但deadline 不会中断服务端线程:业务代码如果阻塞在 JDBC 上,线程仍然占着,只是结果没人接收。deadline 要大于服务端的正常处理时间,又不能大到让故障请求长期占资源,通常按上游 p99 加余量来定。
streaming 有四种形态:unary、server streaming、client streaming、bidi streaming。生成代码的差别只在方法签名:server streaming 的响应参数仍是 StreamObserver,client streaming 的请求参数变成 StreamObserver 且方法返回响应观察者,bidi 两边都是 StreamObserver。底层仍然是 HTTP/2 DATA 帧,受连接级和 stream 级窗口约束;onNext 不是「发出去就忘」,发送过快会堆在流控队列里。linkd-v2 的 9092 只有 unary 的 Ping,没有 streaming 方法;要加 streaming,先在 proto 里加 rpc,重新生成代码,再实现对应的观察者,不能手写一个同名方法冒充。
阻塞 stub 不能在 EventLoop 线程里调用。stub.ping() 会等响应,等在 Netty 的 EventLoop 上就是把整条连接的其他 stream 一起卡住。网关或 handler 里要用异步 stub(newStub)或把阻塞调用提交到业务线程池;第 3 章的阻塞隔离规则在这里同样适用。
在 linkd-v2 里落地:9092 的生命周期
LinkdV2Main 在 9091 之后启动 9092,在关闭时按逆序停止:
LinkdGrpcServer grpcServer = new LinkdGrpcServer(9092, new LinkdServiceImpl("linkd-a"));
grpcServer.start();
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
grpcServer.stop();
try {
http2Server.stop();
httpServer.stop();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}));
LinkdServiceImpl 的 node 来自配置,不是硬编码;多实例部署时 PingReply.node 能看出请求落到了哪台机器。Ping 只做连通性和时钟检查,不查数据库、不调下游,这样 9092 的故障不会和业务故障混在一起。要观测连接数,用 gRPC 自己的指标或 LinkdGrpcServer 暴露的状态,不要把 9093 的 ConnectionRegistry 当成 gRPC 连接表:两者是不同的协议、不同的端口、不同的生命周期。
9092 和 9091 都跑 HTTP/2,但不要试图让它们共享一个 Http2FrameCodec 或一个端口。gRPC 的 path 是 /linkd.v2.grpc.LinkdService/Ping,content-type 是 application/grpc,和 11.2 的 /healthz 是两套路由;混在一个 pipeline 里需要自己按 content-type 分流,收益很小,故障面却变大。
shaded Netty 的版本隔离
grpc-netty-shaded 把 Netty 的类重定位到 io.grpc.netty.shaded.io.netty.*。应用自己的 io.netty 类和 gRPC 内部的 shaded 类是两个不同的类,即使版本号相同也不能互相赋值。这个设计解决的是依赖冲突:linkd-v2 用 Netty 4.2.19.Final,gRPC 1.76.0 内部可以带它自己验证过的 Netty 版本,两者在 classpath 上共存,不会因为 ByteBuf 或 Channel 的方法差异抛 NoSuchMethodError。
代价是边界很硬:
- 应用里的
ByteBuf不能传给 gRPC 的 shaded API,反之亦然;跨边界只能传 byte 数组或 protobuf 消息。 - shaded 包里的
Epoll.isAvailable()检查的是 shaded 自带的原生库,和应用自己的Epoll不是同一个状态。 - 不要同时引入
grpc-netty和grpc-netty-shaded。两个传输实现都在 classpath 上时,gRPC 选择哪一个依赖加载顺序,排障时很难判断;统一用 shaded,或者统一用非 shaded 并显式对齐 Netty 版本。
如果项目必须用非 shaded 的 grpc-netty,就要把 gRPC 传递进来的 Netty 依赖排除,让整个应用只保留 4.2.19.Final 一套。这时 NettyServerBuilder 的包名变成 io.grpc.netty.NettyServerBuilder,应用和 gRPC 共享 EventLoopGroup 与 ByteBuf,版本必须严格一致。两条路都能走,但不能混着走。
常见错误与边界
错误一:只调 onNext 不调 onCompleted。 客户端阻塞到 deadline,报 DEADLINE_EXCEEDED;服务端没有任何异常,因为 stream 还开着。unary 调用必须 onNext 加 onCompleted,出错则 onError,三者互斥。
错误二:客户端忘了 usePlaintext()。 9092 是明文端口,客户端默认按 TLS 连接,握手阶段失败,通常表现为 UNAVAILABLE 加底层 SSL 异常。9092 不做 TLS;要加密就在前面加 TLS 终止,或者另行配置证书,不要指望 usePlaintext() 和 TLS 同时生效。
错误三:grpc-netty 与 grpc-netty-shaded 同时存在。 两个传输都注册到 ServiceLoader,ServerBuilder 选哪个不确定,classpath 里还多出一整套 Netty。用 mvn dependency:tree 查 io.grpc:grpc-netty,发现就排除。
错误四:protobuf 运行时比生成代码老。 protoc 4.33.2 生成的类在更老的 protobuf-java 上加载,可能抛运行时版本校验异常,或者在解析未知字段时行为不一致。生成代码的 protoc 版本和 protobuf-java 版本要一起升级、一起回归。
错误五:在 EventLoop 里调阻塞 stub。 stub.ping() 等响应期间占住 EventLoop,同一条连接上的其他 stream 全部停摆。handler 里用 newStub 的异步形式,或者把调用交给业务线程池。
错误六:deadline 设得过短。 服务端正常处理要 20 ms,客户端设 10 ms,压测时全是 DEADLINE_EXCEEDED,看起来像服务端故障。deadline 要覆盖服务端的正常处理时间,并且区分「客户端取消」和「服务端报错」两种状态码。
边界:gRPC 不等于 Netty。 本节用 NettyServerBuilder 是因为 linkd-v2 已经在 Netty 上;gRPC-Java 的 API 是传输中立的,测试可以用 in-process 传输,其他实现也可以替换。不要把 io.grpc.netty.shaded 的类泄漏到业务接口里,否则换传输时业务代码要跟着改。
边界:gRPC 的 HTTP/2 不是 11.2 的手写 HTTP/2。 gRPC 有固定的 content-type、trailers、状态码映射和消息分帧格式,直接用 curl 发一个普通 HTTP/2 请求不会变成 gRPC 调用。调试要用 grpcurl 或生成的客户端,不能拿 11.2 的 curl --http2-prior-knowledge 去调 9092。
小结
- gRPC 是 HTTP/2 传输、protobuf 序列化和生成代码三层组合,
NettyServerBuilder只负责传输层。 linkd.proto的java_package决定生成类包名,LinkdServiceGrpc提供LinkdServiceImplBase、newBlockingStub和newStub。- 服务端三行启动:
NettyServerBuilder.forPort(9092).addService(...).build().start();停机用shutdown()、awaitTermination、shutdownNow()三步。 - 客户端用
NettyChannelBuilder加usePlaintext(),withDeadlineAfter设置整次调用预算,超时状态是DEADLINE_EXCEEDED。 - streaming 仍是 HTTP/2 DATA 帧,受流控约束;
linkd-v2的 9092 只演示 unaryPing。 grpc-netty-shaded把 Netty 重定位到自己的包名,解决了版本冲突,也切断了ByteBuf、EventLoopGroup的共享;不要和grpc-netty混用。
HTTP/2 和 gRPC 都跑在 NioEventLoopGroup 上,下一节开始换传输:macOS 用 kqueue、Linux 用 epoll,原生传输在什么条件下才真正可用,Epoll.isAvailable() 为什么在 macOS 上返回 false,是 12.1 的内容。
阅读导航:上一节:11.2 HTTP/2 服务端与流生命周期 · 下一节:12.1 原生传输与平台差异 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。