A/B 实验结束、方案回滚后,线上仍有一批用户停留在实验组------分组参数被缓存在 URL、localStorage 或 Cookie 里,回滚只改了服务端开关,没有管客户端残留。本文复盘一次"分组参数回滚失效"问题:原有架构怎么设计的、缺陷在哪、重构后如何用签名 assignment(实验 ID、版本、过期时间、分组、配置 hash)+ 兜底逻辑做校验。做实验平台、功能开关、客户端分流体系的团队,值得照着自己的读取链路检查一遍。
版本与核验环境
本文示例的核验环境:浏览器为 Chrome 稳定版;localStorage、Cookie、Web Crypto 等浏览器 API 行为以 MDN 文档与实测为准。文中所有代码均为示例。
原有架构:分组参数"下发即信任"
先还原最初的实现。实验平台下发分组结果,客户端拿到后存到 localStorage,业务代码在需要的地方直接读取(这是一个典型的失败样例:读取环节没有做任何状态校验):
javascript · 旧实现示意(失败样例,不可直接生产使用)
csharp
// 旧实现示意:下发即信任,直接读缓存
const group = localStorage.getItem('ab_group') || 'default';
applyFeature(group); // 按分组渲染页面
这套实现的运行链路是:服务端下发 → 客户端缓存 → 业务读取。问题出在"读取"这一环没有任何校验------缓存里存的是什么,就用什么。当实验回滚时,服务端把开关关掉了,但客户端 localStorage 里还躺着旧的 ab_group,所有没清缓存的老用户继续命中实验组。这不是偶发,是结构性问题。类似的"下发链路"设计,在各类实验/功能开关平台里是同一套思路;具体平台的实验配置下发方式、版本与回滚语义,均需以官方文档与书面核验为准,本文不声称任何平台已具备特定机制。
复盘时把问题归成三条:
- 回滚只作用于服务端。开关关了,但客户端副本没人管。
- 缓存没有有效期。分组结果存多久、什么时候失效,完全没有概念。
- 没有版本概念。实验调整过参数后,老参数照样被当成有效分组使用。
结论:问题的本质是"分组参数从下发到消费的整条链路缺少状态校验",而不是某一个环节写错。
重构思路:服务端签名 assignment,客户端只做缓存校验
重构的目标不是删掉缓存(缓存对性能和体验仍然必要),而是让客户端对缓存里的分组参数保持怀疑 。核心变化:分组结果不再是一串裸的 group 值,而是服务端返回的签名 assignment ------包含实验 ID、版本、过期时间、分组、配置 hash 等字段,并带签名防篡改;客户端只缓存并校验这份 assignment,不在代码里硬编码版本号常量(客户端常量会过期,版本基准必须来自服务端下发或按需拉取的配置元数据)。
第一层:版本校验。 assignment 里带 version,客户端读取时与当前生效配置(服务端下发或拉取的元数据)比对;不匹配即判定失效、回退默认组。这一层拦截的是"实验参数被调整过但客户端还在用旧参数"。
第二层:过期校验。 assignment 里带 expires_at(或 TTL),读取时发现已过期,立即清除并重新向服务端取分组。过期时间的判定要考虑时钟偏差:客户端时钟不可信,建议用服务端时间戳或留偏差容错,而不是拿客户端本地时间硬比。
第三层:签名与兜底。 对支付结算、订单金额等关键路径,校验签名(防止用户篡改分组参数刷实验组)并允许异步回源确认一次分组有效性;回源失败时按"回退默认组"处理,保证可用性优先于分组准确性。
安全边界:签名与防重放。 签名方案至少要回答五个问题:
- 签名算法:服务端对 assignment 字段做哈希后签名(示例:HMAC-SHA256 或服务端私钥签名,具体算法由团队安全评审决定),并把"算法标识"随 assignment 下发,便于轮换;
- 密钥轮换:签名密钥要有轮换机制(按周期或泄露即换),轮换时保留旧密钥的短宽限期,避免下线瞬间全量失效;
- 签名过期 :签名随
expires_at一起失效------过期后的 assignment 即使签名有效也不可用,签名不能脱离有效期单独成立; - nonce/防重放:对回源校验请求加 nonce(一次性随机数)并记录已消费的 nonce,或严格依赖有效期,防止同一份 assignment 被反复重放当作有效分组;
- 私钥边界与敏感字段 :签名私钥/服务端专用密钥绝不能下发到客户端------客户端若需本地验签,只能持有公开验证材料(公钥)或不做本地验签、由服务端权威校验;涉及用户敏感信息的分组参数不下发客户端,敏感配置只留在服务端判断。服务端还应能主动拒绝旧版本 assignment(实验紧急下线或版本作废时,回源/刷新元数据即判定失效)。
javascript · 新实现示意:签名 assignment + 三层校验
csharp
// assignment 由服务端签发并签名,示例字段(伪代码)
// { experimentId, version, group, expiresAt, configHash, signature }
function resolveGroup(cached, serverMeta) {
if (!cached) return { group: 'default', reason: 'no-cache' };
// 0) 签名校验:签名无效视为被篡改,直接回退
if (!verifySignature(cached)) {
clearGroup();
return { group: 'default', reason: 'bad-signature' };
}
// 1) 版本校验:与 serverMeta 中的生效版本比对(版本基准来自服务端,不写死常量)
if (cached.version !== serverMeta.currentVersion) {
clearGroup(); // 版本不匹配 → 清除残留
return { group: 'default', reason: 'version-mismatch' };
}
// 2) 过期校验:用服务端时间(或带偏差)判断
if (serverNow() > cached.expiresAt) {
clearGroup(); // 已过期 → 清除残留
return { group: 'default', reason: 'expired' };
}
return { group: cached.group, reason: 'ok' }; // 校验通过,正常使用
}
// 关键路径:resolveGroup 后可选 async 回源兜底,超时按默认组渲染
图:分组参数从下发、缓存到回滚校验的状态变化------每层校验对应一个回退原因
新旧方案对照
| 环节 | 旧方案 | 新方案 |
|---|---|---|
| 分组来源 | 服务端下发裸 group 值 | 服务端签发签名 assignment(含版本/过期/配置 hash) |
| 版本基准 | 无 | 服务端元数据,客户端不硬编码常量 |
| 读取逻辑 | 无条件信任缓存 | 签名 + 版本 + 过期三层校验 |
| 回滚处理 | 只关服务端开关 | 客户端残留自动失效 |
| 关键路径 | 无兜底 | 回源兜底、失败回退默认组 |
| 适用场景 | 小流量、短周期 | 生产级、长周期、支付结算 |
对照组非常直观:旧方案只解决"源头开关",新方案同时管住"源头 + 客户端副本"。多端场景下这个对比更明显------如果网站、App、小程序共用同一套实验配置, "客户端副本失效机制"必须是三端统一的 ,而不是各端自己实现一套;各端存储位置(localStorage、Cookie、服务端会话)不同,但校验语义要一致。这是本文的设计建议,不声称任何平台默认提供多端统一语义;具体平台的多端配置与回滚行为,以官方文档与书面核验为准。

图:新旧分组校验方案对照------重点看版本基准与签名校验两行
落地的五个边界
重构方案上线后,有五个边界值得记录:
边界一:回源校验不能阻塞,关键路径与非关键路径分开定义。 关键路径(支付结算、订单金额等)回源确认分组有效性时,如果接口超时,不能等------直接按默认组渲染,异步重试,分组准确性是优化项、可用性是一票否决项;非关键路径(如推荐位、样式差异)允许在"签名有效期内"使用缓存分组,但缓存不得无限期有效,超过最大可接受陈旧时间(max_stale,由配置元数据定义,示例:24 小时)后必须回源或回退默认组,避免用过期分组跑太久。
边界二:默认组不是写死的。 "回退默认组"的"默认组"由服务端配置决定(比如"新版 vs 旧版"实验,默认组通常是当前线上版本),客户端不硬编码默认组。写死了,以后默认版本变更又要发版。
边界三:清理动作要幂等、失败要静默。 清除 localStorage/Cookie 这个动作本身不能影响正常用户------版本校验通过的用户不该被误清,清理失败也不该导致页面报错。清缓存用 try-catch 包住,静默失败即可。
边界四:时钟偏差。 客户端系统时间可能被用户改乱,过期校验若直接用客户端本地时间,会出现"提前过期"或"永不过期"。建议以服务端下发的 expires_at 为基准,或对比时留出偏差窗口(示例:±5 分钟)。
边界五:服务端撤销与隐私。 服务端要能主动撤销某个版本的 assignment(例如实验紧急下线),客户端回源或刷新配置元数据时发现撤销即回退默认组;同时注意隐私与敏感配置边界------涉及用户敏感信息的分组参数不应下发到客户端,敏感配置只留在服务端判断。
上线前必测的七种场景
| 测试场景 | 构造方式 | 预期结果 |
|---|---|---|
| 回滚 | 服务端关闭实验,客户端保留旧 assignment | 客户端回源/刷新后回退默认组,残留被清除 |
| 过期 | 构造 expires_at 已过期的 assignment | 读取时判定过期,回退默认组 |
| 版本不匹配 | 服务端升版本,客户端仍持有旧版本 assignment | 版本校验失败,回退默认组 |
| 服务端撤销 | 服务端撤销某版本,客户端带旧 assignment 回源 | 回源返回撤销状态,回退默认组 |
| 离线 | 断网时读取缓存 assignment | 非关键路径可用缓存(签名有效期内);关键路径按默认组渲染并异步重试 |
| 跨设备 | 同一用户在不同设备/浏览器各有一份缓存 | 各端按各自校验语义回退或续用,行为一致 |
| 重放攻击 | 把已消费的 assignment 或请求重复提交 | nonce 已记录或已过期,服务端拒绝,回退默认组 |
七种场景覆盖了回滚、过期、版本不匹配、服务端撤销、离线、跨设备、重放攻击七类状态变化,能验证"回滚真正生效"而不只是"开关关了"。

图:回滚校验完整流程(下发 → 缓存 → 校验 → 兜底)------每个分支都要对应一种回退原因
复盘结论
这次重构的收益,不在"多写了几行校验代码",而在把"分组参数的生命周期"想清楚了:下发不是终点,消费前的校验才是。任何做实验平台或功能开关的团队,建议回看一遍自己的读取链路------如果业务代码里直接 getItem('ab_group') 就使用分组,那大概率还停留在"下发即信任"的阶段。
参考资料
1.MDN《Window.localStorage》:存储的过期与清理语义
2.MDN《HTTP Cookie》:Cookie 的过期与作用域语义
3.MDN《Web Crypto API》:签名与防篡改的一般性说明
这次复盘沉淀下来的
- A/B 实验回滚失效的根因常是客户端缓存残留,回滚必须同时管住源头与副本。
- 用服务端签名的 assignment(实验 ID、版本、过期时间、分组、配置 hash)替代裸 group 值;客户端只做缓存校验,不硬编码版本常量。
- 回源不阻塞、默认组由服务端决定、清理幂等、时钟偏差、服务端撤销与隐私边界,是落地时的五个边界;六种场景上线前必测。
上线前再核对
- 你的实验平台分组参数存在 localStorage 吗?实验回滚后老用户还在实验组吗?
- 如果回源接口不稳定,你会优先保证分组准确性还是页面可用性?
- 你的分组参数里有敏感信息吗?哪些字段不应该下发到客户端?