7AA7 博客

服务间通信选型:REST、gRPC 与消息队列

陆知远 UID:104
2026-09-13 10:46
7 0 0

讨论服务间通信时,最常见的误判是把问题理解成”哪种协议更快”。沿着这条路走下去就变成基准测试数据的比拼,最后得出”gRPC 比 REST 快”这个既正确又没什么用的结论。

真正的差别在通信语义。协议只是语义的载体,语义决定了你的系统怎么失败、怎么重试、怎么演进。

三种通信语义

维度 REST gRPC 消息队列
语义 同步请求-响应 同步强类型 RPC 异步事件传递
调用方是否等待结果 是 是 否
消费者数量 一对一 一对一(流式可一对多推送) 一对多
契约载体 OpenAPI(可选、易漂移) .proto(强制、可生成代码) Schema Registry(可选)
时间耦合 强,双方需同时在线 强 弱,消费方可离线
典型失败模式 超时、重试风暴 连接中断、版本不兼容 消息积压、乱序、重复

“时间耦合”这一行是选型的核心。REST 和 gRPC 都要求被调用方此刻是活着的;消息队列不要求。这一个区别决定了后面所有的工程复杂度。

REST:可调试性换契约强度

REST 的最大优势不是”简单”,而是整条链路上的工具和人都看得懂。

curl -X POST http://order-svc/api/v1/orders 
  -H 'Content-Type: application/json' 
  -d '{"sku_id":"SKU-1","qty":2,"user_id":"U-9"}'

一条 curl 就能复现线上请求,这个价值在排障时怎么估都不过分。网关、APM、日志、浏览器 devtools、抓包工具全都原生支持 HTTP/JSON,不需要额外的代理或插件。

代价有三处:

契约弱。 除非强制使用 OpenAPI 并在 CI 里做兼容性检查,否则接口文档很容易和实现脱节。字段名改了没人知道,直到消费方报错。

序列化开销。 JSON 的解析成本显著高于二进制格式,在高频内部调用(例如每秒几万次的服务间调用)中会体现为可观的 CPU 占用。对外部 API 这个代价基本可以忽略。

无内置流式。 大量数据返回时只能分页,或自己维护长连接。

REST 适合:对外 API、内部查询类接口、调用频率不高或延迟不敏感的场景、需要被第三方集成的接口。

gRPC:强契约与流式,代价在调试与透传

gRPC 的价值不在”快”,而在契约即代码。

syntax = "proto3";

package inventory.v1;

service InventoryService {
  rpc Deduct(DeductRequest) returns (DeductResponse);
  // 服务端流:一次请求,持续推送变更
  rpc WatchStock(WatchRequest) returns (stream StockEvent);
}

message DeductRequest {
  string sku_id     = 1;
  int32  quantity   = 2;
  string idempotency_key = 3;
}

message DeductResponse {
  bool   success      = 1;
  int32  remaining    = 2;
  string rejection_reason = 3;
}

.proto 文件是唯一事实来源,服务端骨架和客户端存根都由它生成。字段类型不匹配是编译期错误,而不是运行时 500。字段编号(那个 = 1、= 2)让向后兼容变成可机械检查的规则:只增不删、不复用编号、不改类型。

流式能力是另一项 REST 难以提供的东西。双向流适合实时推送、长任务进度回报、设备数据采集。

代价也很明确:

调试门槛。 不能 curl,需要 grpcurl 之类的工具,且要提供 reflection 或 proto 文件。抓包看到的是二进制,看不出内容。

grpcurl -plaintext -d '{"sku_id":"SKU-1","quantity":2}' 
  localhost:9000 inventory.v1.InventoryService/Deduct

网关透传。 HTTP/2 是前提,很多老旧的负载均衡器、WAF、企业网关对 HTTP/2 和 gRPC 的支持不完整。浏览器无法直接调用 gRPC(需要 gRPC-Web 加一层代理)。

生态偏向内部。 让外部合作伙伴接入 gRPC 的沟通成本远高于给一个 OpenAPI 文档。

gRPC 适合:内部服务间高频调用、强类型契约要求、需要流式、多语言技术栈但都能生成 stub 的环境。

消息队列:解耦的代价是一致性

引入消息队列,本质上是把”时间耦合”换成了”数据一致性问题”。

// 生产者:订单支付成功后发事件
@Transactional
public void onPaid(String orderId) {
    orderRepo.markPaid(orderId);
    // 关键:事件写入本地 outbox 表,与业务同事务
    outboxRepo.insert(new OutboxEvent("order.paid", orderId, payloadOf(orderId)));
}
// 独立的投递线程扫描 outbox 表,投递成功后标记

上面这段是 Transactional Outbox 模式,它是”数据库更新”和”发消息”两个动作无法原子化的标准解法。如果直接写成”先更新数据库,再发消息”,那么进程在两步之间崩溃就会丢消息;反过来”先发消息再更新数据库”则会出现消息已发、业务未生效的幽灵事件。

消息带来的三类新问题:

顺序。 分区内有序不等于全局有序。同一个订单的”创建”和”取消”两个事件,如果被路由到不同分区,消费者可能先收到”取消”。解法是按业务键(订单 ID)分区,保证相关事件进同一分区。

重复。 至少一次投递语义决定了消费者必须做幂等(参考去重表或状态机校验)。

积压。 消费能力长期低于生产能力时,积压会持续增长,最终要么扩容消费者,要么接受延迟。监控消费延迟(lag)比监控队列长度更有意义,因为长度不告诉你”要追多久”。

消息队列适合:事件广播(一个事件多个订阅方)、削峰填谷、跨系统解耦、不需要立即得到结果的异步流程。

选型的三个问题

与其背一张对照表,不如在评审时问三个问题:

一、调用方需要立即拿到结果吗?

需要 → REST 或 gRPC。不需要 → 消息队列。

“需要”的判断标准不是”这个接口返回了东西”,而是”调用方拿不到结果就无法继续”。下单时校验库存必须同步,因为要据此决定成败;下单后发通知不需要同步,因为用户在页面上并不需要等它。

二、一个事件有多个消费方吗?

现在有或者可预见会有 → 消息队列。永远只有一个消费方 → 同步调用。

这里有个常见的错误:为了”以后可能有别的系统要用”,提前把所有调用都改成消息。结果多付了一致性的代价,而那个”以后”从未到来。判断依据应该是”这个事件在业务上是否确实属于公共事实”——”订单已支付”是公共事实,”查询用户积分”不是。

三、能容忍延迟吗?

能容忍秒级甚至分钟级 → 消息队列。必须毫秒级 → 同步。

邮件、报表、搜索索引更新、推荐特征计算都属于前者;风控拦截、库存扣减、价格计算属于后者。

现实的混合形态

生产系统里几乎不会只用一种。一个比较稳的划分方式是按读写性质分:

  • 读操作(查询)走同步:REST 或 gRPC。查询天然幂等、无副作用,同步调用延迟可控、实现简单。
  • 写操作后的事件通知走异步:消息队列。写操作完成后的衍生动作(更新缓存、发通知、同步搜索、记统计)都交给消息。

另一种划分是按一致性要求分:

  • 强一致的写路径:同步调用,必要时用分布式事务或 TCC;
  • 最终一致的衍生路径:消息驱动。

混合架构的复杂度在于同一条业务链路会跨越两种语义。比如下单:同步扣库存,异步发通知。库存扣了但通知没发出去,用户会以为下单失败——这类”半完成”状态需要在产品设计阶段就考虑,而不是留给工程补救。常见的做法是给异步动作加补偿任务:定时扫描”已支付但未通知”的订单,重发通知。

一些容易踩的坑

把消息队列当 RPC 用。 发一条命令消息,然后等着消费方回一条响应消息。这拿到了消息队列的全部缺点(延迟、乱序、重复)和 RPC 的全部缺点(强耦合、需要等待),还丢掉了两者各自的优点。需要结果就同步调用。

用 REST 做大批量内部调用。 服务 A 循环调用服务 B 一万次,每次带完整 JSON。这种场景要么加批量接口,要么换成 gRPC。

同步链路上的重试风暴。 每个服务都配了三次重试,五层调用下来最坏情况是 3^5 次放大。重试要放在链路的最高一层,中间层只做超时和熔断,并且一定要配指数退避和抖动。

消息 Schema 不做兼容性管理。 生产者改了字段名,消费者代码里还是旧字段,运行时静默取到 null。用 Schema Registry 做兼容性检查,能让这类问题在发布前暴露。

回到开头:选型不看协议快慢,看你要什么语义。同步要结果,异步要解耦,剩下的是代价问题——想清楚愿意付哪一份。

139浏览量
7赞
0评论
0参与用户
10 分钟阅读时间

评论(0)

还没有评论,来抢沙发。