3106 字
约 10 分钟
15
S02-A04 · 接口契约演进:版本、兼容与废弃

"改了一个字段,三个调用方挂了。"这类复盘总指向同一个错判:改动方以为在改内部实现,调用方以为那是公开承诺。契约的边界不写在文档里,写在"别人是否已经依赖"这件事上。

契约演进的关键决策只有两个:这次改动会不会让已上线的调用方失败;如果会,用什么代价把它变成不会。其余都是这两问的展开。


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. 自测

  1. 格式没变但语义变了,为什么算破坏性变更?怎么判定?
  2. 新增枚举值对"向后兼容"和"向前兼容"分别意味着什么?
  3. 头部版本相对路径版本,运维上多出哪些不可见的代价?
  4. 弃用字段时,为什么按全局统计读取量为零不足以下线?
  5. 错误码从"可重试"改成"不可重试",破坏的是谁的能力?
  6. internal 标注的接口凭什么可能是公开契约?
S02-A04 · 接口契约演进:版本、兼容与废弃
https://www.clxhxhhr.top/posts/6109/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。