一个内部 API 要改返回结构:原来返回数组,改成返回带分页信息的对象。团队选了"在 URL 上加 /v2",理由是"清晰"。半年后有了 v3,再半年有人问"v1 还有谁在用"——没人答得出,因为 v1 的调用方从来没被统计过,而它还在被一个两年前没人维护的脚本调用。
这也决定了版本化的本质:它不是技术方案,而是一份承诺的期限。加版本号的那一天起,就默认承诺"这个版本会在一段时间内保持可用",而这个期限没人写下来,于是它变成无限。
版本化的难点不在"怎么加版本号",而在什么时候可以去掉旧版本。而能不能去掉,取决于你知不知道谁在用。
1. 三种常见做法
| 做法 | 形态 |
|---|---|
| URL 路径版本 | /api/v2/orders |
| 请求头版本 | Accept: application/vnd.x.v2+json |
| 查询参数版本 | /api/orders?version=2 |
三种做法还能混用,而混用是最坏的情况:版本既在路径里又在头里,于是"哪个优先"成了每次评审都要重新讨论的问题。选一种,并且在网关层统一强制。
第一种最流行,因为它可见、可缓存、易调试;代价是URL 与版本绑死,任何跨版本的逻辑都要在路由层分流。
第二种最"语义正确",因为版本属于表示的协商;代价是不可见、不易手工调试,且中间层(CDN、网关)可能剥掉自定义头。
第三种最少用,主要问题是版本混在业务参数里,容易被漏传或默认成错的值。
2. 向后兼容的五条纪律
| 纪律 | 违反后果 |
|---|---|
| 只加字段不删字段 | 老客户端解析失败 |
| 不改字段语义 | 静默错误 |
| 不改字段类型 | 解析失败或精度丢失 |
| 不改枚举含义 | 逻辑错但无报错 |
| 默认值不变 | 行为悄悄变化 |
第三项最危险的是"看起来无害"的改动:int 改 long、时间戳从秒改毫秒都会让老客户端算出离谱的值,而服务端一切正常。
第四项更隐蔽:同一个枚举码新增了含义(如 3 从"已取消"变成"待审核"),老客户端会按旧含义处理,不报错,只是做错。
// 兼容性的做法:新增并行字段,而不是改旧字段
// v1: {"status": 3} 3=已取消
// v2: {"status": 3, "statusCode": "PENDING_REVIEW"}
// 迁移期两字段并存;老客户端继续读 status(保持旧语义)
// 新客户端读 statusCode;等旧客户端清零后,再考虑让 status 跟随新语义
3. 那个不可逆的决定
| 决定 | 可逆性 |
|---|---|
| 加版本 | 可逆(可以不再增加版本) |
| 弃用某版本 | 依赖调用方是否可知 |
| 修改旧版本行为 | 不可逆(有客户端已适配) |
弃用流程里最容易被跳过的是"通知已知调用方":公告发在文档里,只有主动查看的人才知道。有效的做法是逐一向已知调用方发出带日期的通知,并要求回执——回执本身就能确认"这个调用方还在"。
第三项是不可逆的:一旦某个行为被写进某个客户端,改它就是在改契约。这也解释了为什么"我们悄悄改一下,应该没人注意"总是错的——没人注意只说明没人监控,不说明没人用。
// 弃用流程:三步,每步都有可观察的门槛
deprecation:
announce: "2026-11-10" # 响应头 + 文档 + 通知已知调用方
warning_header: "Sunset: 2027-03-01; Deprecation: true"
usage_threshold: "30 天 0 次调用" # 达标才进入下一步
remove_after: "2027-03-01"
4. 怎么知道谁在用
| 手段 | 覆盖 |
|---|---|
| 访问日志按 UA/来源统计 | 已知客户端 |
| 调用方注册表 | 内部系统 |
| 响应头加弃用标记 | 主动发现者 |
| 客户端主动上报 | 需客户端配合 |
区分调用方有个低成本做法:在 API 网关上强制要求来源标识(应用名或调用方 ID),并在日志里落成独立字段。这比事后从 UA 里猜准确得多,且一次投入长期受益。
// 调用方标识的三个要求
1. 必填:缺失即拒绝,避免"匿名调用"存在
2. 稳定:与部署单元解耦(改名不变)
3. 可追溯:能映射到负责人与仓库
// 有了这三条,"谁在用 v1"才是一个可回答的问题
第一项是最可靠的基础手段,但要落地一个前提:能区分调用方。如果所有内部服务都用同一个 token,日志里就只能看到"有人在调"。
第四项看似最弱,其实最能覆盖未知客户端:把弃用信息放进响应头,让任何持久的客户端都可能在日志里留下痕迹。
// 从访问日志里统计 v1 的调用方分布(今天 / 最近 30 天)
awk '$7 ~ /^\/api\/v1\// {print $1}' access.log | sort | uniq -c | sort -rn | head -20
// 用两个时间窗对比:还在增长的调用方需要单独沟通
5. 版本数量与维护成本
| 版本数 | 成本形态 |
|---|---|
| 1 | 无兼容负担 |
| 2 | 双份测试与文档 |
| 3+ | 每增加一个版本,矩阵式增长 |
第二项容易被低估:双版本意味着两套集成测试、两套监控基线、两份排障经验。而且旧版本的问题往往由不熟悉它的人来处理。
所以"能兼容就不加版本"是更省的选择:加字段、加可选参数、加并行接口,都比加版本便宜。版本应当是"结构性变化"时才动的工具,而不是"每次想改就用"的开关。
// 加版本 vs 加字段:成本对照
加字段:改一处 DTO,老客户端忽略新字段 → 成本 1x
加并行接口:多一个入口,共享内部逻辑 → 成本 2x
加版本:路由、测试、文档、监控、弃用流程 → 成本 5x+
// 结论:能用前两种解决,就不要开新版本
6. 排错:五种"版本化带来的问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 老客户端悄悄失效 | 改了字段类型/枚举 | 恢复旧语义,新建字段 |
| 无法判断能否下线 | 调用方不可知 | 先建可区分的身份 |
| 网关剥掉版本头 | 中间层配置 | 改用路径版本 |
| 两个版本行为不一致 | 逻辑分叉后各自演进 | 共享核心逻辑 |
| 文档与实现不符 | 文档未随版本更新 | 文档从契约生成 |
缓解方式是"版本只作用于外壳":路由与 DTO 分版本,业务逻辑共享一套。这样修正业务规则时只改一处,版本间不会出现算法差异。
第四项是长期成本的核心:版本分叉后,同一业务规则会有两份实现,修正一处忘另一处,于是"同一笔订单在两个版本里算法不同"。
7. 边界:什么情况不必版本化
| 场景 | 为什么 | 做法 |
|---|---|---|
| 单一内部客户端、可同步升级 | 无并存需求 | 直接改 |
| 纯新增字段 | 向后兼容 | 不加版本 |
| 试点/实验接口 | 无承诺 | 标注 beta |
| 已明确弃用且无调用 | 无并存需求 | 直接删 |
最后一条与"承诺"有关的边界:版本号一旦发布,就不要回收再用。把 v2 弃用后让 v2 指向新语义,会让所有缓存、文档与旧客户端的判断同时失效,而这类错误没有任何报错。
判断口径一句话:这个改动会不会让已经在跑的客户端做错事。会,就要兼容或版本化;不会(纯新增、无人调用),直接改更快。
8. 自测
- 三种做法的核心代价各是什么?为什么请求头版本"语义正确"却不易调试?
- 五条兼容纪律里,哪两项会造成"服务端正常但客户端算错"?
- 为什么"修改旧版本行为"是不可逆的?"没人注意"说明了什么?
- 判断"能否下线一个版本"的前提是什么?为什么这一步常被跳过?
- 加字段、加并行接口、加版本的成本比例大约是多少?为什么版本的成本是矩阵式的?
- 判断"是否需要版本化"的那个问题是什么?