《互联网产品的服务端架构·交易、增长与平台化》8.1 实验分流与一致性

推荐实验上线一周,同一用户却在一天内看到新老两版页面,实验组指标被对照组行为稀释。文章解释为什么分流必须是确定性哈希、salt 为什么要按实验隔离、分层实验如何保持正交,并给出 Bucket 与 Assign 的 Go 实现、稳定性与权重测试,以及真实运行输出。

同一个用户,一天里看到两个版本

「刚好」的推荐团队要做一次首页改版实验。产品经理周一打开报表,实验组点击率比对照组高 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。历史数据再也无法复现当时的分组,复盘和审计都会失去依据。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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