大量 id 场景下经常需要通过 id 进行 AB Test,最常见的就是使用尾号 hash 进行分组,但是由于 id 生成规则以及其他因素,按照尾号分组往往会造成 id 不匀,从而导致 AB Test 效果受影响,所以下文采用 md5 加盐 Hash 的方式,得到更均匀的分组与 AB Test 效果。
id 即为用户 uid 或商品 pid,加盐中盐代表盐值,可以指定为任一质数,id 加盐可以理解:
saltNum: Int + id: String => String
通过 MD5 编码将上述加盐的 id 进行编码处理,获取加密后的字节形式,md5 包采用 java 自带的:
- import java.security.MessageDigest
-
- val md5 = MessageDigest.getInstance("MD5")
- val encoded = md5.digest(saltNum + id)
将加密后的每个字节转16进制,转换16进制采用 org.apache.commons 自带工具包:
- import org.apache.commons.codec.binary.Hex
-
- val encodeStr = Hex.encodeHexString(encoded)
也可以直接使用 String 自带的 format 方法实现转换16进制:
val encodeStr = encoded.map("%02x".format(_)).mkString("")
将16进制数字截取 TopN,然后将16进制转换为10进制
val num = java.lang.Long.parseLong(encodeStr.slice(0, N), 16).toString
直接取 TopN 并通过 parseLong 得到新的 10 进制数字。
通过新的十进制数字取尾号 hash,获取新的分组,上面得到 10 进制数字 num,可以再使用尾号划分,例如对倒数两位取 mod,即可得到 100 个分组,对倒数三位取 mod,即可得到 1000 个分组,依次类推。
A.MD5 Hash
- def md5Encode(id: String, saltNum: Int, N: Int): String = {
- val input = saltNum + id // 加盐
- val md5 = MessageDigest.getInstance("md5")
- val encoded = md5.digest(input.getBytes) // md5 编码
- val encodeStr = Hex.encodeHexString(encoded) // 转16进制
- val num = java.lang.Long.parseLong(encodeStr.slice(0, N), 16).toString // 转10进制
- val group = num.slice(num.length - 2, num.length).toInt % 100 // hash
- group.toString
- }
B.Common Hash
- def commonHash(num: String): String = {
- val group = num.slice(num.length - 2, num.length).toInt % 100 // hash
- group.toString
- }
对 uid、pid 重新分组主要是为了提高 AB Test 的置信度,而且涉及到工程实现即每个 id 都需要获取对应的 group,所以下面从:
-> id 分组均匀程度
-> id 分组AB效果程度
-> 分组速度
三个方面进行评估。
由于 uid、pid 为系统生成,一定程度上不能做到完美的 hash 均分,所以需要重 hash 解决,下面分别使用 MD5 Hash 与 Common Hash 做 id 数的分析,指标: [分组 id 数 - 分组 id 平均数]
绿线为 MD5,红线为 CommonHash,可以看到 MD5 得到的 100 个分组 id 数相对 CommonHash 分组均匀很多,前者 Std 为 1100+,后者 Std 达到 4000+。
分组均分后,还要验证下效果是否一致,如果 id 数相同但是同组的 id 表现差异很大,对 AB Test 也会造成很大影响,这里采用 Pid 的销售额作图,指标: [pid 销售额 - pid 销售额均值]
绿线为 MD5,红线为 CommonHash,可以看到 pid 在 MD5 hash 后整体表现均匀,而原始的 CommonHash 则存在个别组出现极端坏数据的情况,影响 AB Test。
构造 10000 个 id 模拟 Pid,打印执行时间比较:
- val random = scala.util.Random
- val testId = (0 to 10000).map(x => random.nextLong()).toArray
- val st = System.currentTimeMillis()
- testId.foreach(num => {
- // md5Encode(num.toString, saltNum, N)
- commonHash(num.toString)
- })
- println(s"cost: ${System.currentTimeMillis() - st}")
MD5 耗时 220ms / 10000,CommonHash 耗时 45ms / 10000,前者大约是后者的 5 倍,但是均匀到 id 上 0.022 ms / id 的耗时也是可以接受的,所以耗时虽然比 CommonHash 慢5倍,但是工业场景下也基本不受影响。
经过上面的分析,该使用什么分组 AB Test 不用我说了吧。