JSON 契约的版本演进:兼容、弃用与数据迁移
结论:给接口加字段不等于安全,破坏性变更往往藏在类型与语义里。这篇给出可执行的版本演进路径:兼容边界、版本字段与媒体类型、弃用窗口、消费者排查、双写迁移与回滚、契约测试,以及灰度发布的观察指标。

结论:版本演进按「先扩字段、再回填、最后收缩」的顺序做,并把版本字段写进文档;每次变更先确认消费端对未知字段与未知枚举有兜底,再决定是否发新版本,弃用则从冻结、声明、观测到删除走完整个窗口。
版本演进的难点不是改代码,而是你无法确认还有谁在读旧字段;兼容性从来不是单向的承诺,而是发布方与未知消费者之间持续对齐的过程。
一、兼容的边界:加字段未必安全
教科书说「新增可选字段」是向后兼容的,这句话只在消费者对未知字段宽容时成立。以下三种情况,加字段照样会炸:
- 消费者用
additionalProperties: false的 schema 做严格校验; - 消费者把对象按固定顺序遍历,或按位置而不是按键取值;
- 新字段与旧字段同名但含义不同,例如把
status从「订单状态」扩成「履约状态」。
真正安全的加法需要三个前提:字段可选、语义独立、消费端忽略未知键。发布前先确认这三个前提,再谈版本号。
{
"schemaVersion": 2,
"orderId": "ord-8842",
"status": "paid",
"refundDeadline": "2026-11-30T00:00:00+08:00",
"channel": "wechat"
}
这段响应里,refundDeadline 与 channel 是 v2 新增的可选字段,orderId、status 保持原语义;schemaVersion 随文档一起持久化,而不是只存在于 HTTP 头里。
二、不能做的破坏性变更清单
把下面这些变更视为「必须走版本或双写」的红线,不要指望文档更新能救:
| 变更 | 为什么破坏兼容 | 可接受的替代做法 |
|---|---|---|
| 删除字段 | 消费端读取直接得到 undefined |
先弃用并观测,窗口结束后再删 |
| 改字段类型 | "1" 变 1,强类型客户端直接报错,弱类型端也可能校验失败 |
新增字段名,旧字段双写过渡 |
| 新增必填字段 | 旧调用方没有该字段,校验必失败 | 给默认值,或只在新版本中设为必填 |
| 改枚举值集合 | 消费端的 switch 没有兜底分支 |
客户端先支持未知值,再扩枚举 |
| 改字段语义 | 类型没变,行为变了,最难排查 | 视为新字段,旧字段冻结语义 |
| 改嵌套层级 | 所有取值路径都要改 | 保留旧结构,新增平铺字段或 expand |
其中「改语义」最危险:schema 校验全绿,监控也没有报错,只有业务指标慢慢偏。凡是含义变化,一律当新字段处理。
三、版本字段放哪:文档内、URL 与媒体类型
三种常见位置各有适用面:
| 位置 | 示例 | 适用场景 | 局限 |
|---|---|---|---|
| 文档字段 | "schemaVersion": 2 |
数据库、消息队列、离线备份 | 传输层无法据此路由 |
| URL 路径 | /v2/orders/8842 |
对外公开 API | 资源地址与版本耦合,迁移成本高 |
| 媒体类型 | application/vnd.acme.order.v2+json |
需要内容协商的内部服务 | 调用方要正确设置 Accept |
一条实用分工:传输层用媒体类型协商,持久化数据必须写版本字段。消息队列里的旧消息不会因为你改了 URL 就自动升级,备份文件更不会。schemaVersion 放在文档里,重放、回填和审计才有依据。
GET /orders/ord-8842 HTTP/1.1
Host: api.bbdrug.com
Accept: application/vnd.acme.order.v2+json
如果确实要用 URL 版本,至少保证旧路径在弃用窗口内仍返回旧结构,而不是悄悄指向新实现。对外 API 的版本策略可参考 JSON API 数据契约设计要点 里的演进表格。
四、弃用窗口怎么开
弃用不是发一条公告就完事,而是一段有起点、有观测、有终点的窗口。一个可执行的节奏:
- 冻结:旧字段停止新增写入,只保留读取;
- 声明:文档、变更日志、响应头三处同时标注下线时间;
- 观测:按调用方维度统计旧字段读取量,直到连续两个周期归零;
- 删除:窗口结束、观测归零、回归通过后,才移除字段与兼容代码。
Sunset 响应头(RFC 8594)可以给出机器可读的下线时间;Deprecation 目前仍是 IETF 草案,可用但不要假设所有客户端都认识。两者配合 Link 指向迁移文档:
Deprecation: true
Sunset: Wed, 31 Mar 2027 23:59:59 GMT
Link: <https://bbdrug.com/docs/deprecations/order-status>; rel="deprecation"; type="text/html"
窗口长度按团队约定,常见下限是一个发布周期或 90 天,取两者中更长的一个。对外 API 建议更长,内部服务可以短一些,但都不能短于「所有调用方完成升级」所需的时间。
五、消费者排查:先找到谁在读
弃用前必须能回答「谁还在读」,否则窗口只是自欺欺人。按可信度排序的排查手段:
- 网关日志:按
appId、User-Agent、客户端版本聚合旧字段访问量; - 链路追踪:在序列化层给旧字段打 tag,trace 里能直接看到调用方服务名;
- 消息消费登记:队列的消费组名单比代码搜索更可靠;
- 仓库搜索:
rg一把梭只能覆盖自家代码,外部调用方仍然要靠日志。
rg --type ts --type js 'legacyStatus|statusCode' src/ --stats
搜索结果只能证明「本仓库有人在读」,不能证明「没有别人在读」。真正下线前,把网关日志按天导出,确认旧字段的读取量连续归零,再动手。
六、数据迁移:扩展、回填、收缩
数据迁移推荐 Martin Fowler 的 Parallel Change 三步法:先扩展(新旧字段共存、新旧版本都能读),再回填(把历史数据迁到新结构),最后收缩(删除旧字段与兼容分支)。顺序不能反,否则回滚无路可走。
// 迁移脚本骨架:按主键游标分批,条件写入保证幂等,失败可重跑
async function migrateBatch(collection, cursor, batchSize = 500) {
const records = await collection
.find({ _id: { $gt: cursor } })
.limit(batchSize)
.toArray();
if (records.length === 0) {
return { nextCursor: cursor, done: true };
}
const operations = records
.filter((record) => record.schemaVersion < 2)
.map((record) => ({
updateOne: {
filter: { _id: record._id, schemaVersion: record.schemaVersion },
update: {
$set: {
schemaVersion: 2,
status: record.legacyStatus ?? record.status,
refundDeadline: record.refundDeadline ?? null,
},
},
},
}));
if (operations.length > 0) {
await collection.bulkWrite(operations);
}
return { nextCursor: records.at(-1)._id, done: false };
}
几个容易被忽略的细节:
- 幂等:
filter带上旧版本号,重复执行不会把已迁移记录再写一遍; - 分批与断点:按
_id游标推进,记录 checkpoint,失败从断点续跑; - 演练:先在生产快照上跑一遍,比对迁移前后的记录数与校验和;
- 回滚:旧字段至少保留到新字段校验通过,切换读取路径用开关,不要直接改
schemaVersion回退; - 校验:迁移后跑业务约束(金额非负、状态机合法),不要只看 JSON 能否解析。
配置类数据同样适用这套流程,具体组织方式可参考 配置、夹具与本地数据的 JSON 管理。
七、契约测试:把兼容性写进 CI
兼容不能靠 code review 的记忆,要靠测试。两层就够:
- 结构测试:用 JSON Schema 校验响应,但不要对消费端使用
additionalProperties: false; - 消费者子集测试:把旧消费者真正依赖的字段抽成子 schema,断言新响应仍然满足。
// 契约测试:新响应仍需满足旧消费者依赖的字段与类型
import Ajv from 'ajv';
import assert from 'node:assert/strict';
import { test } from 'node:test';
const ajv = new Ajv({ strict: false });
const validateLegacySubset = ajv.compile(legacyConsumerSubsetSchema);
test('v2 响应仍满足 v1 消费者子集', () => {
const response = buildOrderResponse({ schemaVersion: 2 });
const { schemaVersion, ...legacyFields } = response;
const isValid = validateLegacySubset(legacyFields);
assert.equal(isValid, true, JSON.stringify(validateLegacySubset.errors));
});
子 schema 只声明旧消费者读取的字段及其类型,新字段一律放行。这样既能抓住「删字段、改类型」的破坏性变更,又不会因为加字段而误报。用例数据放进夹具目录,保持键有序、可 diff;夹具的合并冲突处理见 JSON 文件的 Git 冲突:union 会写出坏 JSON,按 key 合并才对。
八、灰度发布:按维度放量,按指标刹车
版本兼容问题几乎不会在单测里暴露,要靠灰度。放量维度建议从窄到宽:
| 维度 | 做法 | 适合验证什么 |
|---|---|---|
| 租户白名单 | 先给内部或友好客户开启新读取路径 | 真实数据形态差异 |
| 客户端版本 | 只对已升级到支持新结构的客户端启用 | 新老客户端混跑 |
| 流量比例 | 5% → 25% → 100%,每档观察一个完整业务周期 | 长尾调用与缓存命中 |
观察指标至少三个:接口错误率、契约校验失败率、新旧字段读取比例。建议设置自动刹车条件,例如 5xx 超过 1%、或旧字段读取量突然回升,就回退开关。灰度期间保持双写,让回退只需切读取路径,不必回补数据。
九、上线前的兼容检查清单
- [ ] 新字段可选,语义独立,没有复用旧字段名;
- [ ] 消费端对未知字段与未知枚举都有兜底;
- [ ] 版本字段随文档持久化,传输层版本可协商;
- [ ] 弃用窗口有公告、有
Sunset、有观测; - [ ] 旧字段读取量能从网关或追踪里定位到调用方;
- [ ] 迁移脚本幂等、分批、可断点续跑,且有回滚路径;
- [ ] 契约测试覆盖旧消费者子集,CI 里跑;
- [ ] 灰度有明确放量维度与刹车指标。
常见问题
新增可选字段一定兼容吗?
不一定。只有消费端忽略未知字段、不做严格 schema 校验时才兼容;如果对方用了 additionalProperties: false 或按位置取值,新增字段一样会失败。发布前用契约测试确认对方的宽容度。
schemaVersion 放在 URL 还是响应体?
传输层可以用媒体类型或 URL 区分版本,但落库的数据必须在文档里写 schemaVersion。消息队列、离线备份和审计都不经过 URL,只有文档内版本才能支撑重放与回填。
弃用字段能直接删吗?
不能。先停止写入,声明下线时间,按调用方统计读取量;窗口结束且读取量连续归零后再删。删除前跑一次全链路回归,确认没有隐藏的定时任务或报表在读取。
迁移脚本跑到一半失败怎么办?
设计成幂等任务:条件更新、按主键游标、记录 checkpoint。失败后从断点重跑,重复执行不会二次写入;回滚保留旧字段与快照,不要只回退 schemaVersion。
如何安排数据迁移的顺序?
按 Parallel Change 三步走:先扩展,让新旧字段共存、新旧版本都能读;再回填,把历史数据迁到新结构;最后收缩,删除旧字段与兼容分支。顺序不能反,否则回滚无路可走;迁移脚本要幂等、分批、可断点续跑。
为什么改字段语义要当新字段处理?
改语义时类型没变、schema 校验全绿、监控也不报错,只有业务指标慢慢偏,是最难排查的一类破坏性变更。凡是含义变化,一律视为新字段,旧字段冻结语义,再用弃用窗口收尾。