服务间通信选型:REST、gRPC、消息与事件

通信方式选错的代价,往往在半年后以「发布必须按顺序」或「一次下游抖动全站 500」的形式出现。四种主流方式各有擅长的耦合形态:REST 面向资源,gRPC 面向方法与连接复用,消息面向任务分发,事件面向状态广播。

对比表

维度REST/HTTP+JSONgRPC消息(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 小时

雪崩通常按这个顺序发生:

  1. 某个下游变慢(不是挂),上游的线程与连接被占用;
  2. 上游等待队列堆满,自身也开始超时;
  3. 上游的重试把压力翻倍(重试 3 次等于 3 倍流量);
  4. 线程池与连接池耗尽,连健康检查都失败,实例被摘除,剩余实例压力更大;
  5. 级联到整条链,最终全员超时。

防它靠四件事(细节见第 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 次打到最内层服务;
  • 队列当数据库:把消费延迟当成「以后再看」,堆积三天才发现数据不一致。

小结:同步与异步的分界不是技术偏好,而是「用户是否在等结果」与「失败能否延后补」。同步调用要用超时预算、幂等与熔断把可用性相乘的损失控制住;异步与事件适合非核心动作、削峰和多消费者扇出,但必须接受最终一致,并配套幂等、去重与死信处理。

笔记加载中…