前面十二章都在讲「怎么把一个数值算清楚」:属性、成长、战斗、掉落、抽卡、匹配、赛季、付费、定价、LTV、留存。但从第 9 章开始,很多结论是假设——折扣的需求响应是假设、流失预警的召回率是假设、价格弹性是假设。要判断一个改动到底有没有用,只有一条路:做实验。本章讲的就是这条路的工程实现。它和 数据分析埋点方案 是分工关系:那篇是埋点与指标口径清单(事件怎么埋、指标怎么定义),本章是实验设计、显著性与决策的可跑流程(怎么分、怎么判、怎么拍板)。没有前者,实验没有数据;没有后者,数据只是数字。
实验最容易出错的地方,不是算显著性,而是分流。如果同一个玩家今天在对照组、明天在实验组,那么「两组差异」里混进了「同一个人反复横跳」的噪声,后面再精致的统计也救不回来。所以本章从分流讲起。
一次实验必须定义的三件事
做实验之前,有三件事必须先写下来,缺一件就不是实验,是「改了就上线」:
- 假设(hypothesis):改了什么、预期哪个指标往哪个方向动。本套的主实验假设是「把次日付费引导位提前,D1 付费转化率从 80‰ 升到 96‰」。
- 指标(metric):一个主指标决定成败,若干护栏指标防止改坏别处。主指标是 D1 付费转化率;护栏是 D7 留存、单次会话时长——主指标涨了但留存崩了,同样不能上。
- 分流(traffic split):哪些玩家进对照组、哪些进实验组,各占多少。本套按 50/50 分,参数写在
config/n13_experiment.json。
前两件是产品决策,第三件是工程实现。本节只讲第三件,因为它最容易被随手写错。
分流的两种写法
分桶看起来只是「随机扔一半人进实验组」,但「随机」有两种完全不同的写法:
- 不稳定分流:每次曝光时现抽一个随机数决定看哪个变体。实现最简单,但同一个玩家每次曝光都可能换组。
- 稳定分桶:用
user_id的哈希当种子算桶号,同一个玩家永远分到同一桶。
两者的区别不是精度,是语义。不稳定分流下,一个玩家可能先看到 A 再看到 B,实验测到的就不是「A 与 B 谁更好」,而是「A 与 B 来回切换的效果」。真正可用的分流必须是稳定的,而且要可复现——同一份用户池、同一个种子,任何人重跑都得到同一份分桶结果。
本套用确定性 PRNG mulberry32(Python 与 TypeScript 同算法同种子)配 user_id 的 FNV-1a 哈希来实现:桶号 = floor(mulberry32(fnv1a(user_id) ^ seed)() × 桶数)。哈希保证稳定,PRNG 保证均匀。
仿真:10 万用户的分桶
下面这段是 S 层仿真脚本,读 config/n13_experiment.json,对 10 万个用户做稳定分桶,统计各桶人数与目标比例的偏差,并对照「不稳定分流」的代价:
# file: sim/n13_split.py
# tier: S
# verify: python3 sim/n13_split.py --config config/ --out out/
"""实验分流仿真(13.1):用确定性 PRNG mulberry32 + user_id 的 FNV-1a 哈希做稳定分桶。
三件事:
1. 稳定分桶:同一 user_id 每次分到的桶不变(hash 做种子,不依赖运行时随机)。
2. 分桶比例:各桶人数与目标千分比的偏差,以及 running share 是否收敛到目标。
3. 不稳定分流的代价:若每次曝光都重抽随机数,一个用户会在多个变体之间来回跳。
产出 out/n13_split.json + out/n13_split.csv + out/n13_split.svg。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from svg_line import render # noqa: E402
def mulberry32(seed):
"""确定性 PRNG:与 proto/src/n13_split.ts 逐位一致。"""
state = seed & 0xFFFFFFFF
def nxt():
nonlocal state
state = (state + 0x6D2B79F5) & 0xFFFFFFFF
t = ((state ^ (state >> 15)) * (state | 1)) & 0xFFFFFFFF
t = (t ^ (t + (((t ^ (t >> 7)) * (t | 61)) & 0xFFFFFFFF))) & 0xFFFFFFFF
return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296.0
return nxt
def fnv1a(s: str) -> int:
"""32 位 FNV-1a 哈希:把 user_id 变成一个稳定的整数种子。"""
h = 0x811C9DC5
for ch in s.encode("utf-8"):
h ^= ch
h = (h * 0x01000193) & 0xFFFFFFFF
return h
def bucket_of(user_id: str, num_buckets: int, seed: int) -> int:
"""稳定分桶:bucket = floor(mulberry32(fnv1a(user_id) ^ seed)() * num_buckets)。"""
r = mulberry32((fnv1a(user_id) ^ seed) & 0xFFFFFFFF)()
return min(num_buckets - 1, int(r * num_buckets))
def load(path: str) -> dict:
with open(path, encoding="utf-8") as fh:
return json.load(fh)
def main() -> int:
here = os.path.dirname(os.path.abspath(__file__))
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=os.path.join(here, "..", "config"))
ap.add_argument("--out", default=os.path.join(here, "..", "out"))
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
cfg = load(os.path.join(args.config, "n13_experiment.json"))
seed = cfg["seed"]
sp = cfg["split"]
buckets = sp["buckets"]
nb = len(buckets)
users = sp["users"]
target = sp["targetPermille"]
counts = [0] * nb
running = [] # 每 1000 人采样一次 control 桶的占比(千分比)
for i in range(users):
uid = f"u{i:06d}"
b = bucket_of(uid, nb, seed)
counts[b] += 1
if (i + 1) % 1000 == 0:
running.append(counts[0] * 1000 // (i + 1))
shares = [c * 1000 // users for c in counts]
dev = [shares[i] - target[i] for i in range(nb)]
# 稳定分桶自检:同一 id 连算 3 次必须一致
stable_ok = all(
len({bucket_of(f"u{i:06d}", nb, seed) for _ in range(3)}) == 1
for i in range(0, 2000, 7)
)
# 不稳定分流:每次曝光重抽(无稳定 key),统计跨多次曝光看到 >1 个变体的用户比例
redraws = sp["unstableRedraws"]
rnd = mulberry32((seed ^ 0x5A17) & 0xFFFFFFFF)
multi_variant = 0
for _ in range(users):
seen = set()
for _ in range(redraws):
seen.add(min(nb - 1, int(rnd() * nb)))
if len(seen) > 1:
multi_variant += 1
multi_permille = multi_variant * 1000 // users
rows = [{"bucket": buckets[i], "count": counts[i], "sharePermille": shares[i],
"targetPermille": target[i], "devPermille": dev[i]} for i in range(nb)]
with open(os.path.join(args.out, "n13_split.csv"), "w", encoding="utf-8", newline="") as fh:
w = csv.writer(fh)
w.writerow(["bucket", "count", "sharePermille", "targetPermille", "devPermille"])
for r in rows:
w.writerow([r["bucket"], r["count"], r["sharePermille"], r["targetPermille"], r["devPermille"]])
render(running, os.path.join(args.out, "n13_split.svg"), "control share permille (running)")
report = {
"version": 1, "seed": seed, "users": users, "buckets": buckets,
"rows": rows, "maxAbsDevPermille": max(abs(d) for d in dev),
"stableBucket": stable_ok,
"unstableRedraws": redraws, "multiVariantPermille": multi_permille,
"runningSampleN": len(running), "runningTail": running[-1],
}
with open(os.path.join(args.out, "n13_split.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
print(json.dumps(report, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 out/n13_split.csv(种子 20261012,10 万用户,目标 500/500‰):
| 桶 | 人数 | 占比(‰) | 目标(‰) | 偏差(‰) |
|---|---|---|---|---|
| control | 50232 | 502 | 500 | +2 |
| treat | 49768 | 497 | 500 | −3 |
脚本还输出 maxAbsDevPermille = 3、stableBucket = true、multiVariantPermille = 872。三个数各有含义:
maxAbsDevPermille = 3:两个桶的占比与目标最多差 3‰。10 万用户下,50/50 的理想波动约 ±1.6‰(1000 分之 500 的标准差约为500/√100000 ≈ 1.6‰),3‰ 在正常范围内。偏差不是零——随机分流天然有波动,只要落在预期内就不算问题。stableBucket = true:抽查的 286 个用户里,每个连算 3 次都落在同一桶。这正是稳定分桶的意义:同一个玩家在实验期内始终看同一个变体。multiVariantPermille = 872:这是不稳定分流的代价。如果每次曝光都重抽(4 次曝光),872‰ 的用户至少会看到两个不同的变体。两桶、4 次独立重抽下,「4 次全在同一桶」的概率是2×(1/2)^4 = 125‰,所以「看到超过一个变体」的理论值是875‰——实测 872‰,吻合。
这一行是整个 13.1 的核心:分流不稳,实验就废。872‰ 的用户被两组「同时影响」,测出来的任何差异都无法归因。
原型:跨语言一致的分桶
分流算法必须跨语言一致——线上是客户端/服务端某种语言,分析是另一种语言,两边分桶结果必须逐位相同,否则「实验组」在两边是两拨人。下面这段是 P 层原型,用 TypeScript 实现同一套 mulberry32 + FNV-1a:
// file: proto/src/n13_split.ts
// tier: P
// verify: node --test proto/test/n13.test.ts
// 实验分流原型(13.1):与 sim/n13_split.py 同一套 mulberry32 + FNV-1a,
// 对 user_id 做稳定分桶,逐位一致。
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const HERE = dirname(fileURLToPath(import.meta.url));
export const CONFIG_DIR = join(HERE, "..", "..", "config");
export interface SplitConfig {
seed: number;
split: { users: number; buckets: string[]; targetPermille: number[]; unstableRedraws: number };
}
export interface SplitReport {
counts: number[]; sharesPermille: number[]; maxAbsDevPermille: number;
}
export function loadJson<T>(name: string): T {
return JSON.parse(readFileSync(join(CONFIG_DIR, `${name}.json`), "utf-8")) as T;
}
/** 确定性 PRNG:与 Python 侧 mulberry32 逐位一致(32 位无符号掩码 + imul)。 */
export function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return function (): number {
a = (a + 0x6D2B79F5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a) >>> 0;
const t0 = t;
t = (t0 + Math.imul(t0 ^ (t0 >>> 7), 61 | t0)) >>> 0;
t = (t ^ t0) >>> 0;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** 32 位 FNV-1a:把 user_id 变成稳定整数种子(与 Python 侧逐位一致)。 */
export function fnv1a(s: string): number {
let h = 0x811C9DC5;
const bytes = new TextEncoder().encode(s);
for (const b of bytes) {
h ^= b;
h = Math.imul(h, 0x01000193) >>> 0;
}
return h >>> 0;
}
export function bucketOf(userId: string, numBuckets: number, seed: number): number {
const r = mulberry32(((fnv1a(userId) ^ seed) >>> 0))();
return Math.min(numBuckets - 1, Math.floor(r * numBuckets));
}
export function assignAll(users: number, numBuckets: number, seed: number): number[] {
const counts = new Array<number>(numBuckets).fill(0);
for (let i = 0; i < users; i++) {
counts[bucketOf(`u${String(i).padStart(6, "0")}`, numBuckets, seed)]++;
}
return counts;
}
export function analyze(cfg: SplitConfig): SplitReport {
const nb = cfg.split.buckets.length;
const counts = assignAll(cfg.split.users, nb, cfg.seed);
const shares = counts.map((c) => Math.floor((c * 1000) / cfg.split.users));
const dev = shares.map((s, i) => s - cfg.split.targetPermille[i]);
return { counts, sharesPermille: shares, maxAbsDevPermille: Math.max(...dev.map((d) => Math.abs(d))) };
}
配套测试 proto/test/n13.test.ts 断言 TS 侧分桶与 Python 实跑逐位一致:计数 [50232, 49768]、占比 [502, 497]、最大偏差 3、fnv1a("u000000") = 1764898608,以及同一 user_id 多次分桶不变。本机 node --test proto/test/n13.test.ts 五个用例全过。
灰度放量与实验的区别
分流解决「分得对」,灰度解决「放得稳」。两者常被混为一谈,但目标不同:
- A/B 实验:为了测量。两组必须同时在线、同时受同样的大盘环境影响,否则差异无法归因。
- 灰度放量(canary / ramp):为了安全。先放 1% 看有没有崩溃、有没有指标异常,再逐步放到 100%。它测的是「会不会出事」,不是「哪个更好」。
灰度放量的版本列车与放量节奏,卷一 7.3 灰度、放量与回滚 已给出工程流程,本节不重复。这里只强调一点:一次「灰度放量」如果顺手拿去比指标,它不是实验——因为放量时两组不同时在线、样本量也不等,直接比会得出错的结论。
分层与互斥
真实项目不会一次只跑一个实验。同时在线的多个实验如果不加约束,会互相污染:一个改付费引导、一个改新手教程,同一个用户同时进两个实验,就分不清是谁的功劳。工程上有两种隔离方式:
- 互斥层(mutual exclusion):把实验分到不同「层」,同层内互斥,一个用户只能进一个。
- 分层正交(layered / orthogonal):不同层的分桶用不同的哈希盐,让「进不进实验 A」与「进不进实验 B」统计独立,从而可以叠加。
本套的分流函数用 fnv1a(user_id) ^ seed 做种子,seed 就是那层盐——换层就换 seed,同一个用户在不同层会落到不同的桶。这为后续做分层正交留好了接口。
分流做对了,才轮到显著性
本节把分流讲透了:稳定分桶(同一用户永远同组)、可复现(同种子同结果)、跨语言一致(Python/TS 逐位相同)、以及不稳定分流的真实代价(872‰ 的用户看到多个变体)。但分流只是第一步——分得再好,如果样本量不够或偷看太多次,结论依然是错的。下一节进入实验的核心:显著性、样本量,以及四个最常见的陷阱。
阅读导航:上一节:目录 · 下一节:13.2 显著性、样本量与常见陷阱 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。