同一个用户,一天里看到两个版本
「刚好」的推荐团队要做一次首页改版实验。产品经理周一打开报表,实验组点击率比对照组高 6%,看起来可以准备全量。同一时间,客服收到一条投诉:一位用户说首页“一会儿是新版,一会儿又变回旧版,收藏的入口找不到了”。
研发先查了前端缓存,没发现问题;又查了网关,发现两个版本确实都发给了同一个用户。分流代码只有一行:
group := rand.Intn(100)
if group < 50 {
variant = "treatment"
} else {
variant = "control"
}
每次请求都重新随机。从单个请求看,用户有 50% 概率进入实验组;从用户视角看,他一天内可能十几次曝光里六次对照组、四次实验组。更麻烦的是,指标统计按曝光而不是按用户去重,同一个人的行为被同时记进两边,实验组的点击率被对照组的行为稀释,报表上的 6% 既不是新版的真实效果,也不是旧版的真实效果。
这不是随机数用错了,而是分流模型错了。实验分流要回答的不是“这次请求给哪个版本”,而是“这个用户在这段时间属于哪个组”。前者可以每次重算,后者必须稳定、可复现、可解释。
分流首先是一个确定性函数
把分流写成 hash(salt, userID) % n,同一用户在同一 salt 下永远得到同一个桶。服务重启、换机器、扩缩容都不影响结果,因为函数只依赖输入,不依赖进程状态或随机种子。
哈希函数不需要密码学强度,但需要两个性质:输出分布均匀,且输入微小变化会让结果明显不同。FNV-1a 足够便宜,也足够均匀,下面会用它。真正容易出错的是 salt 的设计。
| 做法 | 同一用户是否稳定 | 实验之间是否隔离 | 指标能否解释 | 典型问题 |
|---|---|---|---|---|
每次请求 rand.Intn | 否 | 不适用 | 否 | 同一用户反复换组,指标被稀释 |
| 只对 userID 取模 | 是 | 否 | 部分 | 所有实验共用一套分组,交互效应混在一起 |
| userID 加全局固定 salt | 是 | 否 | 部分 | 改一个实验会重排所有实验 |
| 每个实验独立 salt | 是 | 是 | 是 | 需要管理 salt 的生命周期 |
| 分层实验,每层独立 salt | 是 | 是 | 是 | 层内互斥、层间正交需要显式设计 |
salt 是实验的隔离键。两个实验使用不同 salt,同一用户在两个实验里的桶互不相关,这才叫正交。反过来,如果推荐实验和定价实验共用 "exp-2026" 这个 salt,那么进入推荐实验组的用户也会系统性地进入定价实验组,两个实验的效果无法拆开,交互项会污染结论。
还有一层常被忽略:分层。推荐层和定价层可以各自独立分流,因为用户同时被两个实验影响是业务允许的;但同一层里的两个实验必须互斥,否则一个用户可能同时命中两个改版。分层的实现不是加一张复杂的表,而是约定“层内共享 salt、层间使用不同 salt”,并在配置里把层名写进 salt。
失败场景:缓存把实验组页面发给了对照组
开头的事故还有第二个原因。分流本身改成了稳定哈希,但推荐页做了 CDN 缓存,缓存 key 只包含 URL 和地区,没有包含实验分组。第一个命中实验组的用户把新版页面写进缓存,后面同一地区的对照组用户直接拿到新版页面;反过来,实验组用户也可能拿到旧版。
于是日志里出现三种人:分到实验组且看到新版、分到实验组但看到旧版、分到对照组却看到新版。前两种让实验组指标被低估,第三种让对照组指标被高估。团队看到的 6% 差异,方向和幅度都不可信。
修复要做两件事。第一,分流结果必须进入缓存 key,或者实验页面干脆不缓存;第二,曝光日志要同时记录“分到哪组”和“实际看到哪版”,两者不一致时单独告警。很多团队只记录前者,于是缓存错误可以潜伏很久,直到有人发现同一用户截图里的按钮颜色对不上。
这类事故的共同点是:分流函数本身是对的,但分流的结论没有贯穿到缓存、日志和指标。稳定哈希只是起点,一致性要靠整条链路保证。
用 Bucket 与 Assign 实现稳定分流
Bucket 负责把用户映射到 0..n-1 的桶,Assign 负责把桶映射到具体的组。两者分开,是因为桶是实验基础设施,组是实验配置:同一个桶可以在不同实验里按不同边界切分。
// file: internal/experiment/split.go
// tier: L1
// verify: go test ./internal/experiment/
package experiment
import (
"errors"
"fmt"
"hash/fnv"
"sort"
)
// AssignBuckets 是 Assign 使用的桶数量。累计边界必须落在 0..AssignBuckets-1,
// 最后一个隐式边界是 AssignBuckets,因此百分比粒度是 1%。
const AssignBuckets = 100
var (
// ErrEmptyUserID 表示分流时用户 ID 为空。
ErrEmptyUserID = errors.New("experiment: empty user id")
// ErrInvalidBucketCount 表示桶数量不是正数。
ErrInvalidBucketCount = errors.New("experiment: bucket count must be positive")
// ErrEmptyGroups 表示没有给出任何分组边界。
ErrEmptyGroups = errors.New("experiment: empty groups")
// ErrInvalidCut 表示累计边界不在 0..AssignBuckets-1 内。
ErrInvalidCut = errors.New("experiment: cut out of range")
// ErrDuplicateCut 表示同一个累计边界出现了两次。
ErrDuplicateCut = errors.New("experiment: duplicate cut")
)
// Bucket 把用户稳定地分到 0..n-1 的桶。同一 userID 与 salt 永远得到同一结果,
// 因此分流结果可以跨请求、跨进程复现。salt 是实验的隔离键:不同实验使用不同
// salt,同一用户在不同实验里的桶互不相关。
func Bucket(userID, salt string, n int) (int, error) {
if userID == "" {
return 0, ErrEmptyUserID
}
if n <= 0 {
return 0, fmt.Errorf("%w: %d", ErrInvalidBucketCount, n)
}
h := fnv.New32a()
_, _ = h.Write([]byte(salt))
_, _ = h.Write([]byte{0}) // 分隔 salt 与 userID,避免拼接歧义。
_, _ = h.Write([]byte(userID))
return int(h.Sum32() % uint32(n)), nil
}
// Assign 按累计边界把用户分到某一组,返回组号 0..len(groups)。
//
// groups 是累计边界,而不是各组人数。例如 groups=[90] 表示第一组占 0..89
// 共 90%,第二组占 90..99 共 10%;groups=[10,30] 表示 10%、20%、70% 三组。
// 边界必须互不相同且位于 0..AssignBuckets-1;输入顺序不影响结果,函数内部
// 会先排序。组号由排序后的边界顺序决定,0 表示最小的那一组。
func Assign(userID, salt string, groups []int) (int, error) {
if userID == "" {
return 0, ErrEmptyUserID
}
if len(groups) == 0 {
return 0, ErrEmptyGroups
}
cuts := append([]int(nil), groups...)
sort.Ints(cuts)
for i, c := range cuts {
if c < 0 || c >= AssignBuckets {
return 0, fmt.Errorf("%w: %d", ErrInvalidCut, c)
}
if i > 0 && cuts[i-1] == c {
return 0, fmt.Errorf("%w: %d", ErrDuplicateCut, c)
}
}
b, err := Bucket(userID, salt, AssignBuckets)
if err != nil {
return 0, err
}
for i, c := range cuts {
if b < c {
return i, nil
}
}
return len(cuts), nil
}
实现里有几个关键点:
Bucket先校验用户 ID 非空、桶数量为正。空用户 ID 不能默默落到 0 号桶,否则所有匿名流量会挤在一起。- 哈希输入按
salt、一个 0 字节、userID的顺序拼接。0 字节是分隔符,避免salt="ab", userID="c"和salt="a", userID="bc"撞成同一个输入。 Assign的groups是累计边界,不是各组人数。groups=[90]表示第一组 90%、第二组 10%;groups=[10,30]表示 10%、20%、70% 三组。这样配置直接对应产品经理说的比例。- 边界先复制再排序,不修改调用方传入的切片。输入顺序不影响结果,测试会覆盖这一点。
- 边界必须互不相同且在
0..99内。重复边界会产生零宽度的组,范围越界会让最后一组永远无法命中,两者都直接报错而不是静默修正。 - 组号是排序后的位置:0 是最小边界之前的组,
len(groups)是最后一个边界之后的组。产品配置和报表都按这个编号对齐。
Assign 返回的是组号而不是概率,调用方不需要再随机一次。这一点很重要:只要分流函数返回了结果,后面的代码就不能再“补一次随机”,否则稳定哈希前功尽弃。
测试:稳定性、隔离与权重
分流测试要覆盖四类问题:同一输入是否稳定、不同 salt 是否真的隔离、非法输入是否被挡住、边界切出来的比例是否接近配置。
// file: internal/experiment/split_test.go
// tier: L1
// verify: go test ./internal/experiment/
package experiment
import (
"errors"
"testing"
)
func TestBucketStableInRange(t *testing.T) {
for _, user := range []string{"u-1", "u-2", "u-42", "用户-甲"} {
first, err := Bucket(user, "rec-v1", 10)
if err != nil {
t.Fatalf("Bucket(%q) = %v, want nil", user, err)
}
second, _ := Bucket(user, "rec-v1", 10)
if first != second {
t.Fatalf("Bucket(%q) changed from %d to %d", user, first, second)
}
if first < 0 || first >= 10 {
t.Fatalf("Bucket(%q) = %d, want 0..9", user, first)
}
}
}
func TestBucketRejectsBadInput(t *testing.T) {
if _, err := Bucket("", "rec-v1", 10); !errors.Is(err, ErrEmptyUserID) {
t.Fatalf("empty user = %v, want ErrEmptyUserID", err)
}
if _, err := Bucket("u-1", "rec-v1", 0); !errors.Is(err, ErrInvalidBucketCount) {
t.Fatalf("n=0 = %v, want ErrInvalidBucketCount", err)
}
if _, err := Bucket("u-1", "rec-v1", -5); !errors.Is(err, ErrInvalidBucketCount) {
t.Fatalf("n=-5 = %v, want ErrInvalidBucketCount", err)
}
}
func TestBucketSaltIsolation(t *testing.T) {
different := 0
for i := 0; i < 50; i++ {
user := "user-" + string(rune('a'+i%26)) + string(rune('0'+i/26))
a, _ := Bucket(user, "button-color", 100)
b, _ := Bucket(user, "pricing-page", 100)
if a != b {
different++
}
}
if different == 0 {
t.Fatal("two salts produced identical buckets for all users")
}
}
func TestAssignStable(t *testing.T) {
first, err := Assign("u-1", "checkout-v2", []int{90})
if err != nil {
t.Fatalf("Assign = %v, want nil", err)
}
second, _ := Assign("u-1", "checkout-v2", []int{90})
if first != second {
t.Fatalf("Assign changed from %d to %d", first, second)
}
if first < 0 || first > 1 {
t.Fatalf("Assign = %d, want 0 or 1", first)
}
}
func TestAssignWeights(t *testing.T) {
const users = 10000
var counts [3]int
for i := 0; i < users; i++ {
user := "weight-user-" + itoa(i)
g, err := Assign(user, "pricing-v3", []int{10, 30})
if err != nil {
t.Fatalf("Assign = %v, want nil", err)
}
counts[g]++
}
want := [3]float64{0.10, 0.20, 0.70}
for i, c := range counts {
got := float64(c) / users
if diff := got - want[i]; diff > 0.03 || diff < -0.03 {
t.Fatalf("group %d share = %.3f, want %.2f (±0.03)", i, got, want[i])
}
}
}
func TestAssignOrderIndependent(t *testing.T) {
for i := 0; i < 20; i++ {
user := "order-user-" + itoa(i)
a, err := Assign(user, "exp", []int{30, 10})
if err != nil {
t.Fatalf("Assign = %v, want nil", err)
}
b, _ := Assign(user, "exp", []int{10, 30})
if a != b {
t.Fatalf("Assign depends on input order: %d != %d", a, b)
}
}
}
func TestAssignRejectsBadInput(t *testing.T) {
if _, err := Assign("", "exp", []int{90}); !errors.Is(err, ErrEmptyUserID) {
t.Fatalf("empty user = %v, want ErrEmptyUserID", err)
}
if _, err := Assign("u-1", "exp", nil); !errors.Is(err, ErrEmptyGroups) {
t.Fatalf("nil groups = %v, want ErrEmptyGroups", err)
}
if _, err := Assign("u-1", "exp", []int{-1}); !errors.Is(err, ErrInvalidCut) {
t.Fatalf("cut=-1 = %v, want ErrInvalidCut", err)
}
if _, err := Assign("u-1", "exp", []int{100}); !errors.Is(err, ErrInvalidCut) {
t.Fatalf("cut=100 = %v, want ErrInvalidCut", err)
}
if _, err := Assign("u-1", "exp", []int{50, 50}); !errors.Is(err, ErrDuplicateCut) {
t.Fatalf("duplicate cut = %v, want ErrDuplicateCut", err)
}
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf[i:])
}
TestBucketStableInRange 对多个用户连续调用两次,断言桶不变且落在 0..9。TestBucketRejectsBadInput 覆盖空用户、n=0 和负数桶数量。TestBucketSaltIsolation 用 50 个用户比较两个 salt,只要求“至少有一组不同”,因为单个用户碰巧同桶是正常的,全部相同才说明 salt 没有进入哈希。
TestAssignStable 验证同一用户重复调用得到同一组。TestAssignWeights 用一万个用户和 [10,30] 边界,断言三组占比分别在 10%、20%、70% 的 ±3 个百分点内。TestAssignOrderIndependent 断言 [30,10] 和 [10,30] 结果一致,防止实现偷偷依赖输入顺序。TestAssignRejectsBadInput 覆盖空用户、空边界、负边界、越界边界和重复边界。
这些测试不依赖随机种子,因此可以在 CI 里稳定重复。权重测试虽然抽样,但输入是固定的用户 ID 序列,哈希也是确定的,所以结果不会随机器变化。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/experiment/ -run 'TestBucket|TestAssign' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestBucketStableInRange
--- PASS: TestBucketStableInRange (0.00s)
=== RUN TestBucketRejectsBadInput
--- PASS: TestBucketRejectsBadInput (0.00s)
=== RUN TestBucketSaltIsolation
--- PASS: TestBucketSaltIsolation (0.00s)
=== RUN TestAssignStable
--- PASS: TestAssignStable (0.00s)
=== RUN TestAssignWeights
--- PASS: TestAssignWeights (0.00s)
=== RUN TestAssignOrderIndependent
--- PASS: TestAssignOrderIndependent (0.00s)
=== RUN TestAssignRejectsBadInput
--- PASS: TestAssignRejectsBadInput (0.00s)
PASS
ok webapp/internal/experiment 0.332s
PASS 表示 7 个分流用例全部通过。它们证明的是本章这套简化模型:确定性哈希、独立 salt、累计边界。真实系统还要把分组写进缓存 key、曝光日志和指标口径,否则函数再稳定,链路也可能把用户分到另一组。
小结
实验分流必须是确定性函数,输入是 salt 和 userID,输出是稳定的桶或组。salt 按实验隔离,层间使用不同 salt 才能正交;层内共享 salt 才能互斥。分流结果一旦算出,就要贯穿缓存、日志和指标,不能在中途再随机一次。
Bucket 和 Assign 把“分桶”和“分组”拆开,边界用累计百分比表达,非法输入直接报错。下一节讨论分流之后的问题:两组数据差多少才算真的有效果。
阅读导航:上一节:目录与学习路径 · 下一节:8.2 指标、显著性与误判 。
常见错误与边界
- 每次请求重新随机。用户会在两组之间来回跳,指标被稀释,客服也会收到“页面怎么变了”的投诉。
- 所有实验共用一个 salt。实验之间不再正交,交互效应会被算进单个实验的效果里。
- 只对 userID 取模、salt 为空。改一次实验边界就会重排所有用户,历史分组无法复现。
- 把
Assign的groups当成人数。它在本章是累计边界,[90]表示 90/10 两组,不是“90 人”。 - 边界重复或越界时静默修正。零宽度组和永远命不中的组都会让报表对不上配置,应该直接报错。
- 分流结果没有进入缓存 key。CDN 可能把实验组页面发给对照组用户,日志与真实曝光不一致。
- 曝光日志只记“分到哪组”,不记“看到哪版”。缓存、灰度、客户端降级造成的偏差无法被发现。
- 实验结束后立刻删除 salt。历史数据再也无法复现当时的分组,复盘和审计都会失去依据。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。