不能用math/rand做分组,因其seed每次重启重置导致分组不可复现;必须用maphash等确定性哈希,输入归一化且实验名不参与哈希,非均等分组需前缀和匹配而非取模。
为什么不能用
做分组
同一用户今天在 A 组、明天变 B 组,A/B 数据直接作废。根本原因是
每次运行都重置 seed,重启服务或并发请求间无法保证哈希一致性。搜索、推荐、下单等关键路径一旦出现分组漂移,点击率、转化率指标就失去可比性,问题还无法复现。
必须用确定性哈希:输入相同
(或
),永远输出相同整数。线上排查时,你得能靠日志里的
精确还原当时分到了哪一组——这只有稳定哈希能做到。
是临时随机,不是分流逻辑
哪怕加了
,也只是让“每次启动都不同”,更糟
别试图用
这类时间因子,它和用户无关,完全违背 AB 原则
用
还是
Go 1.21+ 官方推荐
:比
快一个数量级,比
更抗碰撞,且默认禁用反射,防哈希碰撞攻击。但要注意它不是开箱即用的“函数”,而是一个需初始化的类型。
(如
)仍是成熟选择,
直接返回值,适合快速验证。但它不内置 seed 管理,若自己封装不当,仍可能引入非确定性。
立即学习
“
go语言免费学习笔记(深入)
”;
用
时,
的 seed 必须全局复用,不能每次请求 new 一个
输入归一化:int64 类型的
要用
写入字节,别用
——负数、前导零会导致哈希不一致
字符串类输入(如
)可直接
,无需额外编码
非均等分组必须用前缀和匹配,别用
看似能分三组,但当权重是 80/15/5 时,取模会放大哈希低位分布偏差,尤其在流量小或哈希不够理想时,实际分到第三组的可能只有 3%。更危险的是,一旦后续要加第四组,所有
表达式都要改,容易漏。
真正稳定的做法是算总权重、取模后做前缀和扫描:
这个逻辑兼容任意整数权重,无精度损失,增删组只需改
切片
实验名(如
)绝不能拼进哈希输入,否则改配置 = 全量重分桶
如果要做多层正交实验(比如首页改版 + 按钮颜色),哈希输入应为
,前缀隔离
配置热加载与 key 安全命名
硬编码
或用
是线上事故高发区:expName 含空格、斜杠、控制字符时,Redis key 非法,查不到配置却无报错,实验静默失效。
key 必须强制标准化:小写 + 下划线 + 字母数字。推荐用
截断作后缀,既唯一又安全;配置结构里必须带
字段,旧代码读到新版配置时能明确失败,而不是忽略新加字段导致行为异常。
配置变更不能 reload 整个进程,要用 goroutine 定期轮询 + atomic.Value 替换
Redis 或 etcd 查配置失败时,应 fallback 到内存缓存的上一版,而非 panic 或返回默认值
实验开关字段(如
)必须参与路由判断,避免“配置已关,但流量还在打”
实际最难的不是写对哈希,而是守住「输入归一化」和「实验名不入哈希」这两条线——它们不出错时毫无存在感,一出错就是全量用户分组漂移,且日志里几乎看不出线索。
math/randmath/randuserIDuserID + queryuserIDrand.Intn(2)rand.Seed(time.Now().UnixNano())time.Now().Unix() % 2hash/maphashmurmur3hash/maphashcrypto/md5fmt.Sprintf + sum64murmur3github.com/spaolacci/murmur3Sum32([]byte(s))hash/maphashh.SetSeed(maphash.MakeSeed())userIDbinary.PutVarintstrconv.Itoaqueryh.Write([]byte(s))%hash.Sum64() % 3%weights := []int{80, 15, 5}
total := 100
h := hash.Sum64() % uint64(total)
acc := 0
for i, w := range weights {
acc += w
if int(h) < acc {
return i
}
}weights"search_ranking_v2""homepage_v2:" + userID"ab_config:user_profile"fmt.Sprintf("ab_config:%s", expName)sha256(expName)[:8]versionenabled: false