实验分桶原理:哈希分桶、一致性哈希、分桶策略与流量分配
分桶,说白了就是把用户扔进不同的实验组里。你想想看,一个AB测试系统,每天几百万甚至上亿的用户进来,怎么保证同一个用户每次看到的实验版本都一样?怎么保证不同实验之间不互相干扰?这些问题的答案,都藏在分桶原理里。
我个人习惯把分桶比作「分房间」。用户来了,根据他的身份证号(用户ID)算一下,把他分配到1号房间还是2号房间。1号房间看实验组,2号房间看对照组。就这么简单。但真正落地的时候,坑可不少。
哈希分桶:最基础的分桶方式
哈希分桶,就是拿用户的某个标识(比如user_id)算一个哈希值,然后对分桶数取模。伪代码长这样:
def hash_bucket(user_id, total_buckets=100):
hash_val = hash(user_id) # 比如用MD5或MurmurHash
bucket = hash_val % total_buckets
return bucket
嗯,这里要注意。直接用 hash() 函数?不行。Python自带的 hash() 每次进程重启结果都不一样。我在项目中遇到过这个问题,上线后发现同一个用户今天在实验组,明天跑到对照组去了,数据直接废了。
那分桶数设多少合适?我一般建议设成100或者1000。100的话,每个桶就是1%的流量。想切10%的流量做实验,就选10个桶。灵活,也好理解。
| 分桶数 | 最小流量粒度 | 适用场景 |
|---|---|---|
| 100 | 1% | 大多数AB测试 |
| 1000 | 0.1% | 小流量实验、灰度发布 |
| 10000 | 0.01% | 超大规模、多实验并行 |
一致性哈希:解决扩缩容的难题
哈希分桶有个硬伤。你想想,如果分桶数从100变成101,所有用户的桶号都得重新算。这意味着什么?所有实验的流量分配全部乱套。这就是所谓的「哈希雪崩」。
一致性哈希就是来解决这个问题的。它的核心思想是:把哈希值空间看成一个环,用户和桶都映射到这个环上。用户顺时针找到的第一个桶,就是他的归属。
我画了一张图,帮你理解这个结构:
你看,用户2顺时针找到的第一个桶是F。如果这时候新增一个桶G,插在用户2和桶F之间,那只有用户2需要重新分配,其他用户纹丝不动。这就是一致性哈希的魅力。
分桶策略:怎么分才科学?
分桶策略,说白了就是「用什么规则把用户分到桶里」。常见的策略有这么几种:
- 用户ID分桶: 最常用。用user_id做哈希,同一个用户永远进同一个桶。适合长期实验。
- 设备ID分桶: 用户没登录怎么办?用设备ID。适合未登录用户的实验。
- 分层分桶: 同一个用户在不同实验层里进不同的桶。这是解决「多实验并行」的关键。
- 随机分桶: 每次请求随机分配。适合短期、不计用户一致性的实验。
我个人最推荐的是「用户ID + 分层分桶」的组合。为什么?因为实际业务中,往往同时跑十几个实验。如果所有实验都用同一个分桶逻辑,那实验之间就会互相污染。
举个例子:实验A和实验B都用user_id分桶。用户123在实验A里进了实验组,在实验B里也进了实验组。这时候你发现实验B的效果好,但到底是实验B本身好,还是因为实验A的改动影响了用户行为?说不清楚。
分层分桶怎么解决?每个实验层用不同的盐值(salt)做哈希:
def layer_bucket(user_id, layer_name, total_buckets=100):
# 盐值 = 层名 + 用户ID
hash_input = f"{layer_name}:{user_id}"
hash_val = hash_md5(hash_input)
bucket = hash_val % total_buckets
return bucket
这样一来,同一个用户在「推荐算法层」和「UI样式层」里,进的桶是完全独立的。实验之间互不干扰。
| 分桶策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 用户ID分桶 | 一致性高,实现简单 | 多实验互相干扰 | 单实验、长期实验 |
| 设备ID分桶 | 覆盖未登录用户 | 设备更换导致分桶变化 | 移动端、Web端 |
| 分层分桶 | 多实验并行无干扰 | 需要管理层配置 | 大规模实验平台 |
| 随机分桶 | 实现最简单 | 用户一致性差 | 短期效果验证 |
流量分配:切蛋糕的艺术
流量分配,就是决定「多少用户进实验组,多少用户进对照组」。常见的分配方式有:
- 等比例分配: 实验组和对照组各50%。最公平,但样本量需求大。
- 不对称分配: 比如实验组10%,对照组90%。适合高风险实验,先小流量验证。
- 动态分配: 根据实时效果调整流量比例。比如实验效果好,逐步从10%扩大到50%。
我曾经踩过一个坑。有个实验,我设了实验组10%、对照组90%。结果跑了三天,实验组的效果显著优于对照组。我兴冲冲地准备全量上线,结果发现实验组的样本量太小,统计功效不够,结论不可靠。
流量分配还有一个关键点:流量正交。什么意思?就是不同实验之间的流量不能重叠。比如实验A用了1-10号桶,实验B就不能再用这些桶。否则同一个用户同时参与两个实验,结果就乱了。
实现流量正交,我常用的方法是「桶位预留」:
# 桶位分配表
bucket_allocation = {
"experiment_A": list(range(1, 11)), # 1-10号桶
"experiment_B": list(range(11, 21)), # 11-20号桶
"experiment_C": list(range(21, 31)), # 21-30号桶
# 预留70个桶给其他实验
}
def get_experiment_group(user_id, experiment_name):
bucket = hash_bucket(user_id, 100)
if bucket in bucket_allocation[experiment_name]:
return "treatment"
else:
return "control"
这样设计,每个实验独占一部分桶位,互不干扰。而且预留的桶位可以随时分配给新实验,灵活得很。
好了,分桶的原理就聊到这儿。记住一句话:分桶做不好,实验数据全是垃圾。我见过太多团队,辛辛苦苦跑了一个月的实验,最后发现分桶逻辑有bug,所有结论都得推翻重来。嗯,希望你不要踩这个坑。