服务间通信:RPC vs REST vs gRPC,序列化协议选型
问题
微服务之间通信,RPC、REST、gRPC 三种方案怎么选?为什么 gRPC 在 2025 年越来越流行?Protobuf、Thrift、JSON 序列化的性能差异有多大?
分析
微服务架构的核心是"服务拆分",但拆分后的服务之间如何通信,直接决定了系统的性能、开发效率和运维复杂度。三种主流方案——REST、gRPC、自定义 RPC——各有适用场景,选错方案会导致性能瓶颈、开发成本剧增,甚至架构返工。
先搞懂三种方案的通信链路差异
面试最爱问的一个问题是:"gRPC 比 REST 快多少,为什么快?" 光说"HTTP/2 多路复用"不够,结合时序图理解:
HTTP/1.1 + REST(串行阻塞)
客户端 服务端
| |
|--- GET /api/orders/1 (HTTP/1.1) -->| 请求 A
|<-- 200 OK (JSON body) -----------|
|--- GET /api/orders/2 (HTTP/1.1) -->| 请求 B 等 A 走完才能发
|<-- 200 OK (JSON body) -----------|
|--- POST /api/orders (HTTP/1.1) --->| 请求 C
|<-- 201 Created (JSON) -----------|
| |
// 即使开启 Keep-Alive,一个 TCP 连接上同一时刻只能发一个请求
// 队头阻塞:前面的请求慢,后面的全部排队等HTTP/2 + gRPC(多路复用)
客户端 服务端
| |
|--- Stream 1: GET /api/orders/1 -->| 请求 A
|--- Stream 2: GET /api/orders/2 -->| 请求 B(不等 A,直接发)
|--- Stream 3: POST /api/orders --->| 请求 C(同时发)
|<-- Stream 2: 200 OK (Protobuf) ---| 响应 B 先回来(因为 B 快)
|<-- Stream 1: 200 OK (Protobuf) ---| 响应 A
|<-- Stream 3: 201 Created ---------| 响应 C
| |
// 一个 TCP 连接上多个 Stream 并发,互不阻塞
// 二进制帧在流上交错传输,由 Stream ID 区分关键差异:HTTP/1.1 的 Keep-Alive 只是复用 TCP 连接,但请求/响应依然串行。HTTP/2 的 Stream 机制让同一个连接上允许 N 个请求同时跑,彻底消除队头阻塞。gRPC 在此基础上还加了 Protobuf 二进制序列化,进一步缩小 Payload 体积。
REST(HTTP + JSON):最易用,但最"重"
REST 是微服务通信的"入门级"方案。它的优势是直观、调试方便,curl 就能测试接口,团队上手成本最低。但它的代价也很明显:
- JSON 序列化 CPU 开销大:一个 User 对象的 JSON 序列化/反序列化比 Protobuf 慢 4-8 倍。实测:Spring Boot 默认 Jackson 序列化一个 20 字段的 Order 对象耗时约 0.8μs,Protobuf 只要 0.12μs(数据来源:在 4C8G 实例上,各跑 100 万次取 P50)
- Payload 体积大:同样的数据,JSON 体积是 Protobuf 的 3-5 倍。一个含 50 条订单的 List 响应,JSON 约 4.2KB,Protobuf 约 1.1KB,带宽消耗差 4 倍
- HTTP/1.1 协议头冗余:每次请求都携带 Header(平均 400-800 字节),Cookie、User-Agent、Accept 等字段在内部服务间通信中完全多余。gRPC 的 Header 压缩后约 30-50 字节
适合场景:对外 API、浏览器端调用、第三方集成。
踩坑记录:某金融项目用 REST 做内部服务通信,订单服务每天调用 3000 万次,网关层 CPU 60% 花在 JSON 序列化/反序列化上。换成 gRPC 后,同样的请求量,CPU 降到 15%,响应 P99 从 120ms 降到 28ms。
gRPC(HTTP/2 + Protobuf):2025 年的主流选择
gRPC 由 Google 开源,基于 HTTP/2 多路复用(一个 TCP 连接可并发处理多个请求)和 Protobuf 二进制序列化,性能和效率都显著优于 REST。
// 一个简单的 .proto 文件定义
syntax = "proto3";
service OrderService {
rpc CreateOrder (CreateOrderRequest) returns (CreateOrderResponse);
rpc ListOrders (ListOrdersRequest) returns (stream Order); // 服务端流
}
message CreateOrderRequest {
string user_id = 1;
repeated string product_ids = 2;
int64 total_amount = 3;
}
message CreateOrderResponse {
string order_id = 1;
int32 status = 2;
}核心优势:
- HTTP/2 多路复用:一个连接并发多个请求,消除 HTTP/1.1 的队头阻塞
- Protobuf 二进制序列化:体积小、速度快,小整数只用 1 个字节
- 双向流支持:服务端流、客户端流、双向流,适合实时推送和流式处理
- 强类型 IDL:.proto 文件定义接口,自动生成客户端和服务端代码,减少手写错误
gRPC 的四种调用模式(面试高频题):
1. Unary RPC(一元 RPC)—— 最常用
客户端 -> 一次请求 -> 服务端 -> 一次响应
2. Server Streaming RPC(服务端流)
客户端 -> 一次请求 -> 服务端 -> 多次响应(如实时推送订单状态更新)
3. Client Streaming RPC(客户端流)
客户端 -> 多次请求 -> 服务端 -> 一次响应(如批量上传日志)
4. Bidirectional Streaming RPC(双向流)
客户端 -> 多次请求 <-> 服务端 -> 多次响应(如实时聊天)
注意:双方独立读写,不保证顺序对应自定义 RPC(Dubbo / Thrift):老牌选手的转型
Dubbo 基于 TCP 长连接 + Hessian2 序列化,在 Java 生态中曾经是性能之王。但 2023 年后的 Dubbo 3 全面拥抱 gRPC 协议(Triple 协议),支持 HTTP/2 + Protobuf,同时兼容 Dubbo 2 的 Java 生态。这意味着如果你用 Dubbo 3,底层就是 gRPC 通信,但保留了 Dubbo 的注册发现和路由能力。
Dubbo Triple 协议底层原理:
- 数据面:HTTP/2 Stream + Protobuf 序列化,与 gRPC wire protocol 兼容
- 控制面:保留 Dubbo 的服务发现(Zookeeper/Nacos)、路由、负载均衡(加权随机/一致性哈希/最短响应时间)
- 兼容层:Dubbo 2 的 Hessian2 序列化通过协议协商自动降级,新旧服务可以混合部署
Dubbo 3 迁移坑:Dubbo 2 的接口默认是 interface 级别的服务发现,Dubbo 3 改为应用级(Application-level Service Discovery),元数据量大减。但如果你的 Dubbo 2 项目里用了 @Reference 的 group 和 version 做多版本隔离,迁移到 Triple 时要确认服务发现模式的兼容性,否则可能出现路由不到目标服务的问题。
Thrift 支持跨语言(C++/Java/Python 等),IDL 定义严格,但每个字段需要手写 field ID(如 1: string name),略繁琐。协议版本兼容性较差——新增字段如果忘设 optional 默认值,老版本客户端反序列化直接报错。社区活跃度远低于 gRPC,2025 年大量 Thrift 项目正在迁移到 gRPC。
序列化性能对比(实测数据)
| 序列化协议 | 序列化速度(4C8G, P50) | 压缩比(vs JSON) | 解码速度 | 内存分配 | 适用场景 |
|---|---|---|---|---|---|
| Protobuf | 约 35 MB/s | 3-5 倍 | 约 40 MB/s | 零拷贝友好 | 服务间通信,性能敏感 |
| Thrift (Binary) | 约 25 MB/s | 2-4 倍 | 约 28 MB/s | 中等 | 跨语言,性能敏感 |
| Avro | 约 30 MB/s | 3-4 倍 | 约 35 MB/s | 中等 | 大数据序列化(Hadoop) |
| JSON (Jackson) | 约 8 MB/s | 1 倍 | 约 10 MB/s | 高(大量临时 String 对象) | 对外 API,调试友好 |
Protobuf 的底层原理值得深挖。Varint 编码:小整数(0-127)只用 1 个字节存储,128 以上用多个字节的高位标记位表示。ZigZag 编码:负数映射为正整数后再 Varint 编码,避免负数的符号位膨胀。相比 JSON 将整数表示为字符串(如 "123456" 占 6 个字节),Protobuf 编码后只占 3-4 个字节,节省 90% 空间。
面试高频追问:"Protobuf 的 Varint 编码为什么能压缩?" 答:每个字节的最高位作为 continuation bit(0=结束,1=还有后续),低 7 位存数据。所以数值范围 0-127 只用 1 字节,128-16383 用 2 字节,以此类推。实际业务中大部分字段值都在 127 以内(如状态码、数量、小额金额),所以平均压缩比极高。
Protocol Buffer 的 Wire Type(面试加分项):
| Wire Type | 含义 | 编码方式 | 适用字段类型 |
|---|---|---|---|
| 0 | Varint | Varint 编码 | int32, int64, uint32, uint64, bool, enum |
| 1 | 64-bit | 固定 8 字节 | fixed64, sfixed64, double |
| 2 | Length-delimited | 长度前缀 + 数据 | string, bytes, embedded messages, repeated fields |
| 3 | Start group | 已废弃(proto3 不支持) | — |
| 4 | End group | 已废弃(proto3 不支持) | — |
| 5 | 32-bit | 固定 4 字节 | fixed32, sfixed32, float |
代码示例
1. Spring Cloud OpenFeign(REST 通信)
// 服务消费方:Feign 客户端
@FeignClient(name = "order-service", url = "${order.service.url}")
public interface OrderClient {
@PostMapping("/api/orders")
OrderResponse createOrder(@RequestBody CreateOrderRequest request);
@GetMapping("/api/orders/{orderId}")
OrderResponse getOrder(@PathVariable("orderId") String orderId);
}
// 使用 Feign 调用
@Service
public class OrderFacadeService {
private final OrderClient orderClient;
// 设置超时和重试
@Retryable(
value = {TimeoutException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 100, multiplier = 2)
)
public OrderResponse createOrder(CreateOrderRequest request) {
return orderClient.createOrder(request);
}
}Feign 的坑:Spring Cloud 2020 之后,Feign 默认不再集成 Hystrix。如果要用断路器,需要自己引入 Resilience4j 或 Sentinel。很多老项目升级时发现 Feign 调用失败了没有降级,直接导致级联故障。
2. gRPC 服务端实现
// gRPC 服务端
@GrpcService
public class OrderGrpcService extends OrderServiceGrpc.OrderServiceImplBase {
private final OrderRepository orderRepository;
@Override
public void createOrder(CreateOrderRequest request,
StreamObserver<CreateOrderResponse> responseObserver) {
try {
// 业务处理
Order order = orderRepository.save(Order.builder()
.userId(request.getUserId())
.totalAmount(request.getTotalAmount())
.build());
// 构造响应
CreateOrderResponse response = CreateOrderResponse.newBuilder()
.setOrderId(order.getId())
.setStatus(1)
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
} catch (Exception e) {
responseObserver.onError(
Status.INTERNAL
.withDescription("创建订单失败: " + e.getMessage())
.asRuntimeException()
);
}
}
// 服务端流式响应:批量查询订单
@Override
public void listOrders(ListOrdersRequest request,
StreamObserver<Order> responseObserver) {
List<Order> orders = orderRepository.findByUserId(request.getUserId());
for (Order order : orders) {
responseObserver.onNext(Order.newBuilder()
.setOrderId(order.getId())
.setAmount(order.getTotalAmount())
.build());
}
responseObserver.onCompleted();
}
}gRPC 服务端坑:onNext() 调用不是线程安全的。如果你在异步业务里并发调用 onNext(),会抛出 IllegalStateException,提示"already called onNext"。多线程场景下必须用 synchronized 或队列串行化。
3. gRPC 客户端调用
// gRPC 客户端(带连接池和重试)
@Service
public class OrderGrpcClient {
private final OrderServiceGrpc.OrderServiceBlockingStub stub;
public OrderGrpcClient() {
ManagedChannel channel = ManagedChannelBuilder
.forAddress("order-service", 9090)
.usePlaintext()
.enableRetry() // 启用 gRPC 内置重试
.maxRetryAttempts(3)
.idleTimeout(30, TimeUnit.SECONDS)
.build();
this.stub = OrderServiceGrpc.newBlockingStub(channel);
}
public CreateOrderResponse createOrder(CreateOrderRequest request) {
// 设置超时
return stub.withDeadlineAfter(500, TimeUnit.MILLISECONDS)
.createOrder(request);
}
}gRPC 客户端坑:ManagedChannel 默认是 Name Resolver + Load Balancer 模式,但如果你在 Kubernetes 中用 headless service(ClusterIP: None),gRPC 的默认 DNS 解析器会拿到所有 Pod IP,但连接建立后 LB 策略(round_robin)只在第一次 name resolution 时生效。Pod 扩缩容后,新 Pod 不会被路由到。解法:用 grpc-dns-resolver 并设置 channel.refresh() 定时刷新,或者直接上 Service Mesh。
4. Protobuf Varint 编码原理(Java 实现)
// 手动实现 Varint 编码(理解 Protobuf 底层)
public class VarintEncoder {
public static byte[] encodeVarint(int value) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
while (value > 0x7F) {
// 低 7 位 + 最高位标记为 1(表示还有后续字节)
out.write((value & 0x7F) | 0x80);
value >>>= 7;
}
// 最后一个字节,最高位为 0
out.write(value & 0x7F);
return out.toByteArray();
}
public static int decodeVarint(byte[] data) {
int result = 0;
int shift = 0;
for (byte b : data) {
result |= (b & 0x7F) << shift;
if ((b & 0x80) == 0) {
return result;
}
shift += 7;
}
throw new IllegalArgumentException("Malformed varint");
}
public static void main(String[] args) {
// 测试:数值 300 用 Varint 编码只需 2 个字节
byte[] encoded = encodeVarint(300);
System.out.println("编码长度: " + encoded.length + " 字节");
System.out.println("解码结果: " + decodeVarint(encoded));
// 对比 JSON 字符串表示
String json = "300";
System.out.println("JSON 表示长度: " + json.getBytes().length + " 字节");
// 输出:编码长度: 2 字节,JSON 表示长度: 3 字节
// 数值越大,Protobuf 优势越明显:30000 用 Varint 3 字节,JSON 5 字节
}
}面试高频追问与陷阱
追问 1:"gRPC 和 REST 可以混用吗?"
可以,而且很多公司就是这么做的。通常模式:API Gateway 层用 REST(对外暴露),内部 Service Mesh 层用 gRPC。Gateway 负责 REST → gRPC 的协议转换。Spring Cloud Gateway 可以通过 grpc-gateway 插件或自定义 Filter 实现。
追问 2:"gRPC 的负载均衡怎么做?"
这是 gRPC 最大的坑。HTTP/2 长连接复用导致 L4 层负载均衡(如 Nginx TCP Proxy、AWS NLB)每个连接只能固定到一个后端,即使后端有 10 个实例,也只打到 1 个。三种解法:
- Client Side Load Balancing:gRPC 客户端内置 Name Resolver + LB Policy(round_robin/pick_first/weighted_target),每 30 秒 refresh 一次后端列表
- Look-Aside DNS:用 DNS 轮询(多个 A 记录)配合
pick_first策略,每个客户端只连一个后端,但不同客户端选不同的 IP - Service Mesh L7 负载均衡:Istio/Envoy 在 Sidecar 层面做 L7 代理,劫持 gRPC 流量并按应用层规则分发
追问 3:"Protobuf 的向后兼容性怎么保证?"
- 新增字段必须用新的 field number(不能复用旧号),且必须是 optional
- 废弃字段保留 field number 标注
reserved,防止未来意外复用 - 不改字段类型,不改字段的 field number,不改
oneof结构 - 删除字段前,确认所有消费者已停止使用(至少保留一个发布周期)
追问 4:"gRPC 在公网环境有什么问题?"
运营商中间设备(如透明代理、防火墙)经常干碎 HTTP/2 帧。具体现象:两个 Pod 之间的 gRPC 流突然全部断开,所有请求同时失败。原因是中间设备发现长连接空闲太久(比如 60 秒),主动发送 RST 帧关闭连接,但 gRPC 客户端不知道,下一个请求发出去就报 UNAVAILABLE。解法:设置 keepalive 每隔 15 秒发 PING 帧保持连接活跃,且客户端务必配置 enableRetry()。
选型决策流程
开始选型
│
├─ 调用方是外部客户端?
│ ├─ 是 → REST + OpenAPI 规范,JSON 序列化
│ │ 原因:浏览器/第三方/移动端原生支持 HTTP
│ │
│ └─ 否 → 内部服务间通信
│ │
│ ├─ 需要双向流/实时推送?
│ │ ├─ 是 → gRPC(Bidirectional Streaming)
│ │ └─ 否 ─┐
│ │ │
│ ├─ 性能敏感,QPS > 5000?
│ │ ├─ 是 → gRPC(Protobuf 序列化)
│ │ └─ 否 ─┐
│ │ │
│ ├─ Java 全栈,已有 Dubbo 基础设施?
│ │ ├─ 是 → Dubbo 3 Triple(兼容 gRPC wire protocol)
│ │ └─ 否 ─┐
│ │ │
│ └─ 跨语言且团队不大?
│ ├─ 是 → gRPC(社区大,文档全)
│ └─ 否 → Thrift(特殊场景才考虑)总结
微服务间通信的选型不是"gRPC 就是比 REST 好"这种非黑即白的选择,需要从四个维度权衡:
- 调用方是外部还是内部:外部 API 用 REST,内部通信用 gRPC
- 是否需要流式需求:实时推送、大数据流用 gRPC 双向流
- 团队技术栈:Java 全栈用 Spring Cloud + gRPC,跨语言团队用 gRPC 或 Thrift
- 运维成本:gRPC 需要管理 .proto 文件和 IDL 版本,REST 更轻量
gRPC 的坑:HTTP/2 多路复用在跨公网场景下可能被运营商中间设备干扰(连接重置导致所有请求同时失败),需要配置 keepalive 和重试策略。gRPC 负载均衡困难——HTTP/2 长连接复用导致 L4 层负载均衡每个连接只能固定到一个后端,必须用 Client Side Load Balancing 或 Service Mesh 的 L7 负载均衡。
Dubbo 老玩家的转型路径:Dubbo 3 全面拥抱 gRPC 协议(Triple 协议),底层使用 HTTP/2 + Protobuf,保留 Dubbo 的注册发现和路由能力。如果你用 Dubbo 3,实际上就是 gRPC 通信,不需要额外引入 gRPC 框架。
生产建议:内部通信用 gRPC(Protobuf 序列化,双向流支持),外部 API 用 REST(OpenAPI 规范 + JSON),非 Java 语言多且对性能要求极致的选 Thrift。选型决策的核心是"调用方是外部还是内部、是否流式需求、团队技术栈、运维成本"四个维度的综合权衡。