gRPC从零到生产级:HTTP/2、Protobuf、流控、Deadline与负载均衡
gRPC是基于接口定义语言、代码生成和HTTP/2传输的RPC框架。它把“调用远程服务”包装成强类型Stub,但本质仍是跨网络请求:连接可能失败、响应可能丢失、超时不等于未执行、流量可能因长连接而倾斜。
本页从.proto契约一直追到HTTP/2帧、服务端线程、业务提交和响应返回,并覆盖四种调用模式、流控、Deadline、取消、重试、负载均衡、TLS、健康检查和生产Runbook。
本页负责从零建立协议与生产心智模型。生成代码、
ManagedChannelImpl、Resolver、Picker、DelayedTransport、Subchannel、ClientCallImpl、RetriableStream、Netty Transport、ServerImpl和Boot 3可运行工程,请继续学习gRPC Java内部原理与生产治理;面试复习单独进入gRPC独立面试题。
一、学习目标
- 区分gRPC、HTTP/2和Protobuf的职责。
- 解释Channel、Subchannel、Call、Stream、Stub和Server Handler。
- 说清一次Unary调用完整链路。
- 区分Unary、服务端流、客户端流和双向流。
- 解释HTTP/2多路复用、Stream ID、帧和头部压缩。
- 区分HTTP/2流控与业务背压。
- 设计Protobuf字段兼容、
oneof和演进规则。 - 传播Deadline与取消,并说明超时为何仍需幂等。
- 解释DNS/NameResolver、LoadBalancer和长连接倾斜。
- 排查UNAVAILABLE、DEADLINE_EXCEEDED、RESOURCE_EXHAUSTED和流卡死。
二、三层职责不要混淆
| 层次 | 负责什么 | 不负责什么 |
|---|---|---|
| gRPC | Stub、调用模式、状态码、拦截器、Deadline、LB等 | 业务事务和幂等 |
| HTTP/2 | 连接、Stream、多路复用、Frame、流控、HPACK | Protobuf字段兼容 |
| Protobuf | Schema、字段编号、二进制编解码、代码生成 | 网络重试和服务发现 |
gRPC默认常使用Protobuf,但协议理论上可替换Marshaller;HTTP/2承载的不只是“一个请求一个连接”,而是一条连接上的多个逻辑Stream。
三、核心对象
| 对象 | 含义 |
|---|---|
| ManagedChannel | 客户端逻辑通信入口,管理解析、LB、连接和状态 |
| NameResolver | 把目标名称解析为地址和服务配置 |
| LoadBalancer | 根据地址、连接状态和策略选择Subchannel |
| Subchannel | 指向一个或一组后端地址的连接管理对象 |
| Stub | 由.proto生成的类型安全调用入口 |
| ClientCall | 一次逻辑RPC调用,可能包含多次尝试 |
| HTTP/2 Stream | 一次尝试在连接上的双向逻辑流 |
| ServerCall | 服务端框架看到的RPC调用上下文 |
| Service Impl | 开发者实现的业务方法 |
Channel不是一条永不变化的TCP连接,也不应每次请求创建。它通常长期存在,内部可管理多个Subchannel和连接。Stub通常轻量且线程安全语义应按生成实现确认,可从Channel派生不同Deadline或凭据配置。
四、从.proto定义契约
syntax = "proto3";
package inventory.v1;
option java_multiple_files = true;
option java_package = "com.example.inventory.v1";
service InventoryService {
rpc Reserve(ReserveRequest) returns (ReserveReply);
rpc WatchStock(WatchStockRequest) returns (stream StockChanged);
rpc BatchReport(stream StockChanged) returns (BatchReply);
rpc Sync(stream StockChanged) returns (stream StockChanged);
}
message ReserveRequest {
string request_id = 1;
string sku = 2;
int32 quantity = 3;
}
message ReserveReply {
string reservation_id = 1;
ReserveStatus status = 2;
}
enum ReserveStatus {
RESERVE_STATUS_UNSPECIFIED = 0;
RESERVED = 1;
REJECTED = 2;
}字段编号是线上的身份,不是展示顺序。sku = 2发布后不能把编号2改给quantity。字段改名通常不影响二进制编号,但会影响JSON映射和代码API。
五、四种调用模式
| 模式 | 请求 | 响应 | 商业场景 |
|---|---|---|---|
| Unary | 一个 | 一个 | 查询库存、创建预留 |
| Server Streaming | 一个 | 多个 | 订阅状态、持续下载 |
| Client Streaming | 多个 | 一个 | 批量上报、分块上传 |
| Bidirectional Streaming | 多个 | 多个 | 双向采集、实时协同 |
流式RPC不是MQ。它通常依赖在线连接和双方内存状态;连接断开后如何续传、去重和恢复需要应用协议设计。需要离线堆积和持久消费时应使用MQ或持久日志。
六、一次Unary调用完整过程
flowchart TD
A["业务线程调用Blocking或Async Stub"] --> B["客户端拦截器加入身份与追踪元数据"]
B --> C["NameResolver与LoadBalancer选择Subchannel"]
C --> D["取得已有HTTP/2连接或建立TLS连接"]
D --> E["创建Stream并发送Headers与Message Frame"]
E --> F["服务端解析Headers和Protobuf消息"]
F --> G["服务端拦截器认证、授权和追踪"]
G --> H["业务方法执行本地事务"]
H --> I["序列化响应并发送Data与Trailers"]
I --> J["客户端把Status和消息交给调用线程"]gRPC状态通常在HTTP/2 Trailers中完成表达。HTTP/2连接成功不等于RPC成功;收到响应消息但Trailers表明非OK时,也不能只取消息忽略最终Status。
6.1 Blocking、Future与Async Stub
- Blocking Stub让当前线程等待结果,使用简单,但不能在事件循环线程中阻塞。
- Future Stub返回可组合Future,适合Unary异步调用。
- Async Stub通过观察者接收消息,支持流式调用。
客户端线程、gRPC网络事件循环和业务执行器是不同资源。服务端若把慢数据库查询直接跑在网络事件循环,会阻塞同线程处理的其他Stream。
七、HTTP/2连接、Stream和Frame
flowchart TD
A["一条TCP加TLS连接"] --> B["HTTP/2 Connection"]
B --> C["多个RPC使用不同Stream ID"]
C --> D["各Stream的Frame交错发送"]
D --> E["接收端按Stream ID分别重组"]7.1 多路复用解决什么
多个Stream共享一条连接,避免HTTP/1.1有限并发连接和应用层队头阻塞。不同Stream的Frame可交错,接收方按Stream ID重组。
但底层仍是TCP:一个丢包可能阻塞后续TCP字节到达,影响同连接上的多个Stream,这叫传输层队头阻塞。HTTP/2没有消除TCP层问题。
7.2 常见Frame
- HEADERS:方法、路径、Content-Type、元数据和状态相关头。
- DATA:承载gRPC消息帧字节。
- SETTINGS:协商并发流、初始窗口等连接参数。
- WINDOW_UPDATE:补充连接或Stream流控窗口。
- RST_STREAM:取消或异常终止单个Stream。
- PING:连接活性和往返时间。
- GOAWAY:通知不再接受更大Stream ID的新流,客户端应迁移新调用。
gRPC消息在DATA中还有自己的消息边界,通常包含压缩标记和消息长度,因此一个Protobuf消息可能跨多个HTTP/2 DATA Frame,一个DATA Frame也不能简单等同于一个业务消息。
八、流控、背压和isReady
HTTP/2有连接级和Stream级接收窗口。接收方消费数据后发送WINDOW_UPDATE,发送方才能继续。它防止一个快速发送者无限占满对方网络缓冲,但不等于业务队列永不OOM。
flowchart TD
A["发送方准备大量消息"] --> B["检查窗口与isReady"]
B --> C["可写时只发送有限批次"]
C --> D["不可写时暂停生产"]
D --> E["接收方消费并更新窗口"]
E --> F["onReady后继续下一批"]异步流式Java调用中,应关注ClientCallStreamObserver或ServerCallStreamObserver的isReady()和onReady回调,按可写状态生产消息。若业务线程无视Ready高速调用onNext,库可能排队大量待发送消息,造成堆内存增长。
应用背压还需要:
- 有界业务队列。
- 单连接最大在途消息数。
- 消费速率指标和最老消息年龄。
- 超限时暂停上游、拒绝或落盘,而不是无限缓存。
- 取消时停止生产任务,避免RPC结束后后台仍计算。
九、Protobuf兼容演进
9.1 必须遵守
- 已发布字段编号不能复用。
- 删除字段后用
reserved保留编号和名称。 - 新增字段时旧客户端会忽略未知字段;新服务端要接受旧客户端缺省值。
- 数值类型互换要确认wire type和语义,不能只看Java可转换。
- Enum零值必须表达UNKNOWN/UNSPECIFIED,兼容未识别值。
- 金额不要用
double;使用最小货币单位整数或明确decimal消息。 - 时间统一使用标准Timestamp或明确时区和精度。
message ReserveRequest {
reserved 4, 5;
reserved "legacy_user_id";
string request_id = 1;
string sku = 2;
int32 quantity = 3;
string tenant_id = 6;
}9.2 缺省值与字段存在性
Proto3标量缺省值可能让“未传数量”和“明确传0”难以区分。需要存在性语义时使用支持presence的字段方式、optional或包装/oneof,并结合目标Protobuf版本验证生成代码。
9.3 oneof
oneof表达多个字段同一时刻只应设置一个,适合支付方式等互斥结构。演进时仍要考虑旧客户端遇到新分支后的行为,不能把未知分支默认为某个危险操作。
十、Deadline、取消与结果未知
Deadline表达调用最晚允许完成的时间点;跨进程传播时框架通常按剩余时长处理,减少机器时钟偏差影响。服务端应周期检查取消状态,停止无意义计算。
flowchart TD
A["上游设置总Deadline 800ms"] --> B["排队与网络已消耗120ms"]
B --> C["下游只获得约680ms剩余预算"]
C --> D["检查是否在剩余预算内完成"]
D --> E["成功返回OK;超时返回Deadline错误"]
E --> F["写操作超时后按request_id查询事实"]DEADLINE_EXCEEDED只表示调用方未按时取得成功结果,不证明服务端没有执行。数据库提交后响应丢失时,客户端仍会超时。因此写接口必须包含稳定request_id,服务端在本地事务中以唯一键记录结果,重试复用第一次结果。
取消也是协作式的:客户端取消会发送RST_STREAM等信号,但服务端业务代码、数据库驱动或外部HTTP调用未必立即停止。业务代码要检查Context取消并给下游设置更短剩余超时。
十一、状态码怎样使用
| Status | 常见含义 | 是否重试 |
|---|---|---|
| INVALID_ARGUMENT | 参数不合法 | 修正参数,不重试 |
| NOT_FOUND | 资源不存在 | 通常不重试 |
| ALREADY_EXISTS | 唯一资源已存在 | 查询原结果或按幂等处理 |
| FAILED_PRECONDITION | 当前状态不允许 | 状态变化后才可能重试 |
| ABORTED | 并发冲突或事务中止 | 可在幂等和退避下重试 |
| RESOURCE_EXHAUSTED | 配额、消息或资源超限 | 降速、缩小请求,避免立即风暴 |
| UNAVAILABLE | 服务暂时不可用或连接失败 | 幂等且有预算时重试 |
| DEADLINE_EXCEEDED | 超过Deadline | 结果可能未知,先查事实 |
| INTERNAL | 服务内部不变量错误 | 通常先修复,不盲目重试 |
| UNKNOWN | 未正确映射的未知错误 | 先定位,不能默认安全重试 |
不要把所有异常都映射成UNKNOWN,也不要把业务“库存不足”伪装成UNAVAILABLE诱导重试。稳定业务拒绝可用明确状态加结构化错误详情,敏感堆栈不要返回客户端。
十二、服务发现与负载均衡
flowchart TD
A["Target例如dns:///inventory"] --> B["NameResolver生成地址快照"]
B --> C["LoadBalancer维护Subchannel"]
C --> D["Picker选择READY Subchannel"]
D --> E["按pick_first或round_robin策略"]
E --> F["RPC创建HTTP/2 Stream"]12.1 为什么只访问ClusterIP会倾斜
若NameResolver只得到一个Kubernetes ClusterIP,gRPC可能建立少量到VIP的HTTP/2连接;每条连接由Kubernetes数据面固定到一个Pod,大量RPC多路复用后只打到少数Pod。扩容不会迁移旧连接。
解决方向:Headless Service返回Pod地址、gRPC自定义Resolver、xDS/Service Mesh提供Endpoint、增加受控Subchannel和连接生命周期。详见Kubernetes连接级负载。
12.2 Wait-for-Ready
默认Fail-fast调用在Channel暂不可用时可能立即失败;Wait-for-Ready允许调用在Deadline内等待Channel恢复。它不是无限排队开关:没有Deadline会积压调用并占内存,写请求仍需幂等。
十三、重试与Hedging
gRPC重试可由服务配置指定可重试状态、最大尝试和退避。一次逻辑RPC可能包含多个Transport Stream尝试。透明重试和显式策略受库版本与提交边界限制,不能假设任何错误都会自动重试。
Hedging是在前一次尚未失败时延迟发起并行尝试,以降低尾延迟,但会增加实际负载和重复执行概率。只适合只读或严格幂等操作,并要限制并发、预算和触发状态。
客户端SDK、Service Mesh和业务代码不能各自独立重试。若每层最多3次,最坏会形成27次尝试。统一由最了解错误语义的一层负责,并记录逻辑请求数与物理Attempt数。
十四、Keepalive、GOAWAY和连接生命周期
- HTTP/2 PING可探测空闲连接是否仍可用。
- Keepalive过于频繁会增加服务器和网络负担,服务端可能发送GOAWAY或关闭滥用客户端。
- GOAWAY通常表示该连接不再接受新的更大Stream ID;已接受Stream可能继续,客户端应在新连接上发起后续RPC。
- 最大连接年龄可让负载逐步再平衡和证书轮换,但设置过短会制造握手风暴。
- TCP Keepalive与HTTP/2 PING是不同层次。
十五、TLS、认证和授权
生产应验证服务端证书名称和信任链,不使用usePlaintext()跨不可信网络。mTLS可让服务端验证客户端工作负载证书;Token可放入Metadata,但必须走TLS且避免日志输出。
拦截器适合:
- 注入和校验认证信息。
- trace上下文传播。
- 统一Deadline上限。
- 记录方法级指标。
拦截器不应把业务事务隐藏在网络层,也不要记录完整请求中的密码、身份证和Token。
十六、Health、Reflection与探针
标准gRPC Health服务可按服务名返回SERVING状态,适合Kubernetes gRPC Probe和负载检查。它应反映是否适合接流量,不要因为一个非关键依赖抖动就让全部实例摘除。
Server Reflection允许工具运行时查询服务描述,便于grpcurl调试。生产是否开启要结合暴露面和权限;关闭Reflection不等于接口安全,TLS、认证和网络策略仍必须存在。
十七、Java最小Demo
服务端实现:
public final class InventoryServiceImpl
extends InventoryServiceGrpc.InventoryServiceImplBase {
@Override
public void reserve(ReserveRequest request,
io.grpc.stub.StreamObserver<ReserveReply> observer) {
if (request.getRequestId().isEmpty() || request.getQuantity() <= 0) {
observer.onError(io.grpc.Status.INVALID_ARGUMENT
.withDescription("request_id与quantity不合法")
.asRuntimeException());
return;
}
// 生产代码:用request_id唯一键在同一本地事务中扣减并保存结果。
ReserveReply reply = ReserveReply.newBuilder()
.setReservationId("R-" + request.getRequestId())
.setStatus(ReserveStatus.RESERVED)
.build();
observer.onNext(reply);
observer.onCompleted();
}
}客户端必须复用Channel并设置Deadline:
ManagedChannel channel = ManagedChannelBuilder
.forAddress("inventory", 9090)
.usePlaintext() // 仅本地演示;生产配置TLS或Mesh mTLS
.build();
InventoryServiceGrpc.InventoryServiceBlockingStub stub =
InventoryServiceGrpc.newBlockingStub(channel)
.withDeadlineAfter(800, TimeUnit.MILLISECONDS);
ReserveReply reply = stub.reserve(ReserveRequest.newBuilder()
.setRequestId("order-20260718-001")
.setSku("sku-1001")
.setQuantity(1)
.build());
channel.shutdown();
channel.awaitTermination(5, TimeUnit.SECONDS);JDK 8/Boot 2.7与Java 17+/Boot 3都可使用grpc-java,但必须选择兼容的grpc-java、Protobuf插件和Netty版本。Boot 3的Jakarta迁移不改变HTTP/2与Protobuf协议;Java 17+应使用当前受支持依赖和更完整的Micrometer/OTel观测,不能把旧依赖坐标原样复制。
十八、商业场景:医疗采集双向流
采集Agent与平台建立双向流:Agent上传批次进度,平台下发暂停、恢复和重试指令。生产协议需要:
- 每条消息含
agent_id、session_id、单调sequence和event_id。 - 平台保存已确认序号,断线后Agent从最后确认位置续传。
- 重复event_id由唯一约束吸收,不能把连接内Exactly Once当持久保证。
- 使用
isReady和有界磁盘队列控制背压。 - 心跳只证明连接活性,业务健康还要看采集进度时间。
- 证书绑定Agent身份,敏感医疗数据传输启用TLS并脱敏日志。
- 服务端发布时发送GOAWAY并让客户端抖动重连,避免同时风暴。
十九、生产Runbook
19.1 UNAVAILABLE突然升高
- 按客户端、服务端、Endpoint、Node和Zone拆分。
- 查看NameResolver地址、Subchannel状态和Channel状态转换。
- 区分连接拒绝、TLS失败、GOAWAY、RST和网络超时。
- 检查服务发现是否陈旧、Pod是否终止、Mesh EDS是否NACK。
- 查看重试Attempt,防止错误率下降但实际流量暴增。
19.2 DEADLINE_EXCEEDED升高
- 查看客户端Deadline分布,不只看服务端耗时。
- 拆分排队、连接、网络、服务端队列、数据库和响应序列化。
- 检查Deadline是否逐层传播,是否某层重新给满额时间。
- 对写请求按request_id查询事实,不能直接重试。
- 检查客户端事件循环是否被阻塞。
19.3 RESOURCE_EXHAUSTED
- 查看错误描述是消息过大、并发流、配额还是内存限制。
- 对照客户端和服务端最大入站消息配置。
- 不要只把上限调大;大消息应分块、对象存储或流式处理。
- 检查未尊重
isReady造成的发送队列堆积。
19.4 流式RPC卡住
- 查看双方是否仍有连接和Stream,是否收到半关闭。
- 检查HTTP/2连接/Stream窗口和onReady回调。
- 检查业务消费者是否阻塞、队列是否满。
- 检查是否漏调
onCompleted或取消后生产线程仍运行。 - 使用消息序号确认最后发送、接收和持久确认位置。
19.5 扩容后流量仍集中
- 查看Resolver是否只有ClusterIP还是全部Pod Endpoint。
- 查看Channel、Subchannel和HTTP/2连接数量。
- 检查pick_first、round_robin和Mesh LB实际配置。
- 区分RPC数和连接数,新Pod只会获得新连接。
- 有界调整连接寿命,避免同时重连。
二十、常见误区
| 误区 | 后果 |
|---|---|
| 每次RPC创建Channel | 握手、线程和连接资源浪费 |
| gRPC像本地方法一样可靠 | 忽略网络失败和结果未知 |
| Deadline等于服务端回滚 | 下游可能已提交 |
| 流式调用等于MQ | 断线后消息和进度丢失 |
无视isReady连续onNext | 待发送队列导致OOM |
| Protobuf删除字段后复用编号 | 新旧版本错读字段 |
| 只连ClusterIP却期待请求均衡 | HTTP/2长连接固定少量Pod |
| 所有层都开启重试 | 重试乘法压垮服务 |
| Keepalive越短越可靠 | PING风暴和服务端GOAWAY |
二十一、面试主线
回答时沿“Proto契约与代码生成 → Channel/Resolver/LB → HTTP/2连接与Stream → 服务端调用 → Trailers状态 → Deadline与结果未知 → 流控背压 → 长连接负载 → 重试和Runbook”展开,并明确Protobuf、HTTP/2和gRPC各自职责。
标准回答见gRPC面试题。
二十二、关联知识点
本章小结
gRPC降低了接口定义和编解码成本,却没有消除分布式不确定性。真正掌握它,需要把一次Stub调用还原成名称解析、Subchannel选择、HTTP/2 Stream、Protobuf消息、服务端事务和Trailers,并能处理Deadline、流控、长连接倾斜、协议演进和失败恢复。
