本节摘要:契约必然要修订,版本控制回答"怎么改、改得大家都安全"。三种主流策略——URI 版本、Header 版本、查询参数版本——各有取舍;破坏性变更与兼容性变更要分开管理(前者升大版本,后者原地兼容)。本节用一个"图书字段演进"案例,带你分清该升版还是该兼容,并给出版本生命周期的处理建议。
想象一个没有版本观念的接口:产品说"把 author 字段改成 authorName",开发照着改了,顺手压缩了状态码范围,一起发了上去。第二天,十几个对接方一起崩——有的客户端还在用 author,有的依赖旧状态码,没人提前接到通知。这场事故不是"改错了",而是"契约没有版本,改动就没有边界"。版本号存在的全部意义,就是给每一次改动划清一条线,让线的这一边(升级方)和那一边(旧客户端)各自安好。下面从改动为什么必然发生讲起。
阅读完本节,你应当能够:
没有哪个业务方案第一次就对。加了字段、改了名字、拆了模块,接口就得跟着动。但契约一动,客户端全部跟着动——这就是"修订号"(版本)存在的意义:给变化划一个边界,让"我用的这一版"在变化中仍能稳如泰山。
问题不在"要不要版本"(要),而在"版本放哪、怎么算破坏、何时升版"。把这三问过一遍,版本体系才有骨架,而不是随缘贴个 /v2 完事。
把三个主流位置对比,各自的取舍立刻浮出水面:

| 策略 | 放哪 | 优点 | 代价 |
|---|---|---|---|
| URI 版本 | /v1/users |
缓存、日志、mock 直观;客户端一眼可见 | 把"版本"这件非资源的事塞进地籍;每次都要维护路径 |
| Header 版本 | Accept: application/vnd.myapp.v2+json |
符合自描述思想,不动 URI | 旧客户端与调试工具不友好;报错难排查 |
| 查询参数版本 | /users?version=2 |
实现最简单 | 语义弱、易混入其他参数、缓存分级难做 |
URI 版本把版本号放最前(/v1/users),好处是缓存、日志、mock 都直观,坏处是把"版本"这件非资源的事塞进了资源地籍;Header 版本用媒体类型协商(Accept: application/vnd.myapp.v2+json)更符合 REST 的自描述思想,但旧客户端与调试工具往往不友好。两者没有绝对优劣,视团队与生态而定。实操上的主流是"对外都走 URI 版本",因为它最容易被人、缓存、网关一眼识别;只在纯内部服务间才考虑用 Header 协商省掉路径负担。
版本讨论最容易失焦的是"哪类变化算破坏"。其实就一张成色表:
判断能不能"原地兼容",就看"此刻在线上跑的旧客户端会不会被弄坏"。加字段不坏、删字段会坏、改名会坏、同一含义换成另一种状态码会坏——记住这条线,比记一条条规则有用。
举一个真实场景。图书接口要加"版次"字段:
GET /book/42 的返回里加一个 revision 字段。旧客户端忽略它,完美兼容,原地做即可。author: "小七" 改名为 authorName。所有读 author 的旧客户端崩,必须升版本或以别名兼容。"以别名兼容"这条可以再拆细一层,它常是破坏性变更的软着陆。上面做法 B 若实在不想升大版本,可以保留 author 作为旧字段别名、同时新增 authorName,让新客户端用新名、旧客户端继续读旧字段,双方各取所需,等旧字段调用量清零后再在下一次大版本里彻底删除。用别名兜一路,能大幅推迟"不得不升版"的时点——阶梯升级的成本,往往低于一次性硬切。但这不等于无限期留着,别名本身也要纳入生命周期管理,否则又堆出一排僵尸字段。
成熟接口对版本有完整生命周期,防止"版本字典无限膨胀":
Deprecation 头与合理替代提示,持续提醒迁移,但不强制。用一组数字把生命周期走得具体。2024 年你发布 v2,同时给 v1 打上"v3 发布即弃用"的标记;两年后 v2 调用量占比到 90%,有团队开始对 v1 发 Deprecation 响应头,提醒尽早迁移;三年后 v1 调用量降到个位数,才执行下线,且保留错误页面的迁移指引。这套节奏的核心是:升版不靠拍板,靠"调用量统计 + 提醒期 + 冷静期"三步走,任何一步没走完都不硬切。没有生命周期管理的接口,最后都会堆成一排谁也不敢动的僵尸版本。
⚠️ 常见坑:在一个版本里既做兼容又偷偷做破坏性变更,客户端会被"悄悄改坏"。正确做法是把破坏性变更明确归入大版本,绝不混进兼容改动里。
💡 关键直觉:版本的真正定价是"破坏性变更的代价"。把变化分级(兼容/破坏),再决定放哪个抽屉(原地/升版),版本体系就不会失控。
版本把"契约怎么安全修订"定了下来,下一节我们把"怎么查这份契约"做成统一语法——过滤、排序、分页。