"改了一个字段,三个调用方挂了。"这类复盘总指向同一个错判:改动方以为在改内部实现,调用方以为那是公开承诺。契约的边界不写在文档里,写在"别人是否已经依赖"这件事上。
契约演进的关键决策只有两个:这次改动会不会让已上线的调用方失败;如果会,用什么代价把它变成不会。其余都是这两问的展开。
1. 契约的三层,与各自的演进空间
把契约等同于"接口签名"的人只盯字段名与类型。真正的契约有三层,演进自由度从下往上递减。
| 层 | 内容 | 可演进空间 | 典型误判 |
|---|---|---|---|
| 传输格式 | 字段名、类型、嵌套、编码、必填性 | 大:加可选字段近零成本 | 以为格式不变即兼容 |
| 语义约定 | 单位、时区、排序、空值含义、幂等性 | 小:文档改了调用方不跟着改 | 语义悄悄变了 |
| 错误码 | 分类、可重试性、定位信息 | 中:加码容易,改语义难 | 业务失败塞进 500 |
语义是最易出事的一层:amount 从元改成分、status 从订单状态改成支付状态、空串从"没有值"改成"有值但为空",格式全兼容,调用方全算错。判定办法只有一问:同样的输入,调用方解出的结论会不会不同。会,就是破坏性变更。
// 兼容的表象,不兼容的实质:字段还在,含义变了
{ "orderNo": "A1024", "amount": 129900, "currency": "CNY" }
// 旧调用方按"元"解析 → 显示 129900 元;正确是 1299.00 元
2. 兼容性分类与判定方法
判定不必争论,走一条流水线:列出已知调用方 → 逐个检查解析路径 → 找一个能复现失败的样本。找不到反例的"可能不兼容"不算破坏性。
| 变更 | 类别 | 判定依据 |
|---|---|---|
| 新增可选请求参数 | 向后兼容 | 老调用方不传即走默认值 |
| 新增响应字段 | 向后兼容* | *调用方须忽略未知字段 |
| 新增枚举值 | 视角兼容,实现不兼容 | 老客户端 valueOf 或穷尽 switch 会炸 |
| 字段改类型 | 破坏 | 反序列化即失败 |
| 字段改语义/单位 | 破坏 | 解析成功但结果错,最难查 |
| 字段改必填 | 破坏 | 老调用方请求被拒 |
| 删除字段 | 破坏 | 依赖它的调用方 NPE 或默认值兜底成错数 |
| 收紧校验(长度/范围) | 破坏 | 原本合法的请求 400 |
"向后"指新服务能被老客户端用;"向前"指老服务能接受新客户端发来的东西。凡新增必填参数都破后者,而多数事故出在只考虑了向后。
// 枚举兜底是消费方的义务,提供方必须把它写进契约文档
public static OrderStatus parse(String raw) {
try { return OrderStatus.valueOf(raw); }
catch (IllegalArgumentException e) {
log.warn("未知状态值 {}", raw);
return OrderStatus.UNKNOWN; // 契约要求:必须存在兜底值
}
}
3. 版本策略的三种形态与运维代价
| 策略 | 形式 | 优点 | 代价 |
|---|---|---|---|
| 路径版本 | /v2/orders |
直观、可路由、可灰度 | URL 膨胀;代码并存 |
| 头部版本 | Accept-Version: 2 |
URL 稳定,日志透明 | 难按版本路由限流;缓存易串 |
| 无版本靠兼容 | 只做增量兼容 | 调用方零成本 | 永远兼容,冗余字段不清理 |
选择判据不是"哪种先进",而是能承受几套逻辑并行。路径版本的隐性成本是多版本共存期的双份测试、双份修复、双份容量评估;无版本的隐性成本是契约永久通胀(字段只增不减,语义靠注释维持)。
无版本策略常是事实状态,可行前提很硬:每次改动都是纯增量,永远接受老字段继续返回。做不到,就只是把问题推到某次不得不大改。
# 路由按版本分流,灰度以版本为单位,而不是以服务实例为单位
routes:
- { path: /v1/orders/**, upstream: orders-v1 }
- { path: /v2/orders/**, upstream: orders-v2, weight: 5 } # 5% 流量
deprecation:
v1: { sunset: 2027-03-31, monitor: caller_metrics }
4. 字段弃用的流程
废弃的难点不是"删",而是删之前如何证明没人用。没有调用量证据的删除等于赌。
| 阶段 | 动作 | 退出条件 |
|---|---|---|
| 标记 | 文档、注解、返回体注释同时标注废弃与替代方案 | 所有调用方收到通知 |
| 并行 | 新旧字段双写共存,旧字段保留原语义 | 双写覆盖一个完整发布周期 |
| 观测 | 按调用方维度统计旧字段读取量 | 连续若干周期读取量为零 |
| 下线 | 移除字段,保留一次回滚窗口 | 无新增告警 |
观测最易被跳过:按全局统计读取为零,可能只是主流调用方升级了,那三个低频方没人记得。必须按 appKey/客户端版本 拆开看。
// 弃用期的最小观测口径
field=amount deprecated_since=2026-06 removal_plan=2027-01
readers: { svc-billing: 0, svc-report: 12/hr, unknown-sdk-1.x: 340/hr }
结论: 暂不可删,svc-report 与未知 SDK 仍在读
5. 分页、排序、过滤的可演进性
这三类参数看似细节,实则是契约里最容易"改一次赔三年"的部分。
| 参数族 | 兼容变化 | 破坏性变化 |
|---|---|---|
| 分页 | 加 nextCursor;加 size 上限 |
页码 0 起改 1 起、默认 size 变小 |
| 排序 | 加排序字段白名单 | 改默认排序、改空值排位、改大小写敏感 |
| 过滤 | 加可选条件、写明"未指定即全部" | "空参数"从全部改成无结果 |
游标替换 offset 是破坏性的:参数名与语义都变,行为差异又在数据量大时才显现(深页越慢,结果还可能因写入重复或遗漏)。值得做,但要按新版本做。
// 游标契约:nextCursor 是 opaque 字符串,调用方不得解析
public record PageResponse<T>(List<T> items, String nextCursor, boolean hasMore) {}
// 契约条款:nextCursor 为空表示结束;客户端不得依赖其内部结构
6. 错误码体系:区分"谁的问题"和"能不能重试"
错误码的核心不是编号规范,而是让调用方自动做出正确动作:重试、降级、提示用户、还是人工介入。
| 维度 | 设计选择 | 后果 |
|---|---|---|
| 分类 | 用 HTTP 状态码表达大类,业务码表达细分 | 只用 200 + body 判断,会让网关/监控全部失灵 |
| 可重试性 | 显式 retryable: true/false |
猜"5xx 就重试"会重放非幂等操作 |
| 定位信息 | 带 traceId 与出错字段路径 |
只给"参数错误",排查从分钟级变小时级 |
| 稳定性 | 业务码一旦发布不重用、不改语义 | 重用编号是最隐蔽的破坏性变更 |
{
"code": "ORDER_AMOUNT_MISMATCH",
"httpStatus": 409,
"retryable": false,
"message": "订单金额与支付金额不一致",
"field": "amount", "traceId": "TRACE_ID"
}
常被忽略的判定:错误码也是契约。把 INVALID_PARAM 拆细是兼容的(调用方多半按大类兜底),但把某码从"可重试"改成"不可重试",等于让自动重试逻辑全线失效。
7. 契约的生成物与测试
| 手段 | 保证什么 | 不保证什么 |
|---|---|---|
| OpenAPI 文档 | 格式与结构可读、可生成 | 语义与时序 |
| 代码生成客户端 | 类型对齐,减少手写误差 | 消费方是否忽略了未知字段 |
| mock 服务 | 联调不阻塞 | 真实边界行为(超时、限流、部分失败) |
| 契约测试 | 提供方不破坏已发布契约 | 文档里没写进契约的部分 |
契约测试的分界线很清楚:只有写进契约的东西才被保护。常见错觉是"上了契约测试就安全",真正出事的恰是契约里没写的语义假设。
# 契约文件里最容易漏写的三件事
pagination: { cursor_opaque: true, end_signal: empty_string }
retry: { idempotent_header: Idempotency-Key, same_key_same_result: true }
unknown_fields: { client_must_ignore: true, server_must_not_reject: true }
8. 跨团队沟通的机制
方案再对,缺机制也会失败。三条硬机制足够:变更公告、迁移窗口、按调用方灰度。
| 机制 | 最小形式 | 缺失后的典型后果 |
|---|---|---|
| 变更公告 | 契约 diff 自动推到调用方群 | 上线当天对方才发现 |
| 迁移窗口 | 明确旧版下线日期,并留可申请延期 | 下线日变成扯皮日 |
| 灰度 | 按调用方或流量比例分批切 | 一次全量,故障面最大化 |
"内部接口"最易出问题,因为默认它没有外部约束。判断很简单:看它有没有出现在别人的启动配置、定时任务或报表 SQL 里。出现过,它就是事实上的公开契约,标 internal 也没用。
9. 排错:六种典型的契约事故
| 现象 | 根因 | 处理方向 |
|---|---|---|
| 上线后少数调用方 500 | 新增枚举值,老客户端无兜底 | 提供方加兜底值 + 通知升级 |
| 数据对但显示差 100 倍 | 单位语义变更 | 双字段并存过渡,禁改语义 |
| 加字段导致对方解析失败 | 客户端严格模式拒绝未知字段 | 契约写明"忽略未知字段",或先发"可选字段"公告 |
| 分页数据重复/遗漏 | offset 分页在深页遇到写入 | 改游标分页,走新版本 |
| 重试导致重复扣款 | 非幂等操作被自动重试 | 幂等键 + retryable 字段 |
| 旧版下线后流量突然回来 | 有未登记的调用方 | 按 appKey 观测,必要时保留兼容层 |
10. 边界:什么时候不必搞这套
| 场景 | 判断 | 做法 |
|---|---|---|
| 单一调用方,且是同团队 | 契约成本高于收益 | 随改随发,仅在文档留一行变更记录 |
| 探索期接口、尚无真实调用方 | 冻结契约会拖慢验证 | 显式标注 experimental,允许调整 |
| 强兼容要求(多端、外部合作) | 改造周期按季度算 | 版本化 + 双写 + 分阶段下线,按季度排期 |
| 内部数据管道(非对外接口) | 关注点在数据可重放 | 用 schema 演进规则(如 Avro 兼容模式)而非 HTTP 版本 |
一句话:兼容性是一种预算。每花一分都在为别人的升级时间买单,所以要花在无法避免处,而不是花在"图省事"处。
11. 自测
- 格式没变但语义变了,为什么算破坏性变更?怎么判定?
- 新增枚举值对"向后兼容"和"向前兼容"分别意味着什么?
- 头部版本相对路径版本,运维上多出哪些不可见的代价?
- 弃用字段时,为什么按全局统计读取量为零不足以下线?
- 错误码从"可重试"改成"不可重试",破坏的是谁的能力?
internal标注的接口凭什么可能是公开契约?