我们团队用版本号管接口兼容性踩过的坑,以及为什么最后选了消费者驱动契约测试
我们一开始也是老老实实用版本号管接口兼容性,URL 里带 /v1、/v2,请求头里塞 Accept-Version,自以为万无一失。两年下来,版本号膨胀到 v17,线上同时跑着 9 个版本的实例,出一次事故才让我们彻底放弃这条路,转向了消费者驱动契约测试。这篇文章把中间踩过的坑和迁移过程完整还原出来。
版本号策略在前 6 个月是可行的,然后开始反噬
2021 年初,我们拆了订单、用户、商品、库存四个微服务,团队定了两条铁律:接口变更必须升版本号,旧版本至少保留一个大版本周期。听起来很合理,前 6 个月也确实没出什么大问题。
转折点在订单服务拆出第 4 个子模块之后。订单查询接口 /api/v3/orders 里有一个 discountDetail 字段,营销团队说数据结构要改,从对象改成数组。按规则,我们在响应体里同时保留 discountDetail(旧字段)和 discountDetails(新字段),版本号升到 v4,通知所有消费方在 4 周内完成迁移。
问题来了:有 3 个内部消费方根本没读迁移邮件,另有 2 个第三方合作方说「排期要等两个月」。同时产品又催着上新功能,商品接口也要改字段。版本号就这样被推着往前走——v5、v6、v7 叠加上去,每个版本都宣称「向下兼容」,但实际上没有人能说清 v5 到底兼容了哪些旧字段。
到 2022 年 8 月,线上同时存在订单服务的 v3 到 v9 共 7 个版本实例。运维团队部署一次灰度要协调 11 个消费方的版本矩阵,回滚成本高到没人敢动。我们统计过,那段时间 60% 以上的发布事故根因是「消费方用了预期之外的接口版本」。
「兼容」二字被我们严重滥用了
回头看,核心问题不是版本号本身,而是我们对「兼容」的定义太宽松。每次加字段都叫「向下兼容」,删字段都叫「已废弃但暂未删除」,但实际上:
- 一个字段被标记
@Deprecated后,到底有没有消费方还在用?没人知道。 - 字段类型从
String改成Integer算不算兼容?文档上写的是「请使用新字段」,但老字段的值类型已经变了,消费方解析直接抛异常。 - 同一个字段在不同版本里语义不同——v3 的
status有 5 种枚举值,v5 扩展到了 8 种,消费方代码里的if-else分支没覆盖新增枚举,兜底逻辑写的是else { throw new Exception() }。
2023 年初出过一次 P0 事故:商品服务把 price 字段从 Double 改成 String(为了支持精确小数),版本号升到 v11,声明了 v10 保留 6 周。但库存服务的某个定时任务用的是 v9 接口,那个版本的 price 还是 Double,而商品服务 v9 实例已经在 3 个月前下线了。定时任务直接空指针,凌晨 2 点库存数据全乱了。
这件事让我们意识到:版本号只能描述服务端「提供了什么」,但描述不了消费方「实际用了什么」。 任何没有消费方信息的兼容性声明,本质上都是服务端的一厢情愿。
废弃字段的下线节奏,没有数据支撑就是赌博
我们尝试过用日志和链路追踪来统计字段使用情况。在网关层加了解析逻辑,记录每个请求用到了响应体的哪些字段路径。方案本身可行,但有两个致命缺陷:
第一,统计口径受限。网关只能看到 HTTP 层面的字段引用,但有些消费方把响应体反序列化后做二次分发,下游再用到哪些字段,网关完全无感知。
第二,统计周期太被动。我们定的规则是「连续 7 天无访问的字段可以下线」,但因为业务有周期性,双十一期间不用的字段,节后可能突然被某个报表任务调用。我们不敢仅凭 7 天的数据删字段,结果就是字段越积越多,接口响应体膨胀到几百 KB。
到 2023 年中,订单查询接口的响应体里,实际有效的业务字段只占 40%,其余全是历史遗留的「兼容字段」。每次序列化都在浪费 CPU,但没人敢删。
消费者驱动契约测试怎么落地的
我们最终选了 Pact 这套框架,核心思路反过来:不是服务端声明兼容什么,而是消费方声明自己需要什么,服务端据此验证自己能不能满足。 这个方向上的切换解决了上面所有问题。
第一步:消费方用单元测试生成契约文件
每个消费方在自己的代码仓库里写一段契约测试,声明对服务端接口的期望。比如订单服务的某个消费方只需要 orderId、amount、status 三个字段:
// 消费方(如营销服务)的测试代码
@ExtendWith(PactConsumerTestExt.class)
@Pact(consumer = "marketing-service", provider = "order-service")
public RequestResponsePact createOrderPact(PactDslWithProvider builder) {
return builder
.given("order #1001 exists")
.uponReceiving("a request for order details")
.path("/api/orders/1001")
.method("GET")
.willRespondWith()
.status(200)
.headers(Map.of("Content-Type", "application/json"))
.body(new PactDslJsonBody()
.integerType("orderId", 1001)
.decimalType("amount", 99.99)
.stringType("status", "PAID"))
.toPact();
}
这段代码跑完之后,Pact 会在 target/pacts 目录下生成一个 JSON 契约文件,精确描述营销服务对订单接口的期望。
第二步:契约文件上传到 Broker,服务端拉取验证
我们搭了一个 Pact Broker(用 Docker 部署的 pactfoundation/pact-broker),消费方在 CI 流水线里把契约文件推上去。服务端每次提交代码时,拉取所有消费方的契约,逐个验证:
// 服务端(订单服务)的验证测试
@Provider("order-service")
@PactBroker(url = "https://pact-broker.internal.com")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class OrderProviderPactTest {
@LocalServerPort
int port;
@BeforeEach
void setUp() {
// 启动服务端实例,指向随机端口
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verifyPact(PactVerificationContext context) {
context.verifyInteraction();
}
@State("order #1001 exists")
void prepareOrder() {
// 在测试数据库插入订单 #1001
}
}
这段测试会启动订单服务的真实实例(连测试数据库),用契约文件里的请求去调,比较实际响应和消费方期望是否一致。如果服务端删了 status 字段,但营销服务的契约里声明了需要它,测试直接挂掉,CI 流水线阻断合并。
第三个关键点:Can I Deploy 检查
我们在 CI 里集成了 pact-broker can-i-deploy 命令。每次服务端准备发布到生产环境时,流水线会先问 Broker:「当前版本能不能安全部署?有没有消费方的契约还没被满足?」如果有,直接阻止发布,并在 Slack 通知对应消费方的负责人。
这反向推动了一个行为变化:消费方要升级契约时,必须在自己的 CI 里先跑契约测试,把新契约推上去,服务端那边才会通过 Can I Deploy 检查。整个兼容性管理的责任从服务端单方面承担,变成了双方在流水线上自动协商。
废弃字段的下线节奏终于有了硬数据
切到 Pact 之后,我们不再靠日志统计字段使用情况。一个字段能不能删,看的是「还有没有消费方的契约声明了这个字段」。如果有,删了会导致那个消费方的契约验证挂掉,CI 直接卡住。如果没有,说明所有已知消费方都不再依赖这个字段,可以直接删。
我们在 2023 年 Q4 用这个机制清理了订单服务里 23 个历史遗留字段,整个过程的决策依据是契约文件,不是拍脑袋。唯一需要注意的是,消费方必须把所有对接口的依赖都写进契约测试——我们内部强制要求,契约覆盖率低于 80% 的消费方不允许部署到生产环境。
版本号也没完全废弃,只是退居二线
迁移到 Pact 之后,我们并没有立刻删掉所有版本号。对于第三方合作方(他们没法接入我们的 Pact Broker),API 网关层依然保留 URL 版本号 /v1、v2,但内部服务的版本号全部去掉了。内部接口的 URL 统一不带版本标识,兼容性完全由契约测试保证。
网关对外暴露的版本号也不再和服务端内部版本绑定。网关做了一层映射,把外部 /v1 的请求转发到内部无版本号的接口,响应体按外部约定裁剪字段。这个裁剪逻辑也是用契约文件驱动的:外部合作方和我们的契约也走 Pact,只不过契约文件由我们代写,放在 Broker 里统一管理。
成本与收益的实话
引入 Pact 不是零成本的。我们团队花了约 3 周做 PoC,2 个迭代在全团队推广。最大的成本不是写测试代码,而是教会所有人理解「消费方驱动」的思维转变——服务端开发习惯了「我提供什么你用什么的」姿态,现在要反过来接受消费方定义接口需求,心理上有落差。
Broker 的运维成本很低,我们用 Docker Compose 部署,PostgreSQL 做存储,日常不需要管。CI 流水线增加契约验证步骤后,构建时间平均多了 40 秒到 2 分钟(取决于消费方数量),在可接受范围内。
收益方面,最直观的数字:2023 年上半年(版本号策略时期),接口兼容性相关的事故 11 起,平均恢复时间 47 分钟。2024 年上半年(契约测试全覆盖后),同类事故 0 起。废弃字段清理周期从「永远不敢删」变成「两周内可以安全下线」。
常见问题
Pact 只适用于 HTTP 接口吗?消息队列和 gRPC 怎么办?
Pact 有对应的扩展。消息队列场景用 Pact Message,消费方声明期望的消息格式,生产方验证自己发出的消息是否符合契约。gRPC 可以用 Pact gRPC 插件,原理类似,契约文件描述的是 protobuf 的 message 结构。我们团队主要用 HTTP + JSON,消息队列部分用 RabbitMQ,Pact Message 也覆盖了。
我们团队只有两三个服务,也需要上契约测试吗?
两三个服务的阶段反而是最佳引入时机。服务少意味着契约关系简单,Broker 配置和测试编写的工作量很小,一个下午就能跑通。等拆到十几个服务再回头补契约测试,历史债务会很重。我们就是后悔没在一开始拆服务时同步引入。
如果消费方没写契约测试就直接部署了,服务端改了接口不就挂了?
这就是 Can I Deploy 检查的价值。我们强制要求消费方在 CI 里跑契约测试并推送契约文件,如果某个消费方没写,服务端部署时 Broker 里就没有它的契约记录,部署检查默认按「无契约 = 无约束」通过。所以关键是流程上卡死:消费方必须先在 Broker 里注册并推送契约,否则不允许部署。我们在 Kubernetes 的 admission webhook 里加了这个检查,技术上强制卡住。