为了加一个字段,有人提议升 v2。这个提议几乎不需要论证——它听起来"更规范"。但它实际提出的是:从此有两套接口要测、要监控、要写文档、要有人值班,且没人知道第二套何时能关。版本不是能力的证明,是一份长期维护义务。该不该开新版本,等于判断这份义务值不值。
1. 版本是负债,不是能力
收益是"这一次不用兼容",一次性的;成本是把状态空间从一份变成两份,按存活期累积。
| 项 | 开新版本 | 兼容扩展 |
|---|---|---|
| 本次改造自由度 | 高 | 低,受旧结构约束 |
| 新增维护面 | 测试、监控、文档、值班各一份 | 一份 |
| 退出成本 | 一次弃用战役 | 无 |
| 适用时间尺度 | 一次性重构 | 长期演进 |
结论不是"尽量别开版本",而是"开版本要说清关闭条件"。没有关闭条件就是永久支持。
2. 什么算破坏性变更
判断破坏性不看改动形式,看旧客户端升级后的行为。第四类最隐蔽。
| 类型 | 例子 | 旧客户端表现 |
|---|---|---|
| 字段删除 | 去掉 discountAmount |
解析失败或取默认值,金额算错 |
| 类型变化 | amount 从字符串变数字 |
强类型反序列化抛错 |
| 必填化 | 原可选,现缺失即 400 | 请求被拒且看不出原因 |
| 语义变化 | 单位元→分、时区本地→UTC、空值"无"→"零" | 一切照常,数字全错 |
它不触发异常,错误只在业务结果里出现,常在几周后才被发现。凡涉及单位、币种、时区、精度、空值语义、排序稳定性、分页游标含义的改动,都按破坏性处理。
// 契约差异检查:结构与语义分两路审
oasdiff breaking BASE.yaml REV.yaml // 结构性差异,工具可判
─── 语义差异工具判不出,只能人工 diff 字段含义
─── 提交前搜索"单位 / 时区 / 空值",命中的字段必须确认
3. 兼容扩展的四种手段
多数需求不必升版本。四种手段按成本递增,能停在前两步就别往后走。
| 手段 | 做法 | 前提 | 失效场景 |
|---|---|---|---|
| 加可选字段 | 字段带合理默认 | 客户端忽略未知字段 | 强校验字段全集 |
| 允许未知字段 | 两端宽容解析 | 契约写明向后兼容 | 生成式客户端严格绑定 |
| 宽松解析 | 未知枚举降 UNKNOWN |
有兜底分支 | switch 全覆盖 |
| 双写期 | 新旧字段同时返回 | 有摘除日期 | 无人收尾变永久 |
// 宽容解析:未知枚举与缺失字段都要有兜底(Jackson 口径)
@JsonIgnoreProperties(ignoreUnknown = true)
public enum Channel {
WECHAT, ALIPAY, UNKNOWN;
@JsonCreator
public static Channel of(String raw) {
return Arrays.stream(values())
.filter(v -> v.name().equalsIgnoreCase(raw))
.findFirst().orElse(UNKNOWN); // 未知值给兜底,不抛异常
}
}
容忍度是双向的:只要一端不宽容,"加可选字段"就不再兼容。这条要写进契约,不该靠自觉。
4. 开新版本的三个判据
三条必须同时成立。缺一条,说明还有别的路。
| 判据 | 提问 | 不成立时的替代 |
|---|---|---|
| 确属破坏性 | 旧端不改代码会否出错 | 走兼容扩展 |
| 难以按期迁移 | 能否一周期内改完 | 排期迁移,只修 bug |
| 不可共存 | 能否同进程按路由分流 | 并行版本,共享数据层 |
第三条最常被跳过:很多"必须新版本"的场景其实能让旧路径走旧实现、新路径走新实现,共用数据层。真不可共存的,通常是数据模型与核心语义都变了。
// 开版决策清单(任一为"否"即回到兼容扩展)
// 1) 旧客户端原样读到的结果会错吗?否 → 不需要新版本
// 2) 调用方能在 DEPRECATION_WINDOW 内迁完?是 → 排期迁移
// 3) 新旧契约能在同一发布单元共存?是 → 路由分流
// 4) 皆否 → 开版本,同时写下关闭条件
5. 共存期的成本结构
代价在四个维度上翻倍,最贵的是值班,最常被低估的是监控。
| 成本项 | 具体形态 | 缩放关系 |
|---|---|---|
| 测试 | 两套契约用例与基线 | 随版本数线性增长 |
| 监控 | 指标按版本打标签 | 标签基数组合膨胀 |
| 文档 | 每版一份示例与变更 | 线性,维护者不增 |
| 值班 | 告警要判断"哪一版坏了" | 定位时间近似翻倍 |
version 标签一加,看板与告警基数变大,查询成本上升。共存期只给活跃版本建看板,旧版本留错误率与调用量两个数,够判断下线即可。
6. 弃用流程的必要环节
弃用不是"发公告然后关掉"。缺任何一环,最后都变成"旧版本还在,因为没人敢关"。
| 环节 | 产出 | 缺失后果 |
|---|---|---|
| 公告期 | 停止支持日与范围 | 称"没收到通知" |
| 迁移工具 | 字段对照表或样例 | 成本转嫁,进度停滞 |
| 调用量观测 | 按调用方分组请求数 | 判断不了是否没人用 |
| 强制下线日 | 具体日期与动作 | 旧版本无限期存活 |
// 弃用声明:机器可读部分要被网关与文档同时消费
deprecated: true
sunset: "SUNSET_DATE" // 与公告日一致,中途不改期
migration: "docs/migrate-v1-to-v2.md"
contact: "@platform-api"
公告写清四件事:改什么、何时生效、影响谁、怎么验证。最后一条最常漏,等于把不确定性留给对方。
7. "永久支持旧版本"的组织代价
不弃用通常不是不知道成本,而是没人愿承担"关掉后爆炸"的责任。责任悬空会沉淀为结构代价。
| 代价 | 机制 | 表现 |
|---|---|---|
| 功能双份实现 | 每版都满足新需求 | 出现"v1 也做一遍" |
| 缺陷修复回移 | 修一处回移一次 | 遗漏回移,旧版带病 |
| 认知负担 | 新人要学两套语义 | 排障时看错版本文档 |
| 决策僵化 | 旧版约束反向限制新版 | 新设计兼容无人用的怪癖 |
可执行的缓解是给旧版本设"最低维护等级":只修安全与正确性,不做功能增强,并写进公告。
8. 内部与外部接口不是一套策略
同一套机制用在内网与开放平台,成本差一个量级。
| 维度 | 内部接口 | 外部接口 |
|---|---|---|
| 调用方 | 可控,个位数 | 不可控、匿名 |
| 升级协调 | 可约同一发布窗口 | 只能靠公告与宽限期 |
| 破坏性容忍度 | 较高 | 极低 |
| 版本机制 | 能不用就不用 | 必须有版本与弃用契约 |
| 观测要求 | 够判断下线 | 需按调用方细分 |
内部接口的最优解常是"不设版本号,只维护一份向前兼容契约 + 变更公告"。外部接口相反:版本号是给对方的安全边界。
9. 版本号放在哪里:由缓存与网关决定
路径、Header、Query 没有审美高下,只有与基础设施的匹配度。
| 形式 | 例子 | 缓存友好 | 网关路由 | 客户端改造 |
|---|---|---|---|---|
| 路径 | /v2/orders |
最好,键天然隔离 | 前缀匹配最省事 | 改 base URL |
| Header | Accept-Version: 2 |
差,需 Vary |
按 Header 匹配 | 改请求头 |
| Query | /orders?version=2 |
差,易污染缓存 | 需重写规则 | 改参数拼接 |
// 路径版本:缓存键天然按版本隔离
location /v1/ { proxy_pass http://app_v1; }
location /v2/ { proxy_pass http://app_v2; }
// Header 版本必须声明 Vary,否则缓存会把两版混在一起
add_header Vary Accept-Version;
判据只有三条:缓存是否按 URL 分键、网关能否按该维度路由、客户端 SDK 是否易改。三条都指向路径时,别为了"URL 干净"选 Header。
10. 灰度版本与并行版本不是一件事
两个词常被混用,但解决的问题与失败方式都不同。
| 项 | 灰度版本 | 并行版本 |
|---|---|---|
| 目的 | 验证新版行为 | 旧调用方继续可用 |
| 分流依据 | 流量比例、用户分组 | 版本号、路径 |
| 生命周期 | 天到周,验证即收敛 | 月到季,直到弃用完 |
| 失败方式 | 分组不可比,结论失真 | 旧版无人维护,长期带病 |
| 必备观测 | 分组错误率与延迟对比 | 按版本调用量与错误率 |
// 并行版本必须能回答"旧版还有谁在用",否则收敛不了
// sum(rate(http_requests_total{route="/orders"}[5m])) by (version, client_id)
// 判据:调用方集合连续 LINGER_WINDOW 未变 → 剩下的是困难户,需点名跟进
11. 用什么判断版本策略是否健康
| 度量 | 定义 | 健康信号 |
|---|---|---|
| 活跃版本数 | 近三十天有调用的版本 | 稳定在 2 以内 |
| 旧版调用占比 | 旧版 / 总请求 | 单调下降,不停平台期 |
| 迁移完成率 | 已迁 / 应迁 | 按公告期推进,末段点名 |
| 弃用周期 | 公告日到下线日 | 有上限且实际执行 |
平台期最值得警惕:迁到八成停住,剩下两成永久占住旧版本,等架构升级时一次性引爆成本。
12. 排错:版本策略常见故障
| 现象 | 原因 | 处理 |
|---|---|---|
| 旧版归零却不敢关 | 观测只到接口层 | 补 client_id 维度,点名下线 |
| 老客户端报错 | "加字段"被判为破坏性 | 客户端改宽容解析 |
| 两版数据不一致 | 双写期两侧逻辑分叉 | 单点写单点读,另一侧只做投影 |
| 文档说弃用,实际仍在跑 | 公告与网关不同步 | 弃用状态由配置驱动 |
| 灰度指标不可对比 | 分母不同、窗口错位 | 同窗口同分组再比 |
13. 边界:什么情况下版本机制该压到最简
| 情形 | 判断依据 | 做法 |
|---|---|---|
| 单一内部调用方 | 一个消费方,可同步改 | 一句通知 + 同一发布窗口 |
| 调用方可同步升级 | 同一发布单元内 | 不设版本号,一起发 |
| 仅新增可选字段 | 旧端不改也不出错 | 直接发,不升版本 |
| 临时实验接口 | 生命周期以周计 | 用 feature flag 而非版本 |
反过来,一旦调用方匿名、难以协调发布窗口、或契约要被第三方长期依赖,版本与弃用机制就是必需——省下的流程成本会在第一次破坏性变更时全部付回来。
14. 自测
- 字段名与类型都没变,只把单位从元改成分,属于哪类变更?为什么最危险?
- 判断"该不该开新版本"的三个条件是什么?缺一条时通常的替代是什么?
- 为什么共存期最贵的成本是值班,而不是文档?
- 弃用公告里最容易被漏掉的是哪一条?
- 版本号放路径还是 Header,判断依据是哪三条?
- 灰度版本与并行版本在"失败方式"上有什么不同?