《互联网产品的客户端架构·交易、增长与平台化》10.1 实验分流与曝光口径

结算页改版上线后先涨后跌,问题往往不在实验结论,而在分流与曝光口径。本节用哈希加盐实现稳定分流、用分层隔离并行实验、把渲染、可见、点击三种曝光分开,并给出只上报一次的曝光日志与灰度开关模型,代码可直接运行,最后复盘一次按会话分流的典型误判。

一次先涨后跌的改版

「回声」的结算页把「立即支付」改成「确认支付并下单」,希望降低误触。实验上线第一天,实验组支付转化率比对照组高 2 个百分点;第三天回落到持平,第五天反而低了 1 个百分点。

团队先怀疑按钮文案,又怀疑样本量,最后才发现两个更基础的问题:

  1. 分流用的是会话 id。用户从商详页进入结算页时换了一次会话,登录后会话 id 又变了一次,同一个人在不同阶段落进了不同分组。
  2. 曝光在页面渲染时上报。支付组件在后台预渲染,用户根本没看到按钮,也被算进了实验组的分母。

实验结论因此不可信。分流决定「谁进组」,曝光决定「谁算数」,两件事都错了,后面的统计再精细也没有意义。

稳定分流需要三个前提

前提做法不这样做会怎样
分流单元稳定登录用户用用户 id,未登录用持久化设备 id会话 id 一变就换组,用户自己和自己对比
每个实验独立加盐salt = 实验名 + 版本号多个实验用同一个盐,同一批用户总被绑在一起
桶位可复现哈希后取模到 0..9999随机数每次调用都变,无法复现、无法排查

分流不是「随机抽奖」,而是确定性映射。同一个用户、同一个实验、同一个盐,在任何设备、任何时间都应得到同一个桶位。服务端与客户端各自实现时,哈希算法也必须一致,否则两端会算出两个答案。

第三个前提常被忽略:桶位必须落日志。用户投诉「我昨天还是新版」时,只有桶位能回答他到底在哪个组,而不是靠猜。

曝光口径:渲染、可见、点击不是一回事

口径触发时机适合回答常见偏差
渲染组件执行渲染功能覆盖、性能预渲染、隐藏容器也算,分母虚高
可见进入视口并停留曝光转化、CTR需要可见性判定,成本略高
点击用户真实点击交互意愿只能解释已发生的点击,不能当曝光

交易场景里,如果实验目标是「按钮文案是否提升支付意愿」,曝光应该用可见:按钮渲染在折叠区域下方、用户没滚动到,就不该进分母。如果目标是「新组件是否拖慢首屏」,则应该用渲染,因为性能成本在渲染时就已发生。

口径必须在实验开始前写死,而不是上线后挑一个看起来显著的口径。换口径比换结论更容易自欺。

只上报一次

同一用户在同一实验里只应有一条曝光记录。实现上有两个细节:

  • 去重键是「实验 + 用户」,不是「实验 + 用户 + 页面」。用户从商详页看到按钮、又从购物车看到同一个按钮,仍然只算一次曝光。
  • 先到的阶段决定记录。如果渲染先到、可见后到,记录保留渲染;如果产品只关心可见,就应该在渲染阶段不调用上报,而不是先报再改。

去重状态要跟随用户会话持久化。只放在内存里,冷启动后会重复上报;只放在服务端,又无法阻止同一页面内的重复调用。常见做法是本地存一份「已上报键」,服务端再做一次幂等。

分层:并行实验不互相污染

一个产品同时跑十几个实验是常态。如果所有实验都用同一套哈希,用户会同时命中所有实验,实验之间互相影响,结论无法归因。

分层解决的是这个问题:把互不相关的实验放进不同层,每层有自己的盐,层与层之间允许同一用户同时命中;同一层内则只允许一个实验命中。比如「结算流程层」和「搜索排序层」可以并行,「结算按钮」和「结算页布局」就必须互斥或串行。

分层的代价是配置复杂度。层名、盐、优先级都要版本化,否则一次改名就会让线上用户集体换组。

开关与灰度

实验开关不是布尔值那么简单,它至少包含三个状态:

  • enabled=false:实验关闭,所有人走对照。
  • rollout < 10000:只有桶位小于 rollout 的用户进入实验,其余人走对照。
  • enabled=true, rollout=10000:全量实验。

灰度比例应该按桶位切,而不是按随机数切。按桶位切的好处是:把 rollout 从 10% 调到 20% 时,原来 10% 里的用户仍然在实验组,新增的 10% 是确定的另一批人;按随机数切则每次调整都会重新洗牌。

一个可运行的分流模块

下面的模块属于 L1:只有哈希、查表和内存去重,没有网络、没有定时器。

// file: src/experiment/split.ts
// tier: L1
// verify: node --test test/experiment/split.test.ts

export type UnitKind = "user" | "device";

export interface VariantDef {
  name: string;
  weight: number;
}

export interface ExperimentDef {
  key: string;
  layer: string;
  salt: string;
  unit: UnitKind;
  enabled: boolean;
  rollout: number;
  variants: VariantDef[];
}

export type AssignReason =
  | "assigned"
  | "disabled"
  | "out-of-rollout"
  | "layer-busy";

export interface Assignment {
  experiment: string;
  variant: string | null;
  bucket: number;
  reason: AssignReason;
}

const TOTAL_WEIGHT = 10_000;

// FNV-1a 32 位,跨端、跨语言都容易实现,结果只依赖输入字符串。
export function hash32(input: string): number {
  let hash = 0x811c9dc5;
  for (let i = 0; i < input.length; i += 1) {
    hash ^= input.charCodeAt(i);
    hash = Math.imul(hash, 0x01000193);
  }
  return hash >>> 0;
}

// 盐参与哈希:同一用户在 A 实验落 bucket 100,在 B 实验完全独立。
export function bucketOf(unitId: string, salt: string): number {
  if (unitId.length === 0) {
    throw new Error("分流单元不能为空");
  }
  if (salt.length === 0) {
    throw new Error("实验盐不能为空");
  }
  return hash32(`${salt}:${unitId}`) % TOTAL_WEIGHT;
}

export function validateExperiment(def: ExperimentDef): string[] {
  const errors: string[] = [];
  if (def.key.length === 0) errors.push("实验 key 不能为空");
  if (def.layer.length === 0) errors.push("实验必须声明分层");
  if (def.salt.length === 0) errors.push("实验必须声明盐");
  if (!Number.isSafeInteger(def.rollout) || def.rollout < 0 || def.rollout > TOTAL_WEIGHT) {
    errors.push("rollout 必须是 0..10000 的整数");
  }
  if (def.variants.length < 2) {
    errors.push("至少需要两个分组");
  }
  const names = new Set<string>();
  let total = 0;
  for (const variant of def.variants) {
    if (variant.name.length === 0) errors.push("分组名不能为空");
    if (names.has(variant.name)) errors.push(`分组名重复:${variant.name}`);
    names.add(variant.name);
    if (!Number.isSafeInteger(variant.weight) || variant.weight <= 0) {
      errors.push(`分组权重非法:${variant.name}`);
    } else {
      total += variant.weight;
    }
  }
  if (total !== TOTAL_WEIGHT) {
    errors.push(`分组权重之和必须为 ${TOTAL_WEIGHT},实际 ${total}`);
  }
  return errors;
}

export function variantForBucket(variants: VariantDef[], bucket: number): string {
  if (!Number.isInteger(bucket) || bucket < 0 || bucket >= TOTAL_WEIGHT) {
    throw new Error(`bucket 越界:${bucket}`);
  }
  let upper = 0;
  for (const variant of variants) {
    upper += variant.weight;
    if (bucket < upper) {
      return variant.name;
    }
  }
  throw new Error("分组权重之和不足 10000");
}

export function assign(def: ExperimentDef, unitId: string): Assignment {
  const errors = validateExperiment(def);
  if (errors.length > 0) {
    throw new Error(errors.join(";"));
  }
  const bucket = bucketOf(unitId, def.salt);
  if (!def.enabled) {
    return { experiment: def.key, variant: null, bucket, reason: "disabled" };
  }
  if (bucket >= def.rollout) {
    return { experiment: def.key, variant: null, bucket, reason: "out-of-rollout" };
  }
  return {
    experiment: def.key,
    variant: variantForBucket(def.variants, bucket),
    bucket,
    reason: "assigned",
  };
}

// 同一层同时只允许一个实验命中:按传入顺序取第一个,其余标记 layer-busy。
// 层的价值在于让多个实验共享同一批用户而不互相污染,而不是共享同一个盐。
export function assignPlan(defs: ExperimentDef[], unitId: string): Assignment[] {
  const busyLayers = new Set<string>();
  const plan: Assignment[] = [];
  for (const def of defs) {
    const assignment = assign(def, unitId);
    if (assignment.reason === "assigned" && busyLayers.has(def.layer)) {
      plan.push({ ...assignment, variant: null, reason: "layer-busy" });
      continue;
    }
    if (assignment.reason === "assigned") {
      busyLayers.add(def.layer);
    }
    plan.push(assignment);
  }
  return plan;
}

export type ExposureStage = "render" | "visible" | "click";

export interface ExposureEvent {
  experiment: string;
  unitId: string;
  variant: string;
  stage: ExposureStage;
  at: number;
}

export function exposureKey(experiment: string, unitId: string): string {
  return `${experiment}\u0000${unitId}`;
}

// 曝光只报一次:先到的阶段决定记录,后续 visible/click 只做本地计数。
export class ExposureLog {
  private seen = new Set<string>();
  private events: ExposureEvent[] = [];

  record(event: ExposureEvent): boolean {
    const key = exposureKey(event.experiment, event.unitId);
    if (this.seen.has(key)) {
      return false;
    }
    this.seen.add(key);
    this.events.push({ ...event });
    return true;
  }

  size(): number {
    return this.events.length;
  }

  list(): ExposureEvent[] {
    return this.events.map((event) => ({ ...event }));
  }

  clear(): void {
    this.seen.clear();
    this.events = [];
  }
}

三个设计点值得单独说明。

哈希用 FNV-1a。 它实现短、跨语言容易对齐,输出稳定。生产上也可以用 MurmurHash 或 SHA-256 截断,关键是端与服务端使用同一份实现和同一份测试向量。

assignPlan 按传入顺序处理层。 同一层第二个命中的实验返回 layer-busy,而不是静默覆盖。顺序必须来自配置而不是代码里的对象遍历顺序,否则不同端可能得到不同优先级。

曝光日志只保留首次事件。 record 返回布尔值,调用方可以据此决定是否发网络请求;list 返回副本,避免外部修改内部状态。

测试与真实输出

本机运行结果如下:

$ node --test test/experiment/split.test.ts
✔ 哈希在小样本上保持稳定 (1.059125ms)
✔ 不同实验盐让同一用户落到不同桶 (0.089833ms)
✔ bucket 始终落在 0..9999 (0.11925ms)
✔ 同一用户重复分流得到相同分组 (0.484459ms)
✔ 权重边界决定分组 (0.068667ms)
✔ bucket 越界时拒绝分组 (0.221542ms)
✔ 灰度比例之外不返回分组 (0.080833ms)
✔ 实验关闭后不返回分组 (0.065375ms)
✔ 同一层第二个命中的实验让位 (0.105875ms)
✔ 不同层可以同时命中 (0.105625ms)
✔ 分组权重之和必须是 10000 (0.120042ms)
✔ 分组名重复与 rollout 越界被拒绝 (0.064791ms)
✔ 空用户或空盐不允许分流 (0.070959ms)
✔ 曝光键只由实验与用户决定 (0.04075ms)
✔ 同一用户同一实验只记录一次曝光 (0.094416ms)
✔ 不同实验或不同用户分别记录 (0.062ms)
✔ 清空后可以重新记录曝光 (0.084875ms)
ℹ tests 17
ℹ suites 0
ℹ pass 17
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 159.841375

十七条用例覆盖:哈希稳定、盐隔离、桶位范围、重复分流、权重边界、越界拒绝、灰度、开关、分层让位、跨层并行、权重校验、命名与 rollout 校验、空输入、曝光键、去重、多用户、清空。

一次「按会话分流」的复盘

某交易客户端做支付按钮实验,分流键用了会话 id。上线首日实验组转化率明显更高,团队准备全量。第二天开始回落,第三天与对照组持平。

复盘发现三处叠加:

  1. 会话 id 不稳定。用户从商品页到结算页会新建会话,登录后服务端又下发新会话。同一个人在实验组和对照组之间来回跳,两组都出现了「高转化」的短暂样本。
  2. 曝光在渲染时上报。支付组件预渲染但被弹层遮挡,用户没看到也算曝光,实验组分母被放大,早期数据看起来更好。
  3. 灰度按随机数切。每次调整比例都重新洗牌,同一用户今天在实验组、明天在对照组。

修复对应三点:分流键改为「登录用户 id,未登录设备 id」;曝光改到按钮真正可见时上报;灰度改为按桶位切,并把盐写进实验配置。回归后,同一用户的桶位在两端、多天保持一致,实验数据才具备可比性。

这个复盘说明:实验系统的正确性不在统计库,而在客户端那几个看似琐碎的键。 键选错,后面所有显著性检验都只是在给噪声盖章。

小结

实验分流是确定性映射:稳定的用户键、独立的实验盐、可复现的桶位。曝光要区分渲染、可见、点击,口径提前写死,同一用户同一实验只报一次。分层让并行实验互不干扰,灰度按桶位切而不是按随机数切。

这套规则不复杂,但它决定了实验数据能不能用。下一节讨论实验配置本身:远端下发的版本、缓存、过期与回退。

阅读导航:上一节:9.3 活动页降级与兜底 · 下一节:10.2 实验配置的本地缓存与回退 。

常见错误与边界

  • 用会话 id 或页面实例 id 分流,用户一跳转就换组。
  • 多个实验共用同一个盐,用户被绑成固定组合,无法归因。
  • 曝光在组件渲染时上报,预渲染与隐藏容器把分母撑大。
  • 曝光去重键带上页面或时间,同一用户被重复计数。
  • 灰度按随机数切,每次调整比例都重新洗牌。
  • 同一层跑两个实验且不声明优先级,不同端命中不同实验。
  • 桶位不落日志,用户投诉时无法还原他到底在哪个组。
  • 实验关闭后仍上报曝光,数据里留下无法解释的实验组流量。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查