Skip to content

服务通信:REST、gRPC 与序列化

本文是微服务系统学习系列的 L2 核心篇。前置:28. 注册发现与配置中心。 学完可以配合面试题食用:04-service-communication-rpc-rest-grpc-protobuf-thrift-json

微服务之间怎么说话

单体应用里,模块之间是方法调用:orderService.createOrder(param),同进程、零序列化开销、毫秒级返回。一拆成微服务,Service A 要调用 Service B,就变成了跨网络调用——IP 端口、序列化、超时、重试、版本兼容,全成了新问题。

选通信方式就是选序列化协议 + 传输协议 + 调用模式的组合。三个主流选择:

方式序列化传输典型场景
RESTJSONHTTP/1.1外部 API、调试友好
gRPCProtobufHTTP/2内部服务间高性能调用
GraphQLJSON (自定义查询)HTTPBFF 层按需取数

内部服务间通信,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 的优点是人类可读,缺点也很明显:

  1. 体积大:字段名重复传输,{"orderId": "12345", "status": "PAID"} 每次都要序列化 key 字符串。
  2. 解析慢:反射解析,动态类型判断,没有固定 schema 就无法做偏移量计算。
  3. 无类型约束"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_EXCEEDEDINVALID_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

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1