讨论服务间通信时,最常见的误判是把问题理解成”哪种协议更快”。沿着这条路走下去就变成基准测试数据的比拼,最后得出”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 做兼容性检查,能让这类问题在发布前暴露。
回到开头:选型不看协议快慢,看你要什么语义。同步要结果,异步要解耦,剩下的是代价问题——想清楚愿意付哪一份。
Comments (0)
No comments yet — be the first.
Please Sign in to join the discussion.