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 把这些契约的双方都实时找得到。
延伸阅读
- gRPC 接口设计与更多约定,见 go-micro-cps-guide。
- 契约变更与 CI 卡点结合,见 k8s-pipeline-guide。
- 错误码与日志检索的呼应,见 rust-logpipe-guide。
