
每个 API 团队迟早都会伸手去拿同一个逃生出口:
“这是破坏性变更,我们直接做 v2 吧。”
听起来负责任。但实际效果是:你现在要维护两套 API、两份文档、两种行为差异,以及一个客户端会无限期推迟的迁移项目。
Milan Jovanović 在这篇文章里说得很直接:
版本控制是兼容性工具,不是设计策略。
大多数 API 变更不需要新版本,它们需要的是更好的变更管理。这两件事的区别,决定了你的 API 是越来越难维护,还是能平稳演进很多年。
什么真正会破坏客户端
很多人以为,只要不改 URL 就没有破坏性变更。实际上,客户端在以下几种情况下会挂掉:
- 删除或重命名字段
- 改变现有字段的含义
- 收紧请求校验(原本可选的字段变成必填)
- 改变分页或错误格式
- 把 enum 当成封闭集合(后续加新值时客户端崩溃)
这个例子很能说明问题:
// 原来
{ "total": 100 }
// 之后
{ "total": { "amount": 100, "currency": "USD" } }
路径没变,端点名没变,但你把一个数字字段改成了对象。所有读取 total 的客户端直接挂了。
所以问题不是”这个变更要不要升版本”,而是”新旧契约能不能安全共存?“
四条兼容性规则
这四条规则直接来自 Z. Nemec 的 API 变更管理文章,是判断一个变更是否可以向后兼容的核心框架。
规则一:增加,而不是替换
最安全的变更是加法。如果要给字段增加更丰富的语义,不要把旧字段改成新形状——加一个新字段,让新旧客户端各取所需。
GET /orders/{id}
// 原来
{
"id": "ord_123",
"status": "paid",
"total": 100
}
// 演进后
{
"id": "ord_123",
"status": "paid",
"total": 100,
"totalMoney": {
"amount": 100,
"currency": "USD"
}
}
旧客户端继续用 total,新客户端迁移到 totalMoney,你把旧字段标记为 deprecated,等真正的迁移窗口结束后再删。
有时候这样做契约会变得不那么优雅。但兼容性的代价,有时候就是一个不那么好看的契约。
规则二:让客户端能容忍新字段
服务端加了一个字段,行为良好的客户端不应该崩溃。
如果响应从这个演变:
{ "id": "ord_123", "status": "paid" }
变成这个:
{ "id": "ord_123", "status": "paid", "estimatedDeliveryDate": "2026-05-29" }
旧客户端应该忽略 estimatedDeliveryDate 然后继续工作。
在 .NET 里,System.Text.Json 默认忽略未知字段,这个行为是对的。真正的风险在于过于严格的自动生成 SDK,或者断言 JSON 精确相等的契约测试。
Milan 把这个问题说得很准:
团队声称要向后兼容,却生成了一旦响应里出现意外字段就报错的客户端模型。那不是兼容策略,那是给自己挖坑。
服务端应该自由地添加可选数据,客户端应该有足够的弹性来忽略它不理解的内容。
规则三:不要改变已有操作的行为
这是最容易被低估、也最危险的一类变更。URL 没变,请求体没变,响应结构没变,但服务端对这个操作做的事情变了。
以 DELETE /orders/{id} 为例。
最初上线时,这个端点是软删除:订单移入归档状态,还在数据库里,还出现在审计报告里,支持团队可以恢复。
客户端建立的契约不只是 HTTP 动词和路径,而是完整的行为:
- 订单可以恢复
- 相关发票和物流记录不受影响
- 审计历史保留
- 重试同一个调用是安全的
几个月后团队觉得软删除太麻烦,悄悄把 DELETE /orders/{id} 改成了硬删除:
- 代码审查时没人注意到
- SDK 调用照常编译通过
- 响应还是
204 No Content - 但一个之前调用 DELETE 后还能”撤销”的支持工具,现在会静默地销毁数据
Nemec 的规则说的就是这个:一旦一个操作上线,它的行为就是契约的一部分,哪怕从来没有写在任何文档里。
类似的隐蔽破坏还有很多:
POST /orders原来幂等,之后悄悄停止了POST /orders/{id}/cancel原来自动退款,之后不再退款了PUT /orders/{id}原来是全量替换,之后变成了部分合并
每一种都保持了 URL、动词和 JSON 结构不变,但都会在 schema diff 里看不出来。
安全做法还是同一个:增加,不要修改。如果你需要硬删除,新开一个操作:
DELETE /orders/{id} # 保持不变,软删除
DELETE /orders/{id}?purge=true # 新增,显式硬删除
或者引入新资源:DELETE /orders/{id}/purge,让破坏性操作有自己的名字和权限。
规则四:对校验改动保持谨慎
这里有两种常见错误:
- 把原本可选的字段变成必填
- 新加一个字段,直接要求必填
两种都会以同样的方式破坏旧客户端——端点路径不动,但原来能成功的请求开始在运行时报错。
以 POST /orders 为例:
// 昨天这个请求有效
{ "customerId": "cus_123", "currency": "USD" }
// 今天要求必须传 country 用于税务计算
{ "customerId": "cus_123", "currency": "USD", "country": "US" }
更安全的路径是:为旧客户端接受缺省值,尽可能从已有数据中推断默认值,或者为更严格的流程开一个新操作。
响应变更通常会经过仔细的设计评审,请求校验的变更也应该如此。
新端点往往比新版本便宜
有时候确实是场景变了,在一个老端点上堆 flag 和可选参数会越来越混乱。比如:
POST /orders?validateOnly=true&includeTaxEstimate=true&reserveInventory=true
这时候你有的不是一个干净的操作,而是多个业务流程藏在同一个端点后面。
这种情况下,新操作或新资源通常比升整个 API 版本便宜得多:
POST /orders保持原样,只做”下单”POST /orders/quote做”询价”POST /checkout-sessions支持更丰富的引导式结账流程
这样旧契约稳定,新行为有干净的落脚点,而不需要把整个 /v2/orders 以及 API 里其他所有东西都拖着一起升级。
真正的废弃是有运营动作的
大多数废弃是虚的——出现在文档里,但什么运营动作都没有。
一个真正的废弃流程应该包含四件事:
-
在 OpenAPI 描述里标记字段或端点为 deprecated
-
在运行时通过响应头发出信号
Deprecation: true Sunset: Sat, 01 Jan 2027 00:00:00 GMT Link: <https://api.example.com/docs/migration>; rel="deprecation" -
给调用方一个清晰的迁移路径
-
在真正收到足够少的流量之前,不删除任何东西
如果你不知道哪些客户端还在使用已废弃的字段或端点,你就不是在管理变更,你是在猜。
按 client ID、API key、租户或应用名追踪使用量,等到实际使用量接近零,再考虑删除。
什么时候升版本才是正确的
Milan 很直接地说:他不反对版本控制。
以下情况才应该升版本:
- 新旧语义无法安全共存
- 资源模型发生了根本性变化
- 兼容性规则会迫使你创造出一个无人能理解的契约
**升版本要刻意,而不是本能反应。**刻意意味着选择你能证明的最小破坏面——有时候是新的端点形状,有时候是表示变体,有时候(尤其对公共 API 来说)就是直接的 URL 版本号,因为它直观且好沟通。
关键不在于你选择哪种机制,而在于你是因为共存真的失败了才升的版,而不是因为这是第一个想到的办法。
升版本时,搭配一个真实的废弃流程:
- 给 v1 一个明确的废止时间
- 监控谁还在用 v1
- 等到真正有人迁移走,而不只是让 v1 无限期存在
真正的工作不是创建 v2,而是让用户从 v1 迁移出去。
一个决策规则
如果想要一个简单的判断依据:
老的和新的能不能在一段迁移期内安全共存?
如果是,大概不需要新版本。如果不是,新旧世界真的无法并存,那就刻意地升版本。
把契约设计成可以演进的,把调用方当成长期集成伙伴,把版本控制留给兼容性真正耗尽的时候。
参考
- API Versioning Should Be Your Last Resort — Milan Jovanović
- API Change Management — Z. Nemec