有一次我在接口里把一个看起来“无伤大雅”的字段从 number
改成了 string
,想着前端“肯定能兜底”。结果老版本 App 在关键页面直接白屏。那天之后,我开始很认真地对待“怎么在不影响用户的前提下,给 JSON 和 API 升级”。
这篇就把我自己踩过的坑和后来形成的一套做法写下来。
你到底在“给谁”做版本
- 接口版本(API Version):对“资源形态 + 协议”的整体版本,比如
/v1/users
到/v2/users
。 - 数据/Schema 版本(Schema Version):对“返回体结构”的版本,比如
user
的字段集合、类型、约束。 - 事件版本(Event Version):消息/事件流里的版本(Kafka/队列)。它和 API 版本是同辈,不是父子。
三者可以独立演进,但要能相互映射:某个 API 版本对应哪些 Schema 版本、某个事件是用的哪套 Schema。
兼容性铁律(能让你少被夜里喊起床)
- 加字段一般是安全的(可选、带默认、客户端可忽略)。
- 不要改类型,不要复用语义(
userId
永远是用户 ID,别哪天借壳成组织 ID)。 - 不要删除字段,先“影子期”(双写一段时间,观测稳定后再移除)。
- 错误码稳定可机读(
code
保持稳定,message
给人看随便变)。 - 旧客户端要“宽容读取”(Tolerant Reader:忽略未知字段、给缺失字段默认值)。
API 版本化的四种常见做法
1)URL Path:/api/v1/...
- 好处:可见、易路由、易做灰度。
- 代价:路径一旦多,维护多套路由;容易把资源语义和版本搅在一起。
- 适用:BFF、对外 API 网关、需要多版本长期共存。
2)HTTP Header:Accept-Version: 2
或 X-API-Version: 2
- 好处:URL 不变,CDN 可基于 Header 缓存,接口文档更干净。
- 代价:客户端要配合;网关/负载需支持按 Header 路由。
- 适用:统一网关/反向代理在手的团队。
3)媒体类型版:Accept: application/vnd.company.user+json;version=2
- 好处:遵循内容协商;对“同一路径多形态”很优雅。
- 代价:认知门槛稍高;监控、Mock 工具要适配。
- 适用:API 平台化、强调 HTTP 语义的团队。
4)负载内声明:"schemaVersion": "1.2.0"
- 好处:数据自描述,落地到日志/离线也能知道版本。
- 代价:路由靠外层(URL/Header),负载版本用于解析/校验/回放。
- 适用:配合上面任一方式使用,尤其是消息/事件场景。
经验:接口版本用于路由与生命周期管理;Schema 版本用于解析与回放。 两条线并行,比一条线“背两份锅”稳得多。
JSON Schema 的管理与命名
- 一个资源一个 Schema 命名空间:
https://json.itzhai.com/schema/user/1-2-0
($id
) - 语义化版本号:
MAJOR.MINOR.PATCH
PATCH
:修文档、描述、示例等非结构改动。MINOR
:向后兼容的结构改动(加可选字段、放宽枚举)。MAJOR
:破坏性改动(删/改类型/改语义)。
- 与“宽容读取”协作:生产解析时允许
unevaluatedProperties
,但写入/事件生产要按 Schema 严格校验,防脏数据外溢。
示例(简化):
1 | { |
非破坏性演进的几种套路
-
新增可选字段:加就完了,客户端读不到用默认值。
-
扩展枚举:在 Schema 里放宽到“枚举 + 兜底(
oneOf + pattern
)”,客户端用白名单 + 默认分支。 -
引入扩展袋:留个
meta
或ext
对象,放灰度字段,方便回滚。 -
分页升级:从
offset/limit
→cursor
,先双轨返回:1
2
3
4
5{
"data": [ ... ],
"paging": { "offset": 200, "limit": 20 },
"pageInfo": { "endCursor": "abc", "hasNextPage": true }
}
真要“破坏性”怎么办
以把 phone:string
升级为 mobile:object
为例:
第 0 周:准备
- 新增字段
mobile
(对象),老字段phone
保留。 - 服务端双写:写请求里若有
phone
,补写mobile
;若只有mobile
,也反向补写phone
(确保回滚安全)。 - 响应体同时返回两者,并在文档标注弃用。
第 2~6 周:观测 & 引导
-
埋点看有多少客户端还在读
phone
。 -
新增 Deprecation/Sunset Header(或文档公告):
1
2
3Deprecation: true
Sunset: Wed, 30 Oct 2025 23:59:59 GMT
Link: </docs/migration/mobile>; rel="deprecation" -
SDK/示例先改,推动集成方升级。
第 8~12 周:收口
- 切
v2
路由:仅返回mobile
,彻底移除phone
。 - 老版本继续跑到 Sunset 日期,读仍双读,写仍双写,确保过渡。
- Sunset 到期:停止双写,删除兼容代码。
关键点:双写先于双读,且整个周期里任何时刻都能回滚。
错误与变更的“稳定接口”
-
错误码:
code
稳定、文档化,message
可变更。1
{ "code": "USER_NOT_FOUND", "message": "user not found" }
-
变更公告:按 API 版本生成
CHANGELOG.md
,面向开发者;面向业务方用“迁移指南+截止时间”。
测试与自动化
- 契约测试:Consumer-Driven Contract(如 Pact),确保“谁在消费,谁有话语权”。
- Schema 校验:CI 里对样例响应跑 JSON Schema 校验;还原线上真实样本回归一遍。
- 回放测试:对事件/日志,取生产样本回放到新 Schema,统计不兼容率。
事件与离线的版本化
- 事件自带
schemaVersion
,并把$id
写进消息头(或体内)。 - 向后兼容的消费器:新老 Schema 都能解析(Tolerant Reader)。
- Schema Registry:集中托管版本与兼容关系(哪怕先做成一个“穷版”的静态库 + 校验脚本,也比口口相传强)。
常见误区
- “这个字段只有我们自己用,改了问题也不大” 。一周后发现某个埋点系统把它当分区键了。
- “我们回收老版本很快” 。统计一看,海外某市场的 App 三个月都没法强更。
- “先删再说” 。回滚时才想起:没有影子数据可回填。
一个轻量的实践清单
- 变更定性:兼容/破坏?对应
MINOR/MAJOR
? - 路由方案:URL/Header/媒体类型?是否需要临时并行版本?
- Schema:新
$id
、schemaVersion
、示例与校验用例补齐 - 双写/双读开关:配置化,默认可灰度
- 监控:老字段读写占比、失败率、回滚预案
- 公告:
Deprecation/Sunset
与迁移指南、截止日期 - 契约与回放测试:在 CI 里跑、在预发里跑
- 下线:数据回收、代码清理、文档归档
写到这儿,核心其实就一句话:版本化不是“多条路径共存”,而是“用最小破坏把变化安全地送到对端”。你把兼容性、观测和回滚设计好了,后面加字段、换分页、甚至重构资源,都不至于影响用户。