接口一旦对外发布,就不再只是自己的代码了。调用方分布在各个业务线,甚至还有外部客户在用,这时候想改一个字段名、删一个废弃参数,都不是改完代码部署上线那么简单。API版本管理的核心目标,就是让v2能够安全上线,同时v1在过渡期内继续稳定服务,最终以可控的节奏完成整体迁移。这篇文章从版本标识设计、代码实现、迁移节奏三个方面,把这件事讲透。

一、版本标识放哪里:三种方案的取舍
做版本管理,第一步是决定版本号写在哪里。常见做法有三种:URL路径、请求头、查询参数。
URL路径版本是最直观的方式,形如/api/v1/users和/api/v2/users。优点是肉眼可见、调试方便、 CDN和网关都容易做路由,Swagger文档也能天然区分版本。缺点是 purists 会说这违反了REST“一个资源一个URI”的原则——严格来说,版本变了URI也变了,意味着资源身份变了。不过工程实践中这个缺点几乎可以忽略,绝大多数公司(包括Stripe早期、Kubernetes)都采用这种方式。
请求头版本相对优雅,URI保持不变,通过Accept头或自定义头传递版本信息,例如Accept: application/vnd.myapp.v2+json。这种方式让资源URI稳定,也方便做内容协商,但调试成本高,浏览器直接访问看不到效果,文档工具支持也不如路径版本友好。查询参数版本(/api/users?version=2)则介于两者之间,实现简单但容易和业务参数混淆,一般不推荐作为主方案。
实际建议是:面向外部、需要长期演进的公开API,优先用URL路径版本;内部微服务之间调用频繁、契约统一管理的,可以用请求头版本减少路由层复杂度。无论选哪种,务必保证全公司统一,不要出现一半接口在路径里带版本、另一半在header里的混合状态,那会是一场维护灾难。
二、代码实现:一套业务逻辑支撑多个版本
很多人对版本管理的恐惧来自“是不是要复制一份代码”。如果v2只是把v1的Controller整体拷贝一遍,改几个字段,短期没问题,但三五个版本之后,代码会膨胀到没人敢动。正确的做法是分层:版本只体现在入口层,业务逻辑尽量共用。
以Spring Boot为例,入口层用不同的包或路由区分版本,内部委托给同一个Service,只做请求响应结构的转换:
package com.demo.api.v2.controller;
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
private final UserService userService;
public UserControllerV2(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public UserVOV2 getUser(@PathVariable Long id) {
UserDTO dto = userService.findById(id);
// v2只是转换了展示结构,业务逻辑复用v1的Service
return UserVOV2.from(dto);
}
}这里的关键在于UserService完全不感知版本,v1和v2的Controller各自负责VO(视图对象)的组装。当v2需要真正的行为变更时,再在Service层通过策略或参数区分,而不是从Controller开始就分叉。转换逻辑建议集中放在独立的Converter类里,方便单元测试覆盖字段映射关系。
如果用的是Nginx或Kong这类网关,还可以在网关层做版本路由:v1流量打到旧服务实例,v2流量打到新服务实例,物理隔离两套部署。这种方案适合v2做了大规模重构、代码结构完全不同的场景,代价是过渡期内双份资源成本。对于改动不大的升级,同进程内多版本共存是更经济的选择。
三、迁移节奏:废弃不是一刀切
版本管理最容易翻车的环节不是上线v2,而是下线v1。直接返回404或者封禁旧版本,会让没来得及改造的调用方瞬间故障,外部API甚至可能引发赔偿纠纷。规范的废弃流程应该分阶段推进。
第一步是预告。在v1的响应中加入废弃提示字段,同时通过HTTP响应头Deprecation和Sunset告知废弃时间点,这是IETF标准化的做法:
HTTP/1.1 200 OK
Deprecation: version="v1"
Sunset: Wed, 31 Dec 2025 23:59:59 GMT
Link: </api/v2/users>; rel="successor-version"
Content-Type: application/json
{"id": 1, "name": "test", "_deprecation_notice": "v1 will be removed, please migrate to v2"}第二步是监控。通过访问日志统计v1各接口的调用方分布,逐一确认迁移进度。可以按调用方设置不同的迁移时间表,对内部团队可以直接定deadline,对外部客户则要给出足够的缓冲期,通常至少三到六个月。对于迟迟不迁移的调用方,可以采取限流、降级响应速度等软性手段施压,但不要偷偷改数据。
第三步才是正式下线。下线前保留一到两周的“仅报错期”,让v1返回明确的错误信息和迁移文档链接,帮助漏网的调用方快速定位问题。下线后监控错误日志一段时间,确认没有残留流量再清理代码。整个过程建议维护一份变更日志(Changelog),明确记录每个版本新增、修改、废弃了哪些字段,这是调用方判断升级成本的唯一依据,写得好能省掉大量沟通成本。
四、几个实战中踩过的坑
除了流程层面,有几个技术细节值得提前注意。首先是向后兼容的定义要清晰:新增字段是兼容的,删除或改名字段是不兼容的,修改字段语义(比如status从字符串改成数字)是最危险的一种,因为它不会立刻报错,只会在业务逻辑上悄悄出错。对于不兼容变更,坚决升大版本;对于兼容性变更,尽量在原版本内迭代,不要滥用版本号。
其次是版本数量要克制。同时在线的版本不要超过两到三个,每个活跃版本都是一份维护成本和安全补丁负担。当v3上线时,v1应该已经进入下线流程,形成滚动的版本生命周期。
最后是文档与Mock同步。v2接口上线的同时,文档、SDK、Mock服务必须同步更新,最好做到接口定义即文档(如OpenAPI规范驱动开发),避免出现调用方按旧文档接入新版本的乌龙。版本管理本质上是对调用方的承诺管理,承诺清晰、变更透明、退出有序,API才能长久健康地演进下去。