2.2 gRPC 设计:proto 与接口约定

服务边界定完,契约层就开始决定调用链的命运。我们 17 个服务的 50 多条跨域链路,契约全在一份份 proto 里。这一篇把 proto 的字段纪律、错误码约定、版本策略一次定死,并给出可直接照抄的 proto 骨架。接口设计的方法论与 go-micro-cps-guide 的契约章节互为对照,本篇只讲我们在 50 条链路上验证过的执行细节。

场景:契约漂移的三起故障

故障 根因 代价
支付回调加字段,订单服务强转旧结构 字段语义变更未评审 线上 15 分钟订单状态错乱
库存接口错误码混用 5 种 code 调用方按 0 与非 0 二分 降级判断误杀 20% 正常请求
商品名从 string 改 enum,旧数据 400 类型变更直接上 回滚 1 次

三起共性:契约变更没走评审。gRPC 的 proto 就是微服务之间的合同,改合同的规矩比写合同的规矩更重要。

故障一值得展开:支付回调加「分账标记」字段,支付侧认为「加个字段是小事」直接上线,订单侧老版本对未知字段的处理是忽略,于是带分账的订单全走了非分账分支,15 分钟后发现账不平。复盘后我们补了规则:新增字段即使向后兼容,也必须进调用方评审,因为「忽略」还是「报错」是调用方的行为选择,提供方无权替它决定。

故障二展开:库存服务早期把「库存不足」「风控拦截」「黑名单」「系统繁忙」「数据未同步」全丢进 gRPC 的 INTERNAL,调用方拿到 code 后没法区分「该重试」与「该降级」。统计那 20% 被误杀的请求,其实是「风控拦截」与「黑名单」——本应直接走降级兜底的业务态,被调用方当成系统错误反复重试,反而放大了库存服务的压力。错误码显式化之后,重试决策只看系统态,业务态按 err_code 表走 3.2 的兜底,重试量降了 60%。

故障二展开:库存服务早期把「库存不足」「风控拦截」「黑名单」「系统繁忙」「数据未同步」全丢进 gRPC 的 INTERNAL,调用方拿到 code 后没法区分「该重试」与「该降级」。统计那 20% 被误杀的请求,其实是「风控拦截」与「黑名单」——本应直接走降级兜底的业务态,被调用方当成系统错误反复重试,反而放大了库存服务的压力。错误码显式化之后,重试决策只看系统态,业务态按 err_code 表走 3.2 的兜底,重试量降了 60%。

方案:四条契约纪律

纪律 内容 执行点
字段只加不改 新增用新号,旧字段不删不改类型不重排 PR 评审加 CI diff
错误码显式 业务错误放响应体的 err_code,gRPC status 只放系统错误 生成的 stub 统一拦截
版本走后缀 破坏性变更开 v2 服务并行,不原地改 注册中心按 v 标签路由
必填显式 关键字段必填校验放服务端入口,不信任调用方 入口 100% 校验

「系统错误与业务错误分层」是四条里最容易被偷懒的一条。最初我们让库存服务把「库存不足」直接抛 gRPC INVALID_ARGUMENT,调用方分不清是参数写错还是真的没货,降级策略没法定。改成分层后,gRPC status 只保留 UNAVAILABLE(服务挂)、DEADLINE_EXCEEDED(超时)这类系统态,业务态全进 err_code,3.2 篇的降级方案就是按这张错误码表逐条配的。

各服务的 err_code 段也固定划分,避免全局冲突:订单段 1xxx、支付段 2xxx、库存段 3xxx,新域申请段位要登记。错误码段冲突过一次(支付与库存都用过 3001 表示「处理中」,语义不同但号撞了),之后段位登记表进了 wiki 首页。

proto 骨架长这样,直接照抄:

syntax = "proto3";
package orders.v1;

service OrderService {
  rpc CreateOrder(CreateOrderReq) returns (CreateOrderResp);
  rpc QueryOrder(QueryOrderReq) returns (Order);
}

message CreateOrderReq {
  string order_id = 1;      // 幂等键,调用方生成
  int64  user_id   = 2;
  int64  amount   = 3;      // 单位分,禁止浮点
}

message CreateOrderResp {
  int32  err_code = 1;     // 0 成功,非 0 见错误码表
  string err_msg  = 2;
}

三个约定要背下来:金额一律 int64 分(浮点进过两次事故);幂等键由调用方生成、服务端查重;err_code 枚举进 wiki 的错误码表,新增必须评审。错误码表本身也进契约管理:每服务的错误码文件放在域 module 的 api 目录,随 proto 一起过 buf 检查与评审,4.4 篇的 CLS 检索就靠这张表把 err_code 翻成人话。

配置与代码:CI 的契约卡点

# 新增字段校验:字段号必须递增,禁止复用旧号
buf lint --path api/ && buf breaking --path api/
# 破坏性 diff 直接红,破坏性变更必须新开 v2

buf breaking 把「只加不改」变成了可执行检查。我们用它拦过 4 次字段类型误改,其中 1 次是把金额从 int64 改 double——照 1.4 篇的说法,工具链漂移的代价在这体现得最重。buf 规则文件放在 internal 仓库统一维护,各域仓库引用同一份 buf.yaml,保证 17 个域的契约检查口径一致,检查项包括字段号递增、枚举只增不删、注释完整性三条。

# buf.yaml 摘录:破坏性变更检查
breaking:
  use:
    - FILE
    - FIELD_SAME_REPETEDNESS

版本策略:v1 与 v2 并行的细节

破坏性变更开 v2,落地时最常被问的是「v2 什么时候关 v1」。我们的关闭条件写进灰度控制器:

条件 判定 说明
调用方全量升 v2 注册中心 v=1 实例归零 按 2.3 的标签统计
v1 流量为 0 看板 7 天无调用 含压测与回放流量
双跑比对通过 影子 diff 差异 0 同 2.1 收拢四步

三条全绿才撤 v1,撤之前在 CLS 留一份 30 天的 v1 日志检索快照,应对极端回滚。一次库存接口 v2 上线用了 3 周走完三条件,其中 v1 实例归零等了两周——有个低频报表服务一直没升,这种长尾调用方在拆服务初期就该盘点,别拖到关版才找。关版那周还要盯一件事:v1 撤注册后 24 小时内回看 4.3 看板,确认没有新的 v1 调用冒头,有就说明有调用方绕开注册走了硬编码地址,这正是要杜绝的旧病。

踩坑:四个实战教训

第一,枚举值只增不删,废弃值留号打注释。删枚举值会让旧客户端反序列化炸掉,我们留了两个废弃号撑了 8 个月,等 100% 实例升完 v2 才在 v2 里清掉。废弃号清理由灰度控制器盯:某枚举值的流量占比在监控里连续 30 天为 0,才进清理清单,用 4.1 的灰度数据反查兼容期是否结束。

第二,metadata 传 TraceID 有 8KB 上限压力:早期把整条调用链塞进 metadata,一次深层链路超限直接调用失败,后来只传 traceId 与 spanId 两个 key。metadata 的 key 也要命名规范:全小写加下划线,前缀分域,避免两个域撞 key 后互相覆盖。

第三,流式接口慎用,团队对 gRPC 流式的坑经验不足,定下「默认一元调用,流式需专项评审」。唯一在用的流式场景是网关的日志推送,评审时补了三条约束:服务端心跳 15 秒、客户端重连指数退避、单流 QPS 上限 200。

第四,proto 文件归属进 2.1 的域 module,跨域改别人 proto 必须对方评审签字,这条把「顺手改字段」的路堵死。签字走 PR 的 review 卡:proto 文件的 review 请求自动 @ 对方域的值班,未签字 CI 不放行。

还有一类隐藏成本是文档:proto 里每个字段必须写注释,注释里写清单位(金额是元还是分)、边界(是否含零)、幂等含义。注释不全的 proto 过不了 buf lint。看起来是形式,实际是新同学接手时救命——没有注释的字段语义,只有翻代码与问老人两条路。

小结

契约层就四句:字段只加不改、错误码显式分系统业务两层、破坏性变更开 v2、金额幂等关键字段进评审。buf breaking 是纪律的执行器,评审是纪律的最后一道。把这三起故障的教训对照到清单:新字段进评审、业务态进错误码表、类型变更开 v2——每起事故对应一条纪律,合同精神落到可执行检查,契约层才算立住。下一篇 2.3 让 etcd 把这些契约的双方都实时找得到。

延伸阅读