JSON 工具箱

JSON 契约的版本演进:兼容、弃用与数据迁移

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

实战约 10 分钟发布于 2026-10-31更新于 2026-10-31
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 数据契约设计要点 里的演进表格。

四、弃用窗口怎么开

弃用不是发一条公告就完事,而是一段有起点、有观测、有终点的窗口。一个可执行的节奏:

  1. 冻结:旧字段停止新增写入,只保留读取;
  2. 声明:文档、变更日志、响应头三处同时标注下线时间;
  3. 观测:按调用方维度统计旧字段读取量,直到连续两个周期归零;
  4. 删除:窗口结束、观测归零、回归通过后,才移除字段与兼容代码。

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 的记忆,要靠测试。两层就够:

  1. 结构测试:用 JSON Schema 校验响应,但不要对消费端使用 additionalProperties: false;
  2. 消费者子集测试:把旧消费者真正依赖的字段抽成子 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 校验全绿、监控也不报错,只有业务指标慢慢偏,是最难排查的一类破坏性变更。凡是含义变化,一律视为新字段,旧字段冻结语义,再用弃用窗口收尾。