《互联网产品的服务端架构·内容、社交与实时互动》4.3 排序实验与效果回收

从实验组点击率涨了 8%、次留却跌了 2% 的事故写起,说明分桶必须稳定、指标必须先定、实验期间不能混入其他改动;给出基于 FNV 的 0 到 99 分桶、rollout 判定与相对提升计算的可测试 Go 实现,并讨论点击与留存的取舍,附真实运行输出。

实验组点击率涨了 8%,次留跌了 2%

「回声」在月初上线了一版新排序,实验组使用“热度加亲和度”的组合,对照组保持原逻辑。两周后,数据同学给出结论:实验组人均点击率相对提升 8%,建议全量。评审会上没有人反对,因为 8% 看起来足够大,样本量也够。

全量一周后,次留数据出来了:实验组次留相对下降 2%。团队回头复盘,发现实验期间还混入了另外两个改动:推送时间调整和评论区折叠。三个改动同时生效,没人能说清 2% 是哪一个带来的。更尴尬的是,实验组的点击率提升有一部分来自“同一内容被点击多次”,而次留下降恰恰可能是内容重复曝光导致的疲劳。

排序实验最难的部分不是分桶,而是先定义“成功”。点击率是即时指标,留存是滞后指标,两者经常反向。只看点击就全量,等于用一个短期代理指标替代了产品目标。

分桶、指标与实验隔离

一个可解释的排序实验,至少要在开始前固定三件事:谁能进组、成功怎么衡量、实验期间还能不能改别的。

决策点必须固定什么常见错误直接后果
分桶哈希输入、盐值、桶数每次请求随机、盐值随版本变化同一用户来回跳组,指标被稀释
流量比例rollout 的语义是 0 到 100把 0.1 当成 10%,或把 50 当成 50%实际进组比例与预期差一个数量级
成功指标主指标、护栏指标、观察窗口上线后才补指标,或只看点击结论无法归因,滞后指标被忽略
实验隔离同时只改一个排序变量实验期间混入推送、UI、召回改动效果无法拆分,全量后回退困难
效果回收分群、分层、显著性只看总体均值,不看新老用户总体涨、核心人群跌,被平均值掩盖
停止规则何时看、看几次、何时停每天偷看,涨了就停假阳性概率大幅上升

分桶要稳定,本质是同一个用户在同一实验里永远落在同一组。常见做法是把 userID + salt 做哈希再对 100 取模。盐值标识实验版本,不能随发布变化;一旦盐值变了,所有用户都会重新洗牌,实验组和对照组的差异就不再是同一个总体。

rollout 的语义要写清楚。rollout=10 表示 10% 的用户进入实验组,不是“10 倍”也不是“0.1”。如果接口参数是整数百分比,就不要在调用方再乘一次 100;如果参数是小数比例,命名上要能看出来。单位错误通常不会报错,只会让实验悄悄变成全量或空跑。

指标要先定,而且要同时定主指标和护栏指标。排序实验常见的主指标是点击率、消费时长或互动率,护栏指标是次留、负反馈率、取关率。只定主指标,团队会不自觉地把主指标优化到损害护栏指标;只定护栏指标,又会导致实验过于保守。

实验隔离是最容易被忽视的一条。排序实验期间,推送时间、评论区样式、召回策略都不应该同时改。如果业务上无法避免,至少要用正交实验设计,让每个改动有独立的实验标签,否则回收阶段只能看到一锅粥。

效果回收要看分布,不只看总体。新用户和老用户对热度的敏感度不同,重度和轻度用户对已读内容的容忍度也不同。总体点击率涨 8%,可能是重度用户涨了 20%、新用户跌了 5% 的平均结果;全量后新用户占比上升,效果就会反转。

失败场景:点击涨 8%,次留跌 2%,团队只看了点击

回到「回声」的例子。实验组点击率从 5.0% 升到 5.4%,相对提升 8%;次留从 42% 降到 41.2%,相对下降约 2%。如果只看点击,结论是全量;如果同时看护栏指标,结论应该是“暂停并拆分原因”。

进一步的拆分显示,点击提升主要来自同一批内容的重复点击:新排序把用户已经互动过的作者内容反复排到前面,用户点开同一作者的多条更新,点击次数上升,但消费内容的广度下降。次留下降的用户集中在“关注作者少于 10 个”的轻度用户,他们的首页被少数作者占满,第二天回来的意愿降低。

这个案例的教训不是“点击率没用”,而是“点击率不能单独用”。点击是手段,留存是目标;手段涨、目标跌,说明排序在局部最优里牺牲了长期价值。正确的做法是把次留设为护栏指标,实验组次留下降超过阈值就停止扩量,而不是等全量后才发现。

另一个问题是实验期间混入了推送改动。推送时间调整会直接影响次留,而且影响方向与新排序可能相反。两个改动叠加后,即使新排序本身中性,次留也可能下降;团队却把账算在排序头上,差点回退了一个正确的改动。

用 Bucket、Assign 与 Lift 固定口径

下面的实现来自 webapp-lab 的 internal/rank 包。Bucket 用 FNV-1a 把 salt + userID 哈希到 0 到 99,Assign 按 rollout 判断是否进组,Lift 计算实验组相对对照组的点击率变化。

三个函数都很小,但它们把实验口径固定下来:同一用户同一盐值永远同桶;rollout 小于等于 0 时不进组,大于等于 100 时全量;对照组零点击时相对提升无定义,直接报错而不是返回 0 或无穷大。

// file: internal/rank/experiment.go
// tier: L1
// verify: go test ./internal/rank/

package rank

import (
	"fmt"
	"hash/fnv"
)

// Bucket 把用户稳定地分到 0..99 的桶。同一 userID 与 salt 永远得到同一结果。
func Bucket(userID, salt string) int {
	h := fnv.New32a()
	_, _ = h.Write([]byte(salt))
	_, _ = h.Write([]byte{0})
	_, _ = h.Write([]byte(userID))
	return int(h.Sum32() % 100)
}

// Assign 判断用户是否进入实验组。rollout 是实验组比例(0..100)。
func Assign(userID, salt string, rollout int) bool {
	switch {
	case rollout <= 0:
		return false
	case rollout >= 100:
		return true
	default:
		return Bucket(userID, salt) < rollout
	}
}

// Stats 是实验组或对照组的一段汇总指标。
type Stats struct {
	Users  int
	Clicks int
}

// Lift 返回实验组点击率相对对照组的变化比例。
// 例如对照组 5%、实验组 5.4%,Lift 为 0.08。
func Lift(control, treatment Stats) (float64, error) {
	if control.Users <= 0 {
		return 0, fmt.Errorf("control users must be > 0, got %d", control.Users)
	}
	if treatment.Users <= 0 {
		return 0, fmt.Errorf("treatment users must be > 0, got %d", treatment.Users)
	}
	if control.Clicks < 0 || treatment.Clicks < 0 {
		return 0, fmt.Errorf("clicks must be >= 0, got control=%d treatment=%d",
			control.Clicks, treatment.Clicks)
	}
	if control.Clicks == 0 {
		return 0, fmt.Errorf("control has no clicks, relative lift is undefined")
	}

	controlRate := float64(control.Clicks) / float64(control.Users)
	treatmentRate := float64(treatment.Clicks) / float64(treatment.Users)
	return (treatmentRate - controlRate) / controlRate, nil
}

Bucket 先把盐值写入哈希,再写一个分隔字节,最后写用户 ID。分隔字节是为了避免 salt="ab", user="c" 和 salt="a", user="bc" 拼出同一个输入。这个细节不影响大多数用户,但会让边界上的分桶结果不可复现。

Assign 对 rollout 做了钳制,而不是报错。原因是调用方通常从配置读取比例,配置错误时更希望实验安全地不扩量,而不是让整个首页请求失败。代价是错误会被隐藏,所以配置校验应该在启动阶段单独做一次。

Lift 返回的是相对变化,不是绝对差值。对照组 5%、实验组 5.4%,相对提升是 8%,绝对提升是 0.4 个百分点。两个数字都真实,但混用会让结论放大或缩小十倍。接口只返回相对值,调用方如果需要绝对值,应该自己从两组 Stats 里算。

测试:稳定分桶、rollout 边界与零点击

实验代码的 bug 往往不会让请求失败,而是让分组悄悄错误。下面的测试固定三个性质:分桶稳定且有界、rollout 边界正确、零点击时报错。

// file: internal/rank/experiment_test.go
// tier: L1
// verify: go test ./internal/rank/

package rank

import "testing"

func TestBucketIsStableAndBounded(t *testing.T) {
	for i := 0; i < 200; i++ {
		user := "user-" + string(rune('a'+i%26)) + "-" + string(rune('0'+i%10))
		first := Bucket(user, "feed-rank-v1")
		second := Bucket(user, "feed-rank-v1")
		if first != second {
			t.Fatalf("Bucket(%q) changed from %d to %d", user, first, second)
		}
		if first < 0 || first > 99 {
			t.Fatalf("Bucket(%q) = %d, want 0..99", user, first)
		}
	}
}

func TestBucketDependsOnSalt(t *testing.T) {
	found := false
	for i := 0; i < 20; i++ {
		user := "user-" + string(rune('a'+i))
		if Bucket(user, "salt-a") != Bucket(user, "salt-b") {
			found = true
			break
		}
	}
	if !found {
		t.Fatal("changing the salt did not move any of the sampled users")
	}
}

func TestAssignMatchesBucket(t *testing.T) {
	user := "user-42"
	salt := "feed-rank-v1"
	bucket := Bucket(user, salt)

	for rollout := 0; rollout <= 100; rollout++ {
		want := bucket < rollout
		if got := Assign(user, salt, rollout); got != want {
			t.Fatalf("Assign(rollout=%d) = %v, want %v (bucket=%d)",
				rollout, got, want, bucket)
		}
	}
	if Assign(user, salt, 0) {
		t.Fatal("rollout 0 must not include anyone")
	}
	if !Assign(user, salt, 100) {
		t.Fatal("rollout 100 must include everyone")
	}
}

func TestLiftComputesRelativeChange(t *testing.T) {
	control := Stats{Users: 1000, Clicks: 50}
	treatment := Stats{Users: 1000, Clicks: 54}

	got, err := Lift(control, treatment)
	if err != nil {
		t.Fatalf("Lift returned error: %v", err)
	}
	if !almostEqual(got, 0.08) {
		t.Fatalf("Lift = %f, want 0.08", got)
	}
}

func TestLiftNegativeWhenTreatmentIsWorse(t *testing.T) {
	got, err := Lift(
		Stats{Users: 1000, Clicks: 50},
		Stats{Users: 1000, Clicks: 45},
	)
	if err != nil {
		t.Fatalf("Lift returned error: %v", err)
	}
	if !almostEqual(got, -0.1) {
		t.Fatalf("Lift = %f, want -0.1", got)
	}
}

func TestLiftRejectsNoControlClicks(t *testing.T) {
	_, err := Lift(Stats{Users: 1000}, Stats{Users: 1000, Clicks: 10})
	if err == nil {
		t.Fatal("expected an error when the control group has no clicks")
	}
}

func TestLiftRejectsInvalidStats(t *testing.T) {
	cases := []struct {
		control   Stats
		treatment Stats
	}{
		{Stats{Users: 0, Clicks: 1}, Stats{Users: 100, Clicks: 1}},
		{Stats{Users: -1, Clicks: 1}, Stats{Users: 100, Clicks: 1}},
		{Stats{Users: 100, Clicks: -1}, Stats{Users: 100, Clicks: 1}},
		{Stats{Users: 100, Clicks: 1}, Stats{Users: 100, Clicks: -1}},
		{Stats{Users: 100, Clicks: 1}, Stats{Users: -1, Clicks: 1}},
		{Stats{Users: 100, Clicks: 1}, Stats{Users: 0, Clicks: 0}},
	}
	for _, c := range cases {
		if _, err := Lift(c.control, c.treatment); err == nil {
			t.Fatalf("expected an error for control=%+v treatment=%+v", c.control, c.treatment)
		}
	}
}

TestBucketIsStableAndBounded 对同一批用户重复计算,要求结果不变且落在 0 到 99。这个测试不能证明哈希分布均匀,但能防止“用了随机数”或“取模写错”这类致命错误。

TestAssignMatchesBucket 遍历 0 到 100 的所有 rollout,要求 Assign 的结果严格等于 bucket < rollout。它同时覆盖了边界:rollout 为 0 时没人进组,为 100 时所有人进组。这个性质比单独测两个端点更有价值,因为它固定了分桶与进组之间的一致性。

TestLiftComputesRelativeChange 用 1000 用户、50 次点击对 1000 用户、54 次点击,得到 0.08。TestLiftRejectsNoControlClicks 则要求对照组零点击时报错。相对提升在分母为 0 时没有定义,返回 0 会被误读成“没有变化”,返回无穷大又会污染后续计算。

这些测试不验证实验是否显著,也不验证样本量是否足够。那些是统计层面的判断,单元测试只负责把分桶和指标口径固定下来。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/rank/ -count=1 -run 'TestBucket|TestAssign|TestLift' -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestBucketIsStableAndBounded
--- PASS: TestBucketIsStableAndBounded (0.00s)
=== RUN   TestBucketDependsOnSalt
--- PASS: TestBucketDependsOnSalt (0.00s)
=== RUN   TestAssignMatchesBucket
--- PASS: TestAssignMatchesBucket (0.00s)
=== RUN   TestLiftComputesRelativeChange
--- PASS: TestLiftComputesRelativeChange (0.00s)
=== RUN   TestLiftNegativeWhenTreatmentIsWorse
--- PASS: TestLiftNegativeWhenTreatmentIsWorse (0.00s)
=== RUN   TestLiftRejectsNoControlClicks
--- PASS: TestLiftRejectsNoControlClicks (0.00s)
=== RUN   TestLiftRejectsInvalidStats
--- PASS: TestLiftRejectsInvalidStats (0.00s)
PASS
ok  	webapp/internal/rank	0.242s

-run 的正则覆盖分桶、进组和提升计算三组测试。输出里的 ok webapp/internal/rank 表示包级通过;具体的 0.00s 只代表这次本机运行,和线上实验的观察窗口没有关系。

小结

排序实验的成败在开始前就决定了一半:分桶是否稳定、rollout 语义是否清楚、主指标和护栏指标是否同时定义、实验期间是否混入其他改动。任何一项没固定,回收阶段都无法把效果归因到排序本身。

Bucket、Assign 和 Lift 把这三件事写成可测试的函数。它们不解决统计显著性和样本量,但能保证“同一个用户不会来回跳组”“10% 就是 10%”“零点击不会被算成没有变化”。这些是实验可信的最低条件。

阅读导航:上一节:4.2 去重、折叠与已读 · 下一节:5.1 关注、好友与双向关系 。

常见错误与边界

  • 每次请求随机分组。用户会在实验组和对照组之间来回跳,指标被严重稀释。
  • 盐值随发布变化。所有用户重新洗牌,实验前后的总体不再可比。
  • 把 rollout 的小数当百分比。0.1 表示 0.1%,不是 10%,而且不会报错。
  • 上线后才补指标。已经发生的曝光无法回填护栏指标,结论只能看即时点击。
  • 实验期间同时改推送、UI 或召回。效果无法拆分,回退时容易误伤正确改动。
  • 只看总体均值。新老用户、重度轻度用户的方向可能相反,平均值会掩盖问题。
  • 对照组零点击时返回 0。相对提升在分母为 0 时无定义,应该显式报错。
  • 每天偷看并提前停止。多次检验会显著抬高假阳性概率,停止规则要事先约定。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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