服务间通信选型:REST、gRPC、消息与事件
通信方式选错的代价,往往在半年后以「发布必须按顺序」或「一次下游抖动全站 500」的形式出现。四种主流方式各有擅长的耦合形态:REST 面向资源,gRPC 面向方法与连接复用,消息面向任务分发,事件面向状态广播。
对比表
| 维度 | REST/HTTP+JSON | gRPC | 消息(Message/Command) | 事件(Event) |
|---|---|---|---|---|
| 耦合方向 | 调用方知道被调方地址与契约 | 同左,且强类型 | 调用方知道队列与命令格式 | 生产者不知道消费者是谁 |
| 时效 | 同步、毫秒级 | 同步、延迟更低 | 异步、秒级到分钟 | 异步、秒级到分钟 |
| 可靠性 | 靠重试加幂等 | 靠 deadline 与重试 | 至少一次(At-Least-Once),可持久化 | 同消息,通常还要求可重放 |
| 可观测性 | 直观,curl 可复现 | 需 proto 与工具,依赖 tracing | 要看堆积与消费延迟,链路断在队列处 | 同消息,且消费者可能很多 |
| 契约演进 | 字段增删自由,易漂移 | 字段编号严格,兼容性好 | 靠消息 schema 管理 | 靠事件 schema 与版本共存 |
| 适用场景 | 对外开放接口、简单 CRUD、调试优先 | 内部高频调用、强类型、流式 | 任务分发、削峰、必须完成的异步处理 | 状态变化广播、多消费者、解耦演进 |
gRPC 的四种模式、错误码与连接池细节见「im-grpc」教程;这里只解决「什么时候该用它」。
同步调用链的雪崩
同步调用的可用性是相乘的,链路里任何一跳的失败率都会放大到整体:
链路:网关 → 订单 → 库存 → 优惠 → 支付
各服务可用性 99.9%(月故障约 43 分钟)
整体 ≈ 0.999^5 ≈ 99.5% → 月故障约 3.6 小时
若两跳在发布期间跌到 99%,整体 ≈ 96% → 月故障约 29 小时
雪崩通常按这个顺序发生:
- 某个下游变慢(不是挂),上游的线程与连接被占用;
- 上游等待队列堆满,自身也开始超时;
- 上游的重试把压力翻倍(重试 3 次等于 3 倍流量);
- 线程池与连接池耗尽,连健康检查都失败,实例被摘除,剩余实例压力更大;
- 级联到整条链,最终全员超时。
防它靠四件事(细节见第 16 章):超时预算(下游超时必须短于上游,全链加起来小于用户的容忍时间)、重试要有上限与退避、熔断与隔离(每个下游独立线程池或信号量)、幂等(先幂等才敢重试)。
超时预算分配示例(用户可接受 1.5 秒):
gateway 1200ms → order 1000ms → inventory 300ms → pricing 200ms
每层超时都小于上一层剩余预算,最内层最多重试 1 次
什么时候必须异步
- 写路径上有非核心动作:发通知、加积分、写操作日志、刷推荐特征。把它们放在主链路里同步执行,等于拿核心交易的可用性给边缘功能兜底;
- 下游不可用时业务仍要能继续:下单不该因为风控暂时不可用而全站不可用,可以先放行后补判,但要有事后处置;
- 流量有尖峰:秒杀、推送、批量导入需要队列削峰,让消费端按自己的能力消费;
- 扇出给多个消费者:一次状态变化要触发 5 个团队的处理,将来还会加第 6 个;
- 跨团队、跨系统的边界:你不该要求对方在你上线时一起上线。
什么时候必须同步
- 用户正在等待结果,且结果决定下一步界面(登录、下单校验、支付结果);
- 需要立刻返回准确的强一致读(余额、库存可售量);
- 写之前的校验(参数合法性、权限、状态机是否允许该转移);
- 对方只提供同步接口,且没有可用的变更通知机制。
判断可以压缩成三个问题:这个结果现在必须准确吗?失败能不能稍后补?多等两秒用户能不能接受? 若答案是「不必 / 能 / 能」,就该异步。
落地:契约与事件结构
同步接口只暴露必要字段,并声明幂等语义:
// 同步调用:调用方生成幂等键,被调方保证超时后重试仍然安全
type PaymentService interface {
Pay(ctx context.Context, req *PayReq) (*PayResp, error)
}
type PayReq struct {
IdempotentKey string // 同一笔支付的重试必须传同一个值
OrderID int64
AmountCent int64 // 用最小货币单位,避免浮点误差
Currency string // ISO 4217,如 CNY
}
异步事件用过去时命名,携带标识与状态,不携带需要回查的大对象:
{
"id": "evt_9f2c1a",
"type": "order.paid",
"version": 1,
"occurred_at": "2025-03-01T10:12:33Z",
"aggregate_id": "order_1024",
"trace_id": "7f3a91c2",
"data": { "order_id": 1024, "amount_cent": 19900, "currency": "CNY" }
}
三个字段的用途要固定下来:id 供消费端去重、aggregate_id 作为分区键保证同一订单的事件有序、trace_id 让调用链在异步段不断。
失败模式
- 把异步当礼物:以为解耦万事大吉,忽略了最终一致的用户可见后果(第 12 章);
- 事件当命令用:事件名叫
sendEmail,本质是命令,生产者开始期待特定消费者,解耦立刻消失; - 同步链无超时:默认超时是「无限」,下游一慢就拖垮上游;
- 重试风暴:三层各重试 3 次,一次请求变成 27 次打到最内层服务;
- 队列当数据库:把消费延迟当成「以后再看」,堆积三天才发现数据不一致。
小结:同步与异步的分界不是技术偏好,而是「用户是否在等结果」与「失败能否延后补」。同步调用要用超时预算、幂等与熔断把可用性相乘的损失控制住;异步与事件适合非核心动作、削峰和多消费者扇出,但必须接受最终一致,并配套幂等、去重与死信处理。