前端知库

前端怎样做灰度发布和 A/B 实验?

灰度是让一部分用户先用新功能,确认没问题再放量;A/B 实验是把用户随机分成几组,对比指标判断哪个方案更好。两者都靠稳定分流:用用户 ID 加实验名做哈希分桶,同一个用户每次都落在同一组。前端要避免页面闪烁,在分组生效时上报曝光,并把分组带进埋点和错误日志。

参考答案

简答

电商要上线新版结算页,不敢一下子全量,就先给 5% 的用户看新版,盯着错误率和支付成功率,没问题再放到 20%、50%、100%,这是灰度。如果产品想知道新版是不是真的能提高转化,就把用户随机分成 A、B 两组,一组看旧版一组看新版,跑够时间后对比数据,这是 A/B 实验。

两者的核心都是分流,而且分流必须稳定。不能用 Math.random(),否则用户刷新一下就换了版本,体验混乱,数据也没法比。通常用用户 ID 加实验名算一个哈希,取模得到 0 到 99 的桶号,桶号小于比例的进新版。同样的输入永远得到同样的结果,放量时已经在新版的用户也不会被切回去。

前端要做三件事:分组结果最好随首屏数据一起下发,避免页面先渲染旧版再闪成新版;在用户真正看到实验内容时上报一次曝光;把分组信息带进所有埋点和错误日志,出了问题能分清是哪个版本。

要注意,实验跑起来以后不要中途改比例或者改目标人群,那会让分组失衡、结论失效。各组实际人数和设定比例明显对不上时,说明分流或上报有 bug,这时的数据不能用来下结论。

灰度和 A/B 实验有什么不同

两者用的技术几乎一样,都是按规则把用户分到不同的代码路径,区别在目的。

灰度的目的是降低发布风险。新功能先给一小部分用户,关注的是错误率、白屏、接口失败这类“有没有坏”的指标,确认安全后逐步放量直到全量,然后删掉开关。Martin Fowler 的文章把这种做法叫金丝雀发布,用的就是功能开关的基础设施。

A/B 实验的目的是做决策。比例通常是固定的,比如五五开,关注的是转化率、停留时长这类业务指标。文章指出,实验开关要保持足够长的时间,直到结果在统计上显著,可能是几小时也可能是几周;实验期间修改参数,结果很可能就不可信了。

怎样做稳定分流

下面用一个简单的哈希函数实现分桶。同一个用户、同一个实验,算出来的桶号永远一样。

JavaScript
// FNV-1a 32 位哈希:同一个字符串永远得到同一个数
function hash(text) {
  let value = 0x811c9dc5;
  for (let i = 0; i < text.length; i += 1) {
    value ^= text.charCodeAt(i);
    value = Math.imul(value, 0x01000193);
  }
  return value >>> 0;
}

// 用户 ID 和实验名一起哈希,换一个实验分组就重新打散
function bucket(userId, experiment) {
  return hash(`${experiment}:${userId}`) % 100;
}

function assign(userId, experiment, treatmentPercent) {
  return bucket(userId, experiment) < treatmentPercent ? 'B' : 'A';
}

console.log(assign('user-42', 'new-checkout', 50) === assign('user-42', 'new-checkout', 50)); // true

let countB = 0;
for (let i = 0; i < 10000; i += 1) {
  if (assign(`user-${i}`, 'new-checkout', 10) === 'B') countB += 1;
}
console.log(countB); // 1002,接近 10000 的 10%

// 比例从 10% 放大到 20%,原来在 B 组的用户仍然在 B 组
let stayed = true;
for (let i = 0; i < 10000; i += 1) {
  const id = `user-${i}`;
  if (assign(id, 'new-checkout', 10) === 'B' && assign(id, 'new-checkout', 20) !== 'B') stayed = false;
}
console.log(stayed); // true

第一个输出说明分组是稳定的。第二个输出是 1 万个用户里进入 B 组的人数,设定 10%,实际 1002 人,哈希分布得比较均匀。第三个输出说明放量时“桶号小于 10”的用户一定也“小于 20”,已经用上新版的人不会被切回旧版。

为什么要把实验名也放进哈希?如果只用用户 ID,桶号小的那批人会在每个实验里都进新版,各实验的结果互相干扰。加上实验名,每个实验都会把用户重新打散。

用户 ID 用什么?登录用户用账号 ID,未登录用户可以生成一个随机 ID 存在 Cookie 里。要注意未登录时分到 A 组、登录后用账号 ID 又分到 B 组的情况,需要事先决定以哪个 ID 为准。

分流最好在服务端做,结果随页面数据下发,前端只读取。这样接口和页面按同一个结果工作,前端也不用打包实验配置。功能开关整体怎样设计,见《前端功能开关和灰度发布应该怎样设计?》。

前端怎样避免页面闪烁

如果前端先渲染旧版,再异步请求分组结果,拿到后切成新版,用户就会看到页面闪一下。这不仅体验差,还会污染实验:B 组用户其实先看了 A 版。

所以首屏用到的分组,应该随 HTML 或第一个接口一起返回,渲染前就确定。做不到时,可以在拿到结果前先显示骨架屏,而不是先显示旧版。分组服务超时就用默认值,默认值选旧版。

曝光和数据怎样上报

曝光事件表示用户真的进入了实验、看到了实验内容。为什么不在分组时就算曝光?比如实验改的是结算页,可大部分被分组的用户根本没走到结算页,把他们也算进来,差异就被稀释了。

上报还要保证两组的丢失情况一致。GrowthBook 文档提到一个常见问题叫样本比例失衡(SRM):设定五五开,实际两组人数差得明显。最常见的原因是分组在服务端完成,曝光却靠前端上报,而广告拦截、脚本报错、用户提前离开都会让前端上报丢失;机器人只请求页面不执行脚本,也会被分组却没有曝光。如果新版页面更慢、更容易报错,B 组的曝光丢得更多,比例就失衡了。

所以分组和曝光要尽量在同一处完成,上报逻辑不能因组别不同而不同。每条埋点和错误日志都带上实验名和组别,排查问题时才能按版本拆开看。

常见追问

实验结果看起来很好,可以提前结束吗?

不建议看到显著就马上停。按常规的固定样本方法分析时,应该在开始前定好样本量或实验时长,到了再看结论。GrowthBook 文档把中途反复查看叫作 peeking 问题,它会让假阳性率高于设定的水平,也就是把偶然的波动当成真实效果。确实需要边跑边看,要改用专门支持这种做法的序贯检验。

灰度出了问题怎样快速回退?

开关值由服务端下发的话,把比例改成 0 就能让所有人回到旧版,不用重新发布前端。前提是旧版代码还在、旧版依赖的接口还兼容。前端静态资源的版本切换和回滚,见《前端怎样做灰度发布和金丝雀发布?出问题怎样回滚?》。