题干与适用场景
一个公共订单 REST API 已经被 600 个第三方集成和多个无法强制升级的移动应用使用。现有 GET /v1/orders 会一次返回全部订单,每个订单包含字符串字段 customer_name,status 目前只会返回 pending 或 paid。下一版希望把客户信息改成结构化对象、对列表分页,并新增 refunded 状态。
600 个集成、现有字段和状态都是本题的面试假设。核心限制是服务方无法控制所有消费者何时升级, 因此成功标准同时覆盖新客户端能调用 v2、旧客户端继续按原契约使用 v1、迁移过程可观测、 弃用日期可发现,以及发布失败时能恢复服务而不改变已承诺的版本语义。
这道题归入 backend,因为核心能力是 API 契约、服务端表示层、发布与兼容治理。它不要求设计整个 订单系统,也不讨论数据库分片。面试中应先把当前契约写清楚,再判断每项变化;先说“URL 加 v2” 无法证明旧客户端安全。
{
"orders": [
{
"id": "ord_1",
"customer_name": "Ada Lovelace",
"status": "paid"
}
]
}面试官考察点
第一个信号是能否区分三类兼容性。源码兼容关注旧客户端重新生成或升级 SDK 后还能否编译; 线协议兼容关注旧序列化器能否解析新消息;语义兼容关注相同调用是否仍得到合理消费者所 依赖的行为。字段类型没变并不代表语义没变,例如把“返回全部订单”悄悄改成“只返回前 100 条”, JSON 仍然合法,但旧客户端会漏数据。
第二个信号是能否按契约逐项判断。绝对规则表无法覆盖真实客户端的解析方式。新增可选请求字段且 默认行为不变,通常可以留在 v1;删除、重命名或改变字段类型会破坏 v1。响应增加字段只有在契约允许未知字段、实际 SDK 也能忽略未知字段时才是安全的增量。给响应枚举增加值尤其需要检查:开放枚举契约可以允许扩展, 但封闭枚举、生成的强类型 SDK 或没有 default 分支的穷举判断可能失败。
第三个信号是把版本、实现和生命周期连成一条可运行路径。强回答会保留 v1 表示层,用统一领域逻辑 分别生成 v1 与 v2 响应;发布前执行契约差异检查与旧 SDK 测试;发布后按消费者观察采用率、错误率和 延迟;最后通过标准化弃用信号、迁移指南和明确停止条件完成下线。版本号是路由选择,不会自动完成 这些工作。
回答前需要澄清的问题
- 消费者是否可控? 若只有同一公司的三个服务且能原子协调发布,可以采用扩展—迁移—收缩,
未必需要长期维护 v2。第三方和旧移动端无法强制更新时,需要稳定版本边界和公开生命周期。
- 当前契约是否要求忽略未知字段和未知枚举? 这决定新增响应字段或枚举值能否留在 v1。不能只
根据 JSON 本身可解析就宣布兼容,还要查看 OpenAPI、SDK 类型和真实消费者行为。
- 列表现有规模与可靠性风险是什么? 若一次返回全部订单仍满足 SLO,可以把分页只放到 v2。
若无界响应已经威胁服务可用性,需要先限流和沟通应急边界,但仍不能把静默截断包装成兼容变更。
- 版本选择机制是否已经公开? 既有 API 使用路径版本就继续
/v1与/v2;既有 API 使用日期
请求头就沿用请求头。迁移期间改换机制会额外制造一个客户端变更。
- 能否识别消费者并联系负责人? 有稳定应用 ID、SDK 版本和联系人时,可以精确追踪迁移;只能
看匿名流量时,下线门槛必须更保守。
- 法律、合同或业务上承诺了多长支持期? 停止日期应由已发布政策、客户义务、风险和真实采用率
决定,不能把其他平台的支持年限直接当作本系统标准。
30 秒回答框架
“我会先冻结 v1 的书面契约和可观测行为,再把变化按源码、线协议和语义兼容性分类。新增可选字段且 默认行为不变可以考虑留在 v1;把字符串换成对象、重命名字段和把全量列表改成分页都需要 v2;新增 响应枚举值要看开放枚举约定与旧 SDK。实现上共用订单领域逻辑,只保留 v1、v2 两套表示适配器。 发布前跑 OpenAPI 差异、旧 SDK、录制请求回放和端到端契约测试,v2 先让明确消费者选择加入。随后 按消费者监控版本采用率和错误,提供迁移指南、弃用与停止日期;只有迁移门槛和义务都满足才关闭 v1。任何阶段出错都回滚 v2 路由或适配器,v1 的行为保持不变。”
分步骤深入解答
先建立兼容基线。保存当前 OpenAPI 文档、已发布 SDK、代表性请求与响应、错误码、排序、默认值和 分页行为。文档没有写出的可见行为也要抽样核对,因为消费者可能依赖字段格式、空值、排序或一次 返回全部结果。基线同时记录消费者 ID、版本、请求量和负责人,后续才能判断“没人使用”还是“无法 识别使用者”。
接着逐项分类:
| 计划变更 | v1 判断 | 处理方式 | |---|---|---| | 增加可选请求字段,省略时保持旧行为 | 通常兼容 | 在 v1 增加,并测试旧请求 | | 增加可选响应字段 | 有条件兼容 | 先验证未知字段策略与旧 SDK | | 把 customer_name 改成 customer 对象 | 不兼容 | v1 保留字符串,v2 返回对象 | | 删除或重命名现有字段 | 不兼容 | 新版本新增名称,旧版本不移除 | | 把全量列表默认改成分页 | 语义不兼容 | 在 v2 定义游标和页面契约 | | 响应增加 refunded 枚举值 | 取决于枚举契约 | 检查开放枚举、生成代码和穷举分支 | | 修正文档未承诺且不会改变合理依赖的拼写 | 仍需证据 | 用消费者测试和流量回放证明 |
这里最容易漏掉的是分页。Google 的兼容指引专门提醒:若旧接口默认返回全部项目,后来新增一个 默认有限的 page_size,旧客户端可能误以为已经拿到完整集合。正确做法是在 v2 明确 items、 nextpagetoken、排序与令牌失效规则;v1 在支持期内继续履行旧行为,同时用配额、响应大小监控和 迁移沟通控制风险。
然后确定版本边界。本题已经采用路径版本,因此增加 /v2/orders,不在同一路径上根据 User-Agent 猜版本,也不把 v1 的默认实现悄悄指向新语义。版本只覆盖外部表示层:请求进入后先解析为统一的领域 命令,订单查询与权限逻辑共用,返回时再由 V1OrderPresenter 或 V2OrderPresenter 生成对应形状。 这样 bug 修复和安全规则仍能同时覆盖两个版本,不需要复制整个服务。
v2 的示例契约可以是:
{
"orders": [
{
"id": "ord_1",
"customer": {
"display_name": "Ada Lovelace"
},
"status": "paid"
}
],
"next_page_token": "eyJvcmRlcl9pZCI6Im9yZF8xIn0"
}发布门禁分四层。第一层比较新旧 OpenAPI,阻止 v1 中的字段删除、必填性变化、类型变化和意外的新 校验规则。第二层用最后一个公开 v1 SDK 编译并运行固定契约用例,覆盖未知字段、空值、错误响应与 枚举。第三层回放脱敏后的代表性请求,比较 v1 新旧版本的状态码、关键字段和排序。第四层让 v2 在 沙箱或 canary 环境由少量已知消费者选择加入,观察功能、4xx、5xx、延迟和响应大小。Schema diff 只能发现结构变化,不能替代排序、默认值和分页完整性的语义断言。
迁移从“可选使用”开始。发布 v2 文档、SDK、迁移对照表和双版本沙箱;为每个已知消费者展示 v1 调用量、失败端点和目标日期。先迁移内部示例与官方 SDK,才能尽早发现指南缺口。每周按消费者查看 采用率,并单独核对总请求量:一个低流量的月末对账集成可能比大量健康检查更重要。核心指标包括 v1、 v2 的唯一活跃消费者数、请求量、4xx、5xx、p95 延迟、分页完成率、旧 SDK 解析失败和已联系但未 迁移的高风险账户。
弃用要区分三个时点:公告迁移政策、API 正式进入弃用状态、API 停止响应。RFC 9745 定义的 Deprecation 响应头传递弃用日期,并可用 deprecation link relation 指向说明;真正计划停止响应 时再配合 Sunset。弃用本身不应改变资源行为。以下日期只是本题示例,实际值要匹配公开政策:
Deprecation: @1803859200
Sunset: Wed, 01 Sep 2027 00:00:00 GMT
Link: <https://api.example.com/migrations/orders-v2>; rel="deprecation"; type="text/html"关闭 v1 前需要同时满足:支持义务已满足;已知关键消费者完成迁移或获批例外;剩余流量能解释; 迁移指南与支持渠道可用;v2 的错误率、延迟和业务结果达到门槛;关闭演练能恢复。若某个高价值客户 尚未迁移,选择延长、提供受限兼容网关或按合同处理。不能为了让采用率看起来达到 100% 就忽略它。
回滚也要在发布前定义。v2 路由和表示适配器可独立关闭,领域写入保持向后兼容,v1 始终保留上一个 已验证产物。若 v2 新字段需要新存储,先执行可兼容的扩展和回填,再让 v2 读取;不要让 v2 发布同时 删除 v1 所需数据。回滚 v2 时恢复其实现版本,不能改变“v2”所代表的契约来掩盖故障。
高质量示范回答
“我先把问题拆成契约变化和发布生命周期。这里客户端不可强制升级,所以我要同时保护 JSON 解析、 旧 SDK 运行,以及同一请求仍然返回完整、含义相同的结果。
customer_name 从字符串变对象会改变类型,重命名也等于删除后新增;这两项放到 v2。给 GET /orders 默认分页会让旧客户端少拿数据,是语义破坏,也放到 v2。新增 refunded 不能直接 假设安全:如果响应枚举在契约中是开放的,而且旧 SDK 能把未知值保留下来,可以在 v1 扩展;如果 生成代码使用封闭枚举或消费者穷举,我会留在 v2,或者先给 v1 增加可识别的回退行为并验证。
我会保留 /v1/orders 的响应和全量语义,新建 /v2/orders 的结构化客户对象、游标与页面规则。 两个版本共用查询、授权和订单状态逻辑,只在请求解析和响应表示上分层。合并前比较 OpenAPI,运行 最后一个 v1 SDK 的契约测试,并回放脱敏请求验证状态码、排序、默认值和完整性。v2 先由内部 SDK 和 少量已知集成选择加入,出现回归就关闭 v2 路由,v1 不受影响。
迁移期我会按应用 ID 统计活跃消费者,把总流量作为辅助,并跟踪版本采用、解析失败、4xx、5xx、 延迟、 分页完成率和关键账户。文档会列出字段映射、分页循环、枚举回退和测试环境。正式弃用时通过响应头和 迁移链接让状态可发现,停止日期另行声明。支持义务、关键消费者、剩余流量和 v2 SLO 全部满足后才 关闭 v1,并先做可恢复演练。这样版本号、兼容实现、迁移证据和最终下线属于同一套可验证方案。”
常见错误
- 只回答在 URL 中加
/v2→ 没有说明什么变化会破坏谁,也没有迁移和停止条件 → **先建立
v1 基线,逐项按源码、线协议和语义分类。**
- 认为新增响应内容永远兼容 → 严格反序列化、封闭枚举和穷举分支仍可能失败 → **核对公开契约、
生成 SDK,并用真实旧版本测试。**
- 让 v1 自动转到 v2 → 相同版本标识开始代表不同语义,客户端无法选择或回滚 → **保留稳定 v1
表示层,让消费者显式选择 v2。**
- 为 v1 和 v2 复制整套服务 → 安全修复和业务规则容易漂移,双版本成本快速扩大 → **共用领域逻辑,
只隔离真正不同的解析与表示。**
- 公告日期后直接关停 → 匿名长尾、低频对账和关键客户可能仍依赖 v1 → **按消费者核实采用率、
支持义务和剩余流量,并预演恢复。**
- 只看服务端 2xx → 客户端可能成功收包却解析失败、漏页或误解新状态 → **增加旧 SDK、分页完成率、
端到端业务结果和支持信号。**
追问及应对
追问一:如果所有消费者都是同一公司的三个服务,还需要 v2 吗?
未必。若能识别所有调用方、协调发布并快速回滚,可以采用扩展—迁移—收缩:先增加兼容字段或新 端点,发布能同时读取新旧形状的消费者,再切换生产者,确认旧使用为零后删除旧契约。仍要保留契约 测试和版本化部署证据,但不必为可控消费者永久维护公开 v2。任何一个离线任务、旧客户端或外部伙伴 不可协调时,这个前提就不成立。
追问二:Webhook 事件应该跟随账户当前 API 版本吗?
不应在重放时使用“当前版本”重新解释历史事件。端点创建时固定事件 API 版本,事件本身记录版本, 重试与重放保持原始形状;升级时创建或切换到新版本端点并验证消费者。Stripe 的公开文档也明确说明 Webhook 事件与端点创建时的 API 版本有关。双发新旧事件会产生重复副作用,只有消费者具备稳定事件 ID 去重且迁移方案明确时才可短期使用。
追问三:新增一个响应枚举值到底算不算破坏性变更?
答案取决于已发布契约。GitHub 把新增枚举值列作增量变化;Google 的兼容指引同时提醒,响应枚举的 旧用户代码可能无法妥善处理新值。面试中应说明这个张力:若契约把枚举定义为开放集合,SDK 提供 unknown 表示且消费者必须容错,可按兼容变化发布;若类型封闭或生态中有穷举判断,应按破坏风险处理, 先改善 SDK 与契约,或放到新版本。不能仅凭服务端 schema 得出结论。
追问四:v1 无界列表已经导致超时,等所有客户迁移来不及怎么办?
先用现有契约允许的配额、缓存、查询优化和背压恢复服务,并主动联系高流量消费者迁移到 v2。若必须 设置紧急响应上限,要明确它可能破坏 v1,走事故与变更审批,公告受影响范围,提供批量导出或临时 兼容通道,并监控漏数风险。静默返回前 100 条且仍报 200 会把可用性事故变成难发现的数据错误,不能 作为兼容修复。
追问五:停止日期到了,仍有 0.2% 的 v1 流量怎么办?
先把比例还原为消费者和业务:是探针、误配置、月末任务,还是合同客户。能识别且无业务依赖的流量 可以清理;关键消费者需要升级、例外或升级支持决策;匿名流量则按公开政策和风险处理。最终决定应 记录剩余调用、通知证据、支持义务、恢复方案与负责人。百分比本身不能证明安全,也不能无限期阻止 版本退出。