一次先涨后跌的改版
「回声」的结算页把「立即支付」改成「确认支付并下单」,希望降低误触。实验上线第一天,实验组支付转化率比对照组高 2 个百分点;第三天回落到持平,第五天反而低了 1 个百分点。
团队先怀疑按钮文案,又怀疑样本量,最后才发现两个更基础的问题:
- 分流用的是会话 id。用户从商详页进入结算页时换了一次会话,登录后会话 id 又变了一次,同一个人在不同阶段落进了不同分组。
- 曝光在页面渲染时上报。支付组件在后台预渲染,用户根本没看到按钮,也被算进了实验组的分母。
实验结论因此不可信。分流决定「谁进组」,曝光决定「谁算数」,两件事都错了,后面的统计再精细也没有意义。
稳定分流需要三个前提
| 前提 | 做法 | 不这样做会怎样 |
|---|---|---|
| 分流单元稳定 | 登录用户用用户 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。上线首日实验组转化率明显更高,团队准备全量。第二天开始回落,第三天与对照组持平。
复盘发现三处叠加:
- 会话 id 不稳定。用户从商品页到结算页会新建会话,登录后服务端又下发新会话。同一个人在实验组和对照组之间来回跳,两组都出现了「高转化」的短暂样本。
- 曝光在渲染时上报。支付组件预渲染但被弹层遮挡,用户没看到也算曝光,实验组分母被放大,早期数据看起来更好。
- 灰度按随机数切。每次调整比例都重新洗牌,同一用户今天在实验组、明天在对照组。
修复对应三点:分流键改为「登录用户 id,未登录设备 id」;曝光改到按钮真正可见时上报;灰度改为按桶位切,并把盐写进实验配置。回归后,同一用户的桶位在两端、多天保持一致,实验数据才具备可比性。
这个复盘说明:实验系统的正确性不在统计库,而在客户端那几个看似琐碎的键。 键选错,后面所有显著性检验都只是在给噪声盖章。
小结
实验分流是确定性映射:稳定的用户键、独立的实验盐、可复现的桶位。曝光要区分渲染、可见、点击,口径提前写死,同一用户同一实验只报一次。分层让并行实验互不干扰,灰度按桶位切而不是按随机数切。
这套规则不复杂,但它决定了实验数据能不能用。下一节讨论实验配置本身:远端下发的版本、缓存、过期与回退。
阅读导航:上一节:9.3 活动页降级与兜底 · 下一节:10.2 实验配置的本地缓存与回退 。
常见错误与边界
- 用会话 id 或页面实例 id 分流,用户一跳转就换组。
- 多个实验共用同一个盐,用户被绑成固定组合,无法归因。
- 曝光在组件渲染时上报,预渲染与隐藏容器把分母撑大。
- 曝光去重键带上页面或时间,同一用户被重复计数。
- 灰度按随机数切,每次调整比例都重新洗牌。
- 同一层跑两个实验且不声明优先级,不同端命中不同实验。
- 桶位不落日志,用户投诉时无法还原他到底在哪个组。
- 实验关闭后仍上报曝光,数据里留下无法解释的实验组流量。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。