服务间通信:REST、gRPC 还是消息队列?
1. 概览
当把一个单体系统拆分成多个小服务时,第一个问题总是:这些服务之间如何通信?本文比较三个最常见的选择 —— REST、gRPC 和消息队列(Kafka/RabbitMQ)—— 并通过具体代码示例展示。读完这篇文章,你会知道何时选择哪一种方案,并避免设计服务间通信时的几个经典错误。
2. 问题场景
假设你有一个电商系统,包含 3 个服务:OrderService、PaymentService、InventoryService。当 OrderService 收到订单时,它需要:
- 调用
PaymentService扣款(需要立即获取结果来反馈给用户)。 - 调用
InventoryService扣库存(可以异步处理)。
这两个不同的需求导致了两种不同的通信方式:同步(REST、gRPC)和异步(消息队列)。
3. REST — 简单、通用、易于调试
REST 通过 HTTP/JSON 是默认选择,因为几乎所有人都会用,调试工具(Postman、curl)随处可得。
3.1. REST 调用示例(使用 RestClient)
public record PaymentRequest(String orderId, BigDecimal amount) {}
public record PaymentResponse(String transactionId, String status) {}
@Service
public class PaymentClient {
private final RestClient restClient;
public PaymentClient(RestClient.Builder builder) {
this.restClient = builder.baseUrl("http://payment-service:8081").build();
}
public PaymentResponse charge(PaymentRequest request) {
return restClient.post()
.uri("/api/payments")
.body(request)
.retrieve()
.body(PaymentResponse.class);
}
}
RestClient(Spring 6.1+)发送 JSON 到 payment-service,收到已反序列化的 PaymentResponse。如果 payment-service 返回 4xx/5xx,retrieve() 会抛出 RestClientException —— 你应该在调用层捕获这个异常。
3.2. 优缺点
- **优点:**易于学习,易于用 curl/Postman 测试,所有语言都有现成支持,可以利用 HTTP 缓存、负载均衡器、API 网关。
- **缺点:**JSON 有效载荷较重,没有强契约(客户端和服务器容易字段不同步),相比 gRPC 延迟更高。
**注意:**REST 适合在请求数量不太大且需要快速用肉眼调试的场景。对于内部系统每秒处理数万次请求的情况,REST JSON 可能会成为瓶颈。
4. gRPC — 高性能、严格契约
gRPC 使用 Protocol Buffers(二进制格式)和 HTTP/2,适合需要速度和明确契约的服务间内部通信。
4.1. 声明 proto
syntax = "proto3";
package payment;
option java_package = "dev.nonla.payment.grpc";
option java_multiple_files = true;
service PaymentGrpcService {
rpc Charge (ChargeRequest) returns (ChargeResponse);
}
message ChargeRequest {
string order_id = 1;
double amount = 2;
}
message ChargeResponse {
string transaction_id = 1;
string status = 2;
}
这个 .proto 文件为服务器和客户端生成 Java stub —— 这正是 REST 所缺少的“硬契约”。
4.2. Maven 依赖
<dependency>
<groupId>net.devh</groupId>
<artifactId>grpc-server-spring-boot-starter</artifactId>
<version>3.1.0.RELEASE</version>
</dependency>
<dependency>
<groupId>net.devh</groupId>
<artifactId>grpc-client-spring-boot-starter</artifactId>
<version>3.1.0.RELEASE</version>
</dependency>
grpc-spring-boot-starter 库帮助将 gRPC server/client 集成到 Spring Boot 中,无需手动管理 Server。
4.3. 服务器实现
@GrpcService
public class PaymentGrpcServer extends PaymentGrpcServiceGrpc.PaymentGrpcServiceImplBase {
@Override
public void charge(ChargeRequest request, StreamObserver<ChargeResponse> responseObserver) {
ChargeResponse response = ChargeResponse.newBuilder()
.setTransactionId(UUID.randomUUID().toString())
.setStatus("SUCCESS")
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
}
}
@GrpcService 自动将此服务器注册到 gRPC 运行时。onNext 发送响应,onCompleted 关闭流 —— 必须调用,否则客户端会一直等待。
4.4. 客户端调用 gRPC
@Service
public class PaymentGrpcClient {
@GrpcClient("payment-service")
private PaymentGrpcServiceGrpc.PaymentGrpcServiceBlockingStub stub;
public ChargeResponse charge(String orderId, double amount) {
ChargeRequest request = ChargeRequest.newBuilder()
.setOrderId(orderId)
.setAmount(amount)
.build();
return stub.charge(request);
}
}
BlockingStub 同步调用,直接返回结果 —— 代码比 REST 更简洁,因为不需要手动反序列化 JSON。
4.5. 优缺点
- **优点:**二进制较小,比 JSON 快 2–5 倍;HTTP/2 多路复用;
.proto契约强制客户端/服务器同步 schema;支持双向流。 - **缺点:**难以手工调试(无法直接读取有效载荷),需要专门工具(grpcurl、BloomRPC),不适合直接从浏览器调用(需要 grpc-web),团队需要学习 proto。
注意:如果还没有 grpc-web 基础设施,不要为面向前端浏览器的公共 API 使用 gRPC。gRPC 最适合内部的服务间通信。
5. 消息队列 — 异步、容错性强
当 OrderService 不需要等待 InventoryService 立即回复时,消息队列(Kafka、RabbitMQ)是合理的选择:发送消息后继续处理,消费者在准备好时处理。
5.1. Maven 依赖
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
5.2. 生产者 — OrderService 发送事件
public record StockReservedEvent(String orderId, String sku, int quantity) {}
@Service
public class InventoryEventPublisher {
private final KafkaTemplate<String, StockReservedEvent> kafkaTemplate;
public InventoryEventPublisher(KafkaTemplate<String, StockReservedEvent> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void publishStockReserved(StockReservedEvent event) {
kafkaTemplate.send("stock-reserved-topic", event.orderId(), event);
}
}
kafkaTemplate.send 将事件推送到 stock-reserved-topic topic,使用 orderId 作为 key 来确保同一订单的事件在同一分区上(保持处理顺序)。
5.3. 消费者 — InventoryService 监听
@Component
public class StockReservedListener {
@KafkaListener(topics = "stock-reserved-topic", groupId = "inventory-service")
public void onStockReserved(StockReservedEvent event) {
log.info("Reserving {} units of {} for order {}",
event.quantity(), event.sku(), event.orderId());
// 扣库存,按 orderId 处理幂等性
}
}
@KafkaListener 自动从 topic 轮询新消息。如果 InventoryService 暂时宕机,Kafka 会保留消息 —— 数据不会丢失,消费者在恢复后继续处理。
5.4. 优缺点
- **优点:**强解耦(生产者无需知道消费者是否还活着),容错性好(消息被保留),易于按分区扩展,支持事件驱动架构。
- **缺点:**没有即时响应(如需结果需另外设计 callback/轮询),数据流调试更复杂(需要跟踪多个 topic),需要单独的 broker 基础设施(Kafka/RabbitMQ)来运维和监控。
**注意:**不要为需要立即反馈给用户的场景使用消息队列(例如支付成功/失败的确认) —— 异步延迟会破坏用户体验。
6. 三种方案快速对比
| 标准 | REST | gRPC | 消息队列 |
|---|---|---|---|
| 通信类型 | 同步 | 同步 | 异步 |
| 格式 | JSON(文本) | Protobuf(二进制) | 通常 JSON/Avro |
| 性能 | 中等 | 高 | 不直接适用(高吞吐量) |
| 契约 | 松散(可选 OpenAPI) | 严格(.proto) |
松散(schema registry 可选) |
| 调试 | 简单(curl、Postman) | 较难 | 最难(需要跟踪多个消费者) |
| 服务宕机时的容错 | 立即失败 | 立即失败 | 消息等待,不丢失 |
| 适用场景 | 公共 API、简单 CRUD | 内部通信、高性能 | 事件驱动、后台处理 |
7. 选择通信方式的场景指南
回到 OrderService 的例子:
@Service
public class OrderProcessingService {
private final PaymentGrpcClient paymentClient; // 需要立即结果、内部、高性能
private final InventoryEventPublisher inventoryPublisher; // 无需等待、后台处理
public OrderProcessingService(PaymentGrpcClient paymentClient,
InventoryEventPublisher inventoryPublisher) {
this.paymentClient = paymentClient;
this.inventoryPublisher = inventoryPublisher;
}
public OrderResult processOrder(Order order) {
ChargeResponse charge = paymentClient.charge(order.id(), order.amount());
if (!"SUCCESS".equals(charge.getStatus())) {
return OrderResult.failed(order.id());
}
inventoryPublisher.publishStockReserved(
new StockReservedEvent(order.id(), order.sku(), order.quantity()));
return OrderResult.accepted(order.id(), charge.getTransactionId());
}
}
processOrder 调用 gRPC 以立即了解支付是否成功(必须同步),然后仅发送事件来扣库存 —— 因为用户不需要等待这一步,即使 InventoryService 暂时宕机,Kafka 仍会保持消息安全。
选择的通用规则:
- 需要立即结果、内部通信、高频率 → gRPC。
- 需要立即结果、与外部通信或其他团队不熟悉 gRPC → REST。
- 无需立即结果、需要容错性和解耦 → 消息队列。
8. 编写流程选择逻辑的测试
@ExtendWith(MockitoExtension.class)
class OrderProcessingServiceTest {
@Mock PaymentGrpcClient paymentClient;
@Mock InventoryEventPublisher inventoryPublisher;
@InjectMocks OrderProcessingService orderProcessingService;
@Test
void shouldPublishStockEvent_whenPaymentSucceeds() {
Order order = new Order("order-1", "SKU-01", 2, new BigDecimal("100.00"));
ChargeResponse success = ChargeResponse.newBuilder()
.setTransactionId("tx-1")
.setStatus("SUCCESS")
.build();
given(paymentClient.charge(order.id(), order.amount())).willReturn(success);
OrderResult result = orderProcessingService.processOrder(order);
assertThat(result.status()).isEqualTo("ACCEPTED");
verify(inventoryPublisher).publishStockReserved(
new StockReservedEvent("order-1", "SKU-01", 2));
}
@Test
void shouldNotPublishStockEvent_whenPaymentFails() {
Order order = new Order("order-2", "SKU-02", 1, new BigDecimal("50.00"));
ChargeResponse failed = ChargeResponse.newBuilder().setStatus("FAILED").build();
given(paymentClient.charge(order.id(), order.amount())).willReturn(failed);
OrderResult result = orderProcessingService.processOrder(order);
assertThat(result.status()).isEqualTo("FAILED");
verifyNoInteractions(inventoryPublisher);
}
}
第一个测试检查当支付成功时,InventoryEventPublisher.publishStockReserved 必须用正确的参数调用。第二个测试检查当支付失败时,系统不会发送扣库存事件 —— verifyNoInteractions 确保没有意外的副作用。
9. 同时使用三种方式时的常见错误
- **滥用 REST 同步处理所有事务:**导致“分布式单体” —— 服务 A 调用 B,B 调用 C,一个服务变慢会导致整个链路超时。
- **使用 gRPC 但不对 proto 版本控制:**在
.proto中修改字段而不保持向后兼容(删除旧字段而不是废弃)会导致旧客户端崩溃。 - **发送消息但不处理幂等性:**Kafka 在消费者重试时可能重新发送消息 —— 如果
InventoryService为同一orderId扣两次库存就会有 bug。 - **混淆同步/异步不清楚:**为了代码易于维护,方法/类名应该清楚地表示通信类型,例如
publishXxxEvent(异步)与fetchXxx/chargeXxx(同步)不同。
10. 总结
没有“最佳”方案适用于所有情况 —— REST 适合需要简单和易于调试的场景,gRPC 适合需要速度和严格契约的内部通信,消息队列适合需要解耦和容错、无需立即响应的流程。实际上,一个成熟的微服务系统通常同时使用所有三种方案,每种方案用于最适合的通信类型 —— 如上面的 OrderService 示例所示。