2020 年重构播放器时,新旧实现牵涉埋点、进度恢复和 App WebView 兼容,不适合某天晚上一次性全量。我们加了一个功能开关,最初只是 if (newPlayerEnabled)。很快发现真正难的不是开和关,而是谁进入实验、怎样保持同一个人始终在同一组、出现什么指标必须停止,以及开关何时删除。
图 1:开关是一条带观测与退出条件的发布管线,而不是永久留在代码里的分支。
分桶必须稳定且可解释
如果每次请求都用 Math.random(),同一个用户刷新后会在新旧版本之间跳动,进度与缓存状态互相污染。我们用用户稳定标识、实验 key 和盐做哈希:
function bucket(subjectId: string, flagKey: string, salt: string) {
const digest = sha256(`${salt}:${flagKey}:${subjectId}`);
return parseInt(digest.slice(0, 8), 16) % 10_000;
}
function enabled(subjectId: string, rollout: number) {
return bucket(subjectId, "player-v2", "2020-10") < rollout * 100;
}rollout=1 表示 1%,精度到万分桶。匿名用户使用设备级随机 ID,但登录后要明确是否重新分组,不能把两套身份悄悄混用。
规则优先级要固定
最终判定顺序是:紧急全局关闭;内部账号强制开启;黑名单关闭;指定版本/平台规则;百分比分桶;默认值。每次评估返回原因,便于排查:
{
"flag": "player-v2",
"value": true,
"reason": "percentage-rollout",
"ruleId": "ios-10-percent",
"configVersion": 17
}只记录结果而不记录规则版本,事故后很难知道当时究竟执行了哪份配置。
扩量之前写停止条件
我们没有等数据出来后再讨论“算不算异常”,而是在 1% 前约定:播放启动失败率比旧版高 0.3 个百分点就停止;首帧 P95 恶化超过 15% 就回退;关键埋点缺失超过阈值也不扩量。
| 阶段 | 人群 | 最短观察 | 放行条件 |
|---|---|---|---|
| 内部 | 团队账号 | 1 天 | 核心路径与降级完成 |
| 1% | 稳定随机用户 | 1 个业务周期 | 错误率和首帧不劣化 |
| 10% | 分平台扩量 | 2 个高峰 | 资源和客服反馈正常 |
| 50% | 主流版本 | 1 天 | 新旧指标差异可解释 |
| 100% | 全量 | 持续观察 | 开始删除旧实现 |
回滚时要考虑已经产生的新状态
播放器 v2 保存了新的进度字段。关闭前端开关不能让服务端停止理解这些数据,否则回滚用户会丢进度。我们把读路径做成双格式兼容,写路径在灰度期保留旧字段,直到确认不再回滚才迁移。
功能开关只能切换行为,不能自动补偿已经写入的数据。涉及数据库和外部副作用时,回滚方案必须在上线前单独设计。
每个开关都有删除日期
开关长期存在会让测试组合指数增长。创建时我们写 owner、目的、创建日、预期全量日和删除任务。全量稳定后先删除旧分支,再删除开关配置,而不是让 player-v2 五年后仍在每次请求里判断。
这次灰度让我对发布有了新的理解:部署只是把代码送到环境,发布是逐步把真实用户交给新行为。一个合格的功能开关必须同时具备稳定分桶、可观察理由、停止条件、数据兼容和结束日期,否则它只是把风险从发布日拖进了未来代码。