一、把开关下发成功,当成配置生效成功
FlagCanaryLab 起初是为了验证首页信息流的灰度开关。服务端下发version=43,客户端日志打印 200,页面也显示“更新成功”。37 秒后,实验组冻屏率从 0.4% 抬到 1.8%,自动回滚逻辑却找不到一份可信的旧配置:Preferences 里只保留了最新 JSON,而内存中的几个组件已经分别读到了新旧字段。
这次故障没有复杂算法,问题却很典型。远程配置是一个跨网络、存储、页面和运行指标的状态机。下载完成只是拿到候选值;Schema 校验、旧版本迁移、灰度命中、快照提交、健康观察和回滚完成,才构成一次完整变更。任何一步写成“收到就覆盖”,出问题时都只能重启应用碰运气。
我把 Demo 工程命名为FlagCanaryLab,页面是RolloutConsolePage,任务号FLAG-1507。本轮候选配置为 v43、Schema 3、分组beta-2、灰度 15%,摘要b61d2a7c;基线是 v42、摘要4c91e2b8。最终状态不是“v43 失败”,而是ROLLED_BACK:候选曾被原子应用,健康指标越过 1.0% 后,所有读者重新切回同一份 v42 快照。
二、候选配置先变成类型,再谈落盘
项目以前用as RemoteConfig把 JSON 强制成类型。它能让编译器安静,却不能阻止服务端把rolloutPercent写成字符串,也不能处理 v2 中名为feedDensity、v3 改为layout.density的迁移。为此我引入 zod,让网络边界只产生unknown,校验和迁移完成后才得到可读快照。
下面的代码解决候选值不可信和 Schema 跨版本迁移。迁移函数保持纯函数,不读 Preferences,也不修改页面状态;这样同一份原始 JSON 在调试脚本和设备端会得到相同结果。
import{z}from'zod';constConfigV3=z.object({schema:z.literal(3),version:z.number().int().positive(),audience:z.string().min(1),rolloutPercent:z.number().min(0).max(100),layout:z.object({density:z.enum(['compact','comfortable'])}),flags:z.record(z.boolean())}).strict();typeRemoteConfig=z.infer<typeofConfigV3>;functionmigrateToV3(raw:unknown):unknown{constlegacy=rawasRecord<string,unknown>;if(legacy.schema===2){return{...legacy,schema:3,layout:{density:legacy.feedDensity??'comfortable'},feedDensity:undefined};}returnraw;}functionparseCandidate(raw:unknown):RemoteConfig{returnConfigV3.parse(migrateToV3(raw));}这里使用.strict(),是为了让拼错的字段直接失败,而不是悄悄被丢弃。Demo 的正式 v43 校验结果invalidFields=0;故障注入样本把rolloutPercent改成"15",zod 会拒绝,当前 v42 不受影响。迁移只保留一个历史版本,跨两代以上的客户端要求服务端下发兼容文档或强制升级,不能无限堆叠迁移分支。
zod 只负责结构正确,不负责业务正确。version必须大于活动版本、audience必须属于允许集合、摘要必须覆盖规范化后的字节,这些检查放在CandidateGate。页面退出不会中止正在执行的校验,但结果带fetchGeneration,过期候选不能提交。
三、灰度命中必须稳定,不能每次启动重新抽签
如果每次启动调用Math.random(),一个用户今天命中 v43,明天又回 v42,问题样本会被打散。Demo 使用安装标识与配置版本生成稳定桶值,结果范围 0~9999;15% 灰度就是桶值小于 1500。beta-2还要先通过人群条件,二者都满足才进入候选观察。
这一段代码解决同一安装重复漂移以及版本变化后无法独立分桶的问题。示例用 SHA-256 的前四字节生成桶值,真正项目还需要保护安装标识,日志只记录不可逆摘要。
typeAssignment={installHash:string;version:number;bucket:number;selected:boolean;};asyncfunctionassignCanary(installId:string,config:RemoteConfig):Promise<Assignment>{constsource=`${installId}:${config.version}:${config.audience}`;constdigest=awaitCryptoBox.sha256(source);constbucket=digest.readUint32BE(0)%10_000;return{installHash:digest.hex().slice(0,12),version:config.version,bucket,selected:config.audience==='beta-2'&&bucket<config.rolloutPercent*100};}任务FLAG-1507的桶值是 864,因此命中 v43。将version纳入源串,是为了让下一次 v44 重新独立灰度;如果产品要求同一实验长期保持人群,就应该改用实验 ID,而不是机械照搬。该函数返回的是分配证据,不直接改变配置。重复调用会得到同样结果,也不会创建新的 Preferences 文件。
四、Preferences 中保存的是双槽快照,不是散落字段
旧实现把每个开关分别put。即使最后统一flush,页面也可能在写入过程中读到一半新值。现在只保存三个键:active_slot、slot_a、slot_b。候选总是写入非活动槽,完成 JSON、摘要、版本和写入时间的整体校验后,再切换一个很小的活动槽指针。
下面的commitSnapshot同时解决半写和回滚无基线问题。它先保留活动快照,再写候选槽;任何异常都不会改变active_slot。
typeSnapshot={version:number;schema:number;checksum:string;committedAt:number;payload:RemoteConfig;};classConfigSnapshotStore{constructor(privatepref:preferences.Preferences){}asynccommitSnapshot(next:Snapshot):Promise<void>{constactive=String(awaitthis.pref.get('active_slot','a'));conststandby=active==='a'?'b':'a';constencoded=JSON.stringify(next);constverified=awaitSnapshotCodec.verify(encoded,next.checksum);if(!verified)thrownewError('CHECKSUM_MISMATCH');awaitthis.pref.put(`slot_${standby}`,encoded);awaitthis.pref.flush();awaitthis.pref.put('active_slot',standby);awaitthis.pref.flush();}asyncrollbackOnHealth(expectedVersion:number):Promise<Snapshot>{constactive=String(awaitthis.pref.get('active_slot','a'));constprevious=active==='a'?'b':'a';constsnapshot=SnapshotCodec.parse(String(awaitthis.pref.get(`slot_${previous}`,'')));if(snapshot.version!==expectedVersion)thrownewError('BASELINE_LOST');awaitthis.pref.put('active_slot',previous);awaitthis.pref.flush();returnsnapshot;}}Preferences 适合这个 Demo 的小型配置快照,不适合大体积策略包或多进程强事务。双次flush是有意为之:先保证备用槽可读,再切换指针。断电发生在第一次 flush 之后,旧槽仍活动;发生在第二次之后,新槽已完整。快照内不保存密钥、令牌或用户隐私,只保留公开实验参数。页面离开时不销毁 Preferences 实例,但会注销ConfigHub订阅,避免旧页面继续接收热更新。
五、从指标越线到所有组件看见同一个回滚版本
远程配置最容易遗漏的是“回滚成功”的定义。把active_slot改回去还不够,内存读者必须在同一 revision 上刷新,而且旧 v43 异步任务不能继续提交。ConfigHub每次发布都增加revision,组件拿到不可变快照;网络请求、图片预取和列表布局都捕获该 revision,结果回写前再比较一次。
DevEco Studio 中的故障注入把 5 分钟滑窗冻屏率改为 1.8%,超过 1.0% 门槛。HiLog 顺序固定为:CANDIDATE_VALID v43、CANARY_ASSIGNED bucket=864、SNAPSHOT_ACTIVE v43 rev=108、HEALTH_BREACH 1.8%、ROLLBACK_ACTIVE v42 rev=109。右侧模拟器显示 v42 已恢复,迟到 v43 提交计数为 0。
这组日志刻意不打印完整 JSON。线上只需要版本、Schema、人群、桶值、摘要前缀、revision 和决策;原始配置可能包含尚未公开的实验名称,不该进入普通日志。模拟指标越线和重试候选版本只存在于调试页面,前者复现回滚,后者重新拉取 v43,但仍会经过完整 Schema 与分桶流程。
六、这次回滚真正证明了什么
最终运行页的活动版本为 42,候选版本 43,Schema 3,人群beta-2,灰度 15%,桶值 864。v43 的摘要是b61d2a7c,基线摘要是4c91e2b8;健康值从 0.4% 升至 1.8%,状态从OBSERVING_43收敛到ROLLED_BACK,回滚耗时 184 ms,旧 revision 的迟到提交为 0。
验收时我没有只点一次按钮。进程重启后,active_slot仍指向 v42;把备用槽末尾截断 12 字节,启动读取会拒绝损坏快照并继续使用活动槽;连续触发两次健康越线,回滚门闩只执行一次,第二次记录ALREADY_ROLLED_BACK。这三条用例分别覆盖持久化、损坏隔离和重复调用,证明回滚不是只在当前页面内看起来生效。
这个 Demo 没有实现一个完整远程配置平台。它没有解决服务端签名体系、跨设备实验一致性,也没有替代 APMS 的真实指标采集。它解决的是客户端最小闭环:候选值可验证、历史 Schema 可迁移、灰度分配可复现、落盘可回退、内存读者可同步、旧异步结果可拒绝。
现在我会把“配置更新成功”拆成五个可验收事实:v43 结构合法;安装桶 864 确实命中;双槽切换后所有读者处于 revision 108;指标越线被唯一门闩捕获;v42 在 revision 109 恢复且没有 v43 迟到写入。对日常开发来说,这比再加一层 try/catch 更重要。远程开关的价值是降低发版风险,前提是它自己不能成为一个没有边界的第二套发布系统。
把这些事实画在同一张诊断页上还有一个好处:产品、测试和开发讨论的是同一个版本、同一份摘要和同一个健康窗口,不再各自用“我这里已经恢复”描述不同状态。