Appearance
一、问题背景
很多团队都会遇到同一个问题:
- Web 已经发了新版
- 后端也已经上了新版
- 但 App 还在审核,线上仍然有大量旧版客户端
- 旧版 App 请求到了新版后端,结果因为接口字段、状态枚举、参数要求变化而报错
这个问题本质上不是“App 审核慢”,而是客户端生效速度慢于服务端发布速度。 Apple 官方本身就支持 phased release,说明移动端天然会长期同时存在多个版本;而且即使开启分阶段发布,用户仍然可以手动下载最新版,所以线上一直会并存新老版本客户端。(Apple Developer)
二、整体实现思路
真正稳妥的方案,不是只喊一句“后端向前兼容”,而是把下面四件事一起做:
- 接口默认只做兼容式演进
- 客户端显式上报版本和能力
- 新功能用开关灰度,而不是和发版强绑定
- 破坏性变更走 API 版本化和弃用周期
这类思路和大厂公开的 API 治理方式是一致的: Stripe 明确区分 major release 和月度 release,并说明月度发布只包含 backward-compatible changes;Google AIP 也把稳定 API 中原地做 breaking change 视为非常严肃的事情;Microsoft Graph 对 breaking change 和版本支持也有明确策略。(Stripe Docs)
三、先定规则:什么改动允许,什么改动不允许
这一层最重要,因为后面所有开关、灰度、版本控制,都是建立在“接口改动有纪律”之上的。
1)日常迭代允许的改动
这类改动通常是兼容式的:
- 新增接口
- 老接口新增可选参数
- 老接口响应新增字段
- 新增业务状态,但旧端有兜底逻辑
- 新功能默认关闭,先不对旧端生效
Stripe 官方文档明确表示,月度发布只做 backward-compatible changes,可以安全升级;这类思路本质上就是“尽量只做追加,不做破坏”。(Stripe Docs)
2)日常迭代不要做的改动
这类改动最容易把旧 App 打崩:
- 删除字段
- 字段改名
- 字段类型变化
- 把可选参数改成必填参数
- 改变原有字段语义
- 删除旧接口
- 让旧客户端必须传新 header / 新参数
- 返回旧客户端完全不认识且没有兜底处理的新状态
Google AIP 对稳定 API 的态度非常明确:在稳定 API 中原地做 breaking change 是极端手段;Microsoft Graph 也把 breaking change 单独纳入版本与支持策略中。(Google AIP)
四、不要只传版本号,要传“能力集”
很多团队现在只会让客户端上报:
appVersionplatform
这还不够。
更稳的做法是让每次请求都带上这几类信息:
- 平台:iOS / Android / Web
- App 版本号
- Build 号
- API 版本
- 能力集 capabilities
原因很简单:
你真正关心的,不是“它是不是 3.8.1”,而是:
- 这个客户端会不会解析新字段
- 这个客户端认不认识新状态
- 这个客户端有没有接新流程
- 这个客户端是否支持某个新能力
所以后端最好按“能力”判断,而不是只按版本号判断。
推荐请求头
http
X-Client-Platform: ios
X-App-Version: 3.8.1
X-Build-Number: 20260419
X-Api-Version: v1
X-Capabilities: order_cancel_v2,payment_token_v3,profile_schema_v2为什么能力比版本号更稳
因为同一个版本号,实际线上也可能出现不一致:
- iOS 审核包和 Android 热修包不同
- 部分用户已经灰度启用新功能
- 某个渠道包补过一个小修复
- Web 和 App 共用后端,但能力集并不一致
所以最稳妥的判断方式是:
ts
if (capabilities.includes("order_cancel_v2")) {
// 走新逻辑
} else {
// 走兼容旧客户端的逻辑
}五、新功能不要和发版绑死,要挂 Feature Flag
这是移动端治理里最关键的一层。
即使后端先上线了,也不要让“后端上线 = 新逻辑立刻对所有用户生效”。 正确做法是:后端先支持新旧两套路径,新功能默认关闭,等 App 审核通过后再逐步打开。
Firebase Remote Config 官方文档明确支持把参数当作 feature flags 使用,并支持逐步 rollout 到特定用户组,同时结合 Crashlytics 和 Analytics 观察发布效果。(Firebase)
开关建议至少支持这些维度
- 按平台开:只开 iOS,不开 Android
- 按版本开:只给
>= 3.8.0 - 按能力开:只给支持
profile_schema_v2的客户端 - 按用户比例开:1%、5%、20%、100%
- 按用户白名单开:测试账号、内部账号
- 按地区 / 渠道开:需要时使用
六、发布流程改成“两阶段”,而不是“一次性上线”
这一步特别重要。
阶段 A:先上兼容后端
这一版后端要满足:
- 旧 App 调用时不报错
- 新 App 调用时能拿到新能力
- 新开关默认关闭
- 数据库先扩字段,不删旧字段
- 服务端允许新旧 DTO / VO 并存
阶段 B:等 App 生效后,再开启新能力
等审核通过、灰度包覆盖率上来后,再逐步启用:
- 先给测试用户
- 再给 1%
- 再给 5%
- 再给 20%
- 最后全量
Apple 的 phased release 文档明确给出了 7 天放量节奏:1%、2%、5%、10%、20%、50%、100%,而且可以暂停后继续。(Apple Developer)
所以你们要建立一个基本认知:
客户端上线不是一个点,而是一段时间。 因此后端兼容期也必须覆盖这一段时间,而不是“后端今天发了,明天就删旧逻辑”。
七、真遇到破坏性变更时,不要偷偷改老接口,直接升 API 版本
有些需求本身就不适合兼容:
- 返回结构必须重做
- 核心字段语义必须改变
- 老流程必须下线
- 老客户端根本无法理解新协议
这时候不要继续强行改 /api/v1/...,而应该:
- 保留
/api/v1/... - 新增
/api/v2/...
或者通过请求头区分 API 版本。
Google AIP-185 专门规定了 Google API 的版本策略;Stripe 也明确区分 major release 和兼容式月度发布;Microsoft Graph 也有独立的版本支持和 breaking change 策略。(Google AIP)
一个简单判断标准
不升版本,继续兼容
适用于:
- 新增字段
- 新增可选参数
- 新增非破坏性接口
- 新逻辑可以按开关控制
升版本
适用于:
- 删除字段
- 字段改名
- 字段类型变化
- 响应结构重做
- 参数规则发生不兼容变化
- 旧端无法继续正确解析
八、最低支持版本和强制升级,只在少数场景用
很多团队一遇到兼容问题,就想“那就强制升级”。
这个手段能用,但不要滥用。
适合强制升级的场景
- 安全漏洞
- 合规风险
- 资金结算风险
- 旧协议会导致严重数据错误
- 某些核心能力必须整体升级才能保证业务正确性
不适合强制升级的场景
- 普通功能调整
- 页面样式变化
- 一个字段结构优化
- 新功能刚上线想偷懒不做兼容
也就是说,强制升级是兜底方案,不是日常方案。
九、观测一定要按“客户端版本”分桶
没有观测,兼容治理最后一定会失效。
至少要统计这些:
- 接口错误率:按
platform + appVersion + buildNumber - 新旧接口调用量占比
- 某个 capability 的覆盖率
- 新状态 / 新字段触发后的错误率
- Crash 率按客户端版本分布
- 某个老版本的活跃用户量
Firebase 的 rollout 文档本身就强调:要结合 Crashlytics 和 Analytics 来衡量 rollout 的稳定性和成功情况。(Firebase)
只有拿到这些数据,你们才敢回答下面这些问题:
- 旧兼容逻辑什么时候能删?
- 某个版本是不是还在线上大量活跃?
- 某次异常是不是只出现在旧 App?
- 新开关是否可以从 5% 放到 20%?
十、推荐的服务端架构:加一层 BFF 或网关适配
如果你们现在是:
- Web 直接调业务后端
- App 直接调业务后端
- 页面字段和后端结构强耦合
那后面兼容成本会越来越高。
更稳的方式是:
- App / Web → BFF / API Gateway
- BFF / Gateway → 内部业务服务
BFF 层专门负责:
- 读取客户端版本 / 能力
- 新旧字段映射
- 旧端返回旧结构
- 新端返回新结构
- feature flag 决策
- 降级与兜底
这样内部服务可以更专注于业务,客户端兼容策略集中在边界层处理。
十一、分步落地方案
下面给你一个适合多数团队的落地步骤。
第一步:定义接口兼容红线
先把团队规范写清楚:
- 允许:新增接口、新增可选字段、新增可选参数
- 禁止:删字段、改名、改类型、改必填、改语义
这一步没有做,后面都会乱。
第二步:统一客户端请求元数据
网关层统一解析:
- platform
- appVersion
- buildNumber
- apiVersion
- capabilities
然后挂到统一的 ClientContext 上,后端各业务服务统一使用,不要每个接口手写一套版本判断。
第三步:所有新功能必须挂开关
凡是 App 相关需求,默认都要支持:
- 关闭
- 指定版本开启
- 按能力开启
- 按比例灰度
第四步:改发布流程
以后涉及 App 的需求,固定流程改成:
- 后端先上兼容版本
- 开关默认关闭
- App 提审
- 审核通过后灰度启用
- 观察监控
- 放量
- 老逻辑进入弃用期
- 覆盖率足够后再删除
第五步:建立弃用周期
不要“新版本一上,老逻辑立刻删”。 应该有一个明确的弃用窗口,例如:
- 发布公告日期
- 新客户端最低覆盖率目标
- 旧接口停止写入时间
- 旧接口彻底下线时间
十二、最简代码示例
下面只给最小必要示例,方便你们先把机制搭起来。
1)客户端请求头
ts
// 请求时统一带上客户端元数据
const headers = {
"X-Client-Platform": "ios",
"X-App-Version": "3.8.1",
"X-Build-Number": "20260419",
"X-Api-Version": "v1",
"X-Capabilities": ["order_cancel_v2", "profile_schema_v2"].join(","),
};2)服务端解析 ClientContext
ts
export interface ClientContext {
platform: "ios" | "android" | "web" | "unknown";
appVersion: string;
buildNumber: string;
apiVersion: string;
capabilities: string[];
}
export function parseClientContext(
headers: Record<string, string | string[] | undefined>,
): ClientContext {
const capabilityHeader = headers["x-capabilities"];
const capabilityValue = Array.isArray(capabilityHeader)
? capabilityHeader[0]
: capabilityHeader;
return {
platform: normalizePlatform(headers["x-client-platform"]),
appVersion: getSingleHeader(headers["x-app-version"]),
buildNumber: getSingleHeader(headers["x-build-number"]),
apiVersion: getSingleHeader(headers["x-api-version"]) || "v1",
capabilities: capabilityValue
? capabilityValue
.split(",")
.map((item) => item.trim())
.filter(Boolean)
: [],
};
}
function getSingleHeader(value: string | string[] | undefined): string {
if (Array.isArray(value)) return value[0] || "";
return value || "";
}
function normalizePlatform(
value: string | string[] | undefined,
): ClientContext["platform"] {
const platform = getSingleHeader(value).toLowerCase();
if (platform === "ios") return "ios";
if (platform === "android") return "android";
if (platform === "web") return "web";
return "unknown";
}3)服务端不要直接按版本号硬切,优先按能力判断
ts
export function supportsCapability(
clientContext: ClientContext,
capability: string,
): boolean {
return clientContext.capabilities.includes(capability);
}ts
// 这里先判断 capability,再决定返回新旧结构,避免旧 App 直接拿到无法解析的数据
export function buildProfileResponse(
clientContext: ClientContext,
user: { id: string; nickname: string; avatar: string },
) {
if (supportsCapability(clientContext, "profile_schema_v2")) {
return {
userId: user.id,
profile: {
nickname: user.nickname,
avatar: user.avatar,
},
};
}
return {
id: user.id,
nickname: user.nickname,
avatar: user.avatar,
};
}4)Feature Flag 决策示例
ts
export interface FeatureFlagRule {
featureKey: string;
enabledPlatforms: string[];
minAppVersionByPlatform: Record<string, string>;
requiredCapability?: string;
}
export function isFeatureEnabled(
clientContext: ClientContext,
rule: FeatureFlagRule,
): boolean {
if (!rule.enabledPlatforms.includes(clientContext.platform)) {
return false;
}
const minVersion = rule.minAppVersionByPlatform[clientContext.platform];
if (minVersion && compareVersion(clientContext.appVersion, minVersion) < 0) {
return false;
}
// 这里额外叠加 capability 判断,避免某些渠道包版本号满足但实际能力未接入
if (
rule.requiredCapability &&
!supportsCapability(clientContext, rule.requiredCapability)
) {
return false;
}
return true;
}ts
export function compareVersion(current: string, target: string): number {
const currentParts = current.split(".").map(Number);
const targetParts = target.split(".").map(Number);
const maxLength = Math.max(currentParts.length, targetParts.length);
for (let index = 0; index < maxLength; index += 1) {
const currentValue = currentParts[index] || 0;
const targetValue = targetParts[index] || 0;
if (currentValue > targetValue) return 1;
if (currentValue < targetValue) return -1;
}
return 0;
}5)控制器里使用
ts
// 这里把客户端上下文放在入口层统一解析,业务层只消费标准化后的 ClientContext
const clientContext = parseClientContext(req.headers);
const profileFeatureRule = {
featureKey: "profile_v2",
enabledPlatforms: ["ios", "android"],
minAppVersionByPlatform: {
ios: "3.8.0",
android: "3.8.0",
},
requiredCapability: "profile_schema_v2",
};
if (isFeatureEnabled(clientContext, profileFeatureRule)) {
return buildProfileResponse(clientContext, user);
}
return buildProfileResponse(
{
...clientContext,
capabilities: [],
},
user,
);十三、一个最容易执行的团队规范模板
你们可以直接把下面这几条定成规范。
接口规范
- 普通迭代只允许 additive change
- 破坏性变更必须升
v2 - 不允许在
v1原地做不兼容修改
客户端规范
- 所有请求必须上报
platform / appVersion / buildNumber / apiVersion / capabilities - 新功能接入后必须登记 capability 名称
- 旧版本必须对未知字段、未知枚举做兜底
发布规范
- 先发兼容后端
- 再发客户端
- 审核通过后开灰度
- 监控通过后再全量
- 旧逻辑不得在客户端全量前删除
运维规范
- 错误率必须按客户端版本分桶
- 开关必须支持快速关闭
- 旧接口下线前必须看活跃覆盖率
十四、结论
如果只让我给一句最核心的建议,那就是:
把旧客户端当成长期存在的真实依赖,而不是一个“过两天就没人用了”的临时问题。
一旦按这个前提设计,你们的方案自然就会变成:
- 默认兼容式演进
- 客户端上报能力
- 新功能先挂开关
- 发布分两阶段
- 破坏性变更走版本化
- 下线旧逻辑依赖真实覆盖率数据
这套方式,既适合你们现在的 Web + 后端 + App 并行迭代,也和公开的大厂 API 治理、移动端灰度发布思路一致。(Stripe Docs)
参考资料
- Stripe API Versioning:月度发布保持 backward-compatible,major release 才承载不兼容变更。(Stripe Docs)
- Microsoft Graph Versioning and Support:对 breaking changes 和版本支持有明确策略。(Microsoft Learn)
- Google AIP-185 / AIP-181:规定 API versioning 策略,并强调稳定 API 中原地做 breaking change 应极其谨慎。(Google AIP)
- Firebase Remote Config Rollouts:支持按参数作为 feature flags 进行渐进发布,并结合 Crashlytics / Analytics 观察效果。(Firebase)
- Apple Phased Release:7 天分阶段放量,且用户仍可手动更新。(Apple Developer)
你要是愿意,我下一条可以继续直接给你一篇 “适合 NestJS 项目的版本兼容治理落地方案”,我会按同样的 Hexo 格式写,并且把中间件、ClientContext、Feature Flag、接口版本目录结构都补出来。