主题
服务通信:REST、gRPC 与序列化
本文是微服务系统学习系列的 L2 核心篇。前置:28. 注册发现与配置中心。 学完可以配合面试题食用:04-service-communication-rpc-rest-grpc-protobuf-thrift-json
微服务之间怎么说话
单体应用里,模块之间是方法调用:orderService.createOrder(param),同进程、零序列化开销、毫秒级返回。一拆成微服务,Service A 要调用 Service B,就变成了跨网络调用——IP 端口、序列化、超时、重试、版本兼容,全成了新问题。
选通信方式就是选序列化协议 + 传输协议 + 调用模式的组合。三个主流选择:
| 方式 | 序列化 | 传输 | 典型场景 |
|---|---|---|---|
| REST | JSON | HTTP/1.1 | 外部 API、调试友好 |
| gRPC | Protobuf | HTTP/2 | 内部服务间高性能调用 |
| GraphQL | JSON (自定义查询) | HTTP | BFF 层按需取数 |
内部服务间通信,gRPC 已是大厂主流。不是因为它"新",而是因为性能差距在两个数量级。
gRPC 核心链路:从 IDL 到四类流
gRPC 的流程是:写 .proto 文件(IDL)→ 编译生成客户端/服务端桩代码 → 用 HTTP/2 传输二进制 Protobuf 数据。
Protobuf 序列化后的体积是 JSON 的 1/5 到 1/10,反序列化速度是 JSON 的 5-10 倍。微服务调用链上每个请求省几微秒,链路一长,差距就滚雪球。
HTTP/2 带来的好处:连接多路复用(一个 TCP 连接承载多个请求),头部压缩(HPACK),服务端推送。对微服务来说,连接复用意义最大——不用为每个 RPC 建 TCP 连接,减少连接数和延迟。
gRPC 支持四种流模式,每种对应一个具体场景:
- Unary(一元):客户端发一个请求,服务端回一个响应。最常用,对标普通 REST GET/POST。
- Server Streaming(服务端流):客户端发一个请求,服务端持续返回多条数据。场景:订阅实时价格、日志推送。
- Client Streaming(客户端流):客户端连续发多条,服务端汇总后回一条。场景:批量上传、数据聚合。
- Bidirectional Streaming(双向流):双方同时发,全双工。场景:实时聊天、游戏状态同步。
序列化对比:JSON 为什么不适合内部 RPC
JSON 的优点是人类可读,缺点也很明显:
- 体积大:字段名重复传输,
{"orderId": "12345", "status": "PAID"}每次都要序列化 key 字符串。 - 解析慢:反射解析,动态类型判断,没有固定 schema 就无法做偏移量计算。
- 无类型约束:
"12345"是字符串还是数字由上下文决定,客户端和服务端可能理解不一致。
Protobuf 用二进制格式 + 预定义 schema 解决这些问题:
- 字段用编号(tag)标识,不传字段名。
field 1就是orderId,双方约定好。 - 反序列化时直接按编号绑字段偏移,零反射。
varint编码对小整数极其省空间。
Avro 是另一种选择,常用于 Kafka 消息序列化,但需要 writer schema 和 reader schema 两套,动态演化能力比 Protobuf 强,也意味着更复杂。
一句话结论:对外暴露 API 用 JSON(到处能消费),内部服务间通信用 Protobuf(性能压倒性胜利)。
接口契约管理:IDL 优先与版本兼容
微服务各自独立部署,通信协议一旦改了,调用方不兼容就崩了。所以接口契约是第一优先级。
IDL 优先的意思是:先写 .proto 文件,再生成代码,而不是先写代码再导出接口定义。好处是契约驱动开发,双方都遵守同一个协议。
Protobuf 的版本兼容规则:
- 字段编号不可改:删除字段要
reserved保留编号,防止未来被误用。 - 新增字段用新编号,不修改旧编号:客户端用旧版本 proto 反序列化时,未知字段会跳过,老版本不受影响。
- 不改变字段类型:
int32改成int64会破坏兼容性,因为 varint 编码长度变了。 - Option 和 Oneof 的修改要谨慎:
oneof的字段编号也不能重复使用。
protobuf
syntax = "proto3";
package order;
service OrderService {
rpc CreateOrder(CreateOrderRequest) returns (CreateOrderResponse);
}
message CreateOrderRequest {
string user_id = 1;
repeated string item_ids = 2;
int64 total_cents = 3;
reserved 4; // 曾经是 coupon_id,已废弃
// 新增:version 5 是来源渠道
string channel = 5;
}
message CreateOrderResponse {
string order_id = 1;
int32 status = 2;
}每条规则都有血的教训:谁改过字段编号,谁半夜被 oncall 电话叫醒过。
超时与重试的传播
微服务调用链上,A 调 B,B 调 C。如果 C 慢,B 在等 C,A 在等 B——整个线程池被占满,就是雪崩的开端。
Deadline 传播:gRPC 支持在请求中携带 deadline(截止时间),每个节点收到请求后,把 deadline 减去已经消耗的时间,再传给下游。
java
// 周超时:80ms 内没返回就报错
OrderServiceGrpc.OrderServiceBlockingStub stub = OrderServiceGrpc.newBlockingStub(channel);
OrderOuterClass.CreateOrderResponse response = stub
.withDeadline(Deadline.after(80, TimeUnit.MILLISECONDS))
.createOrder(request);Deadline 传递的做法:A 设 deadline 80ms,A→B 路上花了 10ms,B 还剩 70ms,B→C 花了 10ms,C 还剩 60ms。如果 C 执行超过 60ms,C 直接返回 DEADLINE_EXCEEDED,不用再等。
重试与退避:gRPC 内置了 retry 策略,但要注意重试预算——不控制重试次数的结果就是重试风暴。A 重试 3 次 × B 重试 3 次 × C 重试 3 次 = 27 倍流量。
yaml
# gRPC 重试配置示例
retryPolicy:
maxAttempts: 3
initialBackoff: 0.1s
maxBackoff: 2s
backoffMultiplier: 2.0
retryableStatusCodes: [UNAVAILABLE]只对 UNAVAILABLE(服务不可用)重试,对 DEADLINE_EXCEEDED 和 INVALID_ARGUMENT 不重试——前者是系统过载信号,重试只会加剧;后者是参数问题,重试一万次也一样。
动手实操:Protobuf + Spring Boot gRPC
以下是一个完整的 Spring Boot gRPC 示例,包含服务端、客户端以及 deadline 传播。
1. 新增依赖(build.gradle)
groovy
implementation 'net.devh:grpc-server-spring-boot-starter:3.0.0.RELEASE'
implementation 'net.devh:grpc-client-spring-boot-starter:3.0.0.RELEASE'2. 定义 .proto 文件
protobuf
syntax = "proto3";
package inventory;
service InventoryService {
rpc DeductStock(DeductRequest) returns (DeductResponse);
rpc SubscribeStock(StockQuery) returns (stream StockUpdate); // 服务端流
}
message DeductRequest {
string sku_id = 1;
int32 quantity = 2;
}
message DeductResponse {
bool success = 1;
int32 remaining = 2;
}
message StockQuery {
string sku_id = 1;
}
message StockUpdate {
string sku_id = 1;
int32 current_stock = 2;
string change_type = 3; // ORDER / RETURN / ADJUSTMENT
}3. 服务端实现
java
@GrpcService
public class InventoryGrpcService extends InventoryServiceGrpc.InventoryServiceImplBase {
@Override
public void deductStock(DeductRequest request, StreamObserver<DeductResponse> responseObserver) {
// 从 Context 中读取 deadline
Context context = Context.current();
if (context.getDeadline() != null && context.getDeadline().isExpired()) {
responseObserver.onError(Status.DEADLINE_EXCEEDED.asRuntimeException());
return;
}
// 模拟扣库存逻辑
boolean success = doDeduct(request.getSkuId(), request.getQuantity());
DeductResponse reply = DeductResponse.newBuilder()
.setSuccess(success)
.setRemaining(success ? getRemaining(request.getSkuId()) : 0)
.build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
}
@Override
public void subscribeStock(StockQuery request, StreamObserver<StockUpdate> responseObserver) {
// 服务端流:每 5 秒推送一次库存变化
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(() -> {
StockUpdate update = StockUpdate.newBuilder()
.setSkuId(request.getSkuId())
.setCurrentStock(getRemaining(request.getSkuId()))
.setChangeType("ADJUSTMENT")
.build();
responseObserver.onNext(update);
}, 0, 5, TimeUnit.SECONDS);
// 生产环境别忘了用 Context.currentDeadline() 关闭 scheduler
}
}4. 客户端调用(含 deadline 传播)
java
@Component
public class InventoryClient {
@GrpcClient("inventory-service")
private InventoryServiceGrpc.InventoryServiceBlockingStub blockingStub;
public boolean deduct(String skuId, int quantity) {
DeductRequest request = DeductRequest.newBuilder()
.setSkuId(skuId)
.setQuantity(quantity)
.build();
try {
DeductResponse response = blockingStub
.withDeadline(Deadline.after(50, TimeUnit.MILLISECONDS))
.deductStock(request);
return response.getSuccess();
} catch (StatusRuntimeException e) {
if (e.getStatus().getCode() == Status.Code.DEADLINE_EXCEEDED) {
log.warn("库存扣减超时,sku={}", skuId);
return false; // 降级:返回 false,走补偿逻辑
}
throw e;
}
}
}常见误区与小结
- REST 和 gRPC 不是二选一:外部 API 用 REST,内部服务间用 gRPC,两者可以共存。BFF 层做转换。
- Protobuf 字段编号不能改:你以为删字段用新编号就行,但旧客户端还在用老编号,序列化就崩了。必须
reserved。 - gRPC 不是 HTTP/2 的自动性能加速器:HTTP/2 多路复用需要长连接开得好,否则初始化连接开销还不如短连接 HTTP/1.1。
- deadline 不加就是灾难:没有超时的调用链,一个慢服务能拖死全部。每个 gRPC 调用必须有 deadline。
- 重试策略不是越强越好:重试加退避,只对
UNAVAILABLE生效,DEADLINE_EXCEEDED重试会放大负载。
小结:服务通信是微服务架构的"毛细血管"——选对协议、管好契约、定好超时重试,链路才能稳定。下一篇讲限流、熔断与降级,三道防线拦住雪崩。
参考
参考:gRPC 官方文档 grpc.io/docs/、《Designing Data-Intensive Applications》第 4 章编码与演化、Protobuf Language Guide protobuf.dev