Mendel - A/B Testing Platform (Part 1)
离上一篇文章已经过了好久了,能够记起还有这么一个坑没填,并且能够记得要些什么,纯属不易…… 这篇主要讲一下系统实现的时候遇到的问题以及我是如何解决的,当中会有疏漏的地方,也欢迎看到这篇文章并且感兴趣的人一起讨论。
流量分布
这个系统最最主要的目的和功能就是分配流量,所以如何分配流量,就成为了首先要面临的问题。
我们的配置分为两种,基础配置和实验配置,那么随之的,流量也分为两种:基础流量和实验流量。对于一个国家的请求而言,如果把流量按照一百份来划分,用了10%的流量做实验,那么就需要在这些请求中挑出10%出来,让其走实验的配置,剩余的90%则按照基础配置进行选择(在某个layer中运用某种算法)。
这其中有一个要求,即流量不能随机划分,不能让用户感知到变化,上一秒是根据base config进行的选择,出现了ABCD,下一秒也要按照base config,不能表现不一致。
这一点我们通过fnv
哈希算法来实现,将用户的country
,session
和uid
作为输入,确保三要素相同的情况下,得到的hash分布是随机的。
当然这里也有一些细节,比如既要支持按照session
划分,又要按照uid
划分,在对一致性要求没那么高的时候,还可以加上hour
作为第四因素,变相放大实验流量,得到更多结果。
所以解决方案为:
fnv(country + session + uid [+ hour]) % 100
的出一个数字,通过这个数字判定是base还是experiment
实验选择
接着上面,在得到一个[0,99)的数字以后,怎么就能确定是base还是exp呢?
如果base的config占80%, exp1占10%,exp2占5%,exp3占5%,如何确定一个请求(hash = 55)在哪里呢?
最简单的方法,用一个map[uint8]string
来存储没一个数字所对应的实验ID或者BaseID,每次请求过来都用这个map查一下。
但我有点嫌慢,就选择了另外一种方式:bitmap
我们有100份流量,但是数字最大到int64,所以就需要两个int64来存储我们的实验信息,如果一个数字是45,那么就选择第一个bitmap,如果是76,就选择第二个。
具体操作是将bitmap右移[>>]
hash位,在与1与或[&&]
,如果是真,那么就在这个实验里,如果是假,就换下一个实验的bitmap进行同样操作。
这样能够比map的方式快50%
流量分布不均匀
这其实是自己挖的一个坑,因为所有的流量都会分布到[0,99)上,所以我在存储的时候用了uint8
, 认为这个数字已经足够用了,但实际上,虽然它够用,但模100之后并不平均,因为uint8
的范围是[0,128), 将这一批平均分布的数字模100之后的分布则必然不平均,[0,28)在[0,99)内占了两份。
// Hash returns a hash value of [0,100)
func Hash(x string) uint8 {
hash := fnv.New32a()
hash.Write([]byte(x))
return uint8(hash.Sum32()) % 100
}
所以简单粗暴的方式是更改一下取模顺序,我们认为hash.Sum32()
是平均的,那么其mod 100也是平均的,则最后类型转换成uint8
则也是平均的了。
// Hash returns a hash value of [0,100)
func Hash(x string) uint8 {
hash := fnv.New32a()
hash.Write([]byte(x))
return uint8(hash.Sum32() % 100)
}
但是,但是,但是,上线一版hotfix之后,结果还是不平均,而且还出现了两极分化。即如果有AB两种选项,每个占比50%,则在一批随机生成的uuid所产生的hash数字中,最终结果应该有四种:AA,AB,BA,BB,且均占25%,但是结果却出乎意料,要么没有AA,BA,要么没有AB,BB,非常神奇。
但是我们也没有从数学上找到原因,只是通过下面一种mod一个奇数的方法解决了这个问题,这个奇数是从fnv包里抄的。
// Hash returns a hash value of [0,100)
func Hash(x string) uint8 {
hash := fnv.New32a()
hash.Write([]byte(x))
return uint8(hash.Sum32() % 16777619 % 100)
}
如果有学数学的同学恰好知道,还请不吝赐教,感谢。
命名问题
这个其实是一个想当然的问题,我理所当然的认为所有名字用uuid就好,不重复。但是却忽视了一个很重要的功能:易读性。
如果config ID,service ID,experiment ID,module ID和layer ID都是毫无章法的uuid,那么在配置的时候谁也不认识谁,看到一个ID连他的基本属性都不知道,只能全文查找,在配置的时候也极易出错。
所以就在每一种类型前面加了一个prefix,如果是config,那就是 config_<uuid>
, 如果是layer,那就是layer_<uuid>
。
但还有一个问题,我们还是不知道哪个layer是哪个layer,在写配置文件的时候也很容易犯错误,且不好追查。所以就又加了alias
,每个layer除了自己的uuid,还有一个alias
用于人工区分和辨认,具体可以是module.layer
或者service.module.layer
,总之比一个uuid强多了。
数据统计
我采用了InfluxDB作为统计数据的后端存储,每来一条数据,我就处理一条,写到InfluxDB里去。
但有一天我发现一个问题,数据会丢。因为我处理的时候,是将发送过来的数据的时间戳作为写入的时间,这样的目的是即使数据重放也不会重复写入,但带来的问题就是,如果两条数据是同一秒处理的,那么第一条数据就会被覆盖,也就是,丢了。
解决的方法是batch写入,攒两秒的数据,或者两百条,满了就发车,写db,这样既解决了覆盖写入的问题,又减轻了对influxdb的负担,一箭双雕,一举两得。
后来又发现一个问题,就是field太多了,而且有很多重复的。因为所有的结果都由这一个服务处理,写field的时候将key和value作为field的名称,这就导致了有n多的field,且如果两个project中有相同的key,就会造成field的混乱,找不到自己的数据了。
解决方法是,每个field前insert一个project ID。其实最好的方法是每一个project都建立一个单独的database,或者每一个experiment建立一个单独的database,这样数据也是分离的,但是我懒。
Python 脚本发送 JSON 数据
Python 用 requests 库发送JSON的时候,如果不指定 headers={'content-type': 'application/json'}
那么发送的数据就不是JSON,这东西可把我坑坏了。有时候Postman帮你解决了太多东西,导致你认为这东西都是理所当然的,但其实并不然,等你自己实现的时候才发现坑这么多。
认证问题
由于时间紧迫,没有实现认证就匆匆上线系统了,导致所有的API接口都是可以被任意瞎搞的,想创建就创建,想删除就删除。我对于这样的事情是无法容忍的,尤其是知道了某个印度同事在瞎搞我的系统之后,test环境也就罢了,搞线上环境,忍无可忍。
于是乎我就悄悄摸摸上线了一个简陋的认证:程序每半个小时生成一个token,写在日志里,任何critical的操作都需要在请求的header里带上这个token。考虑到除了我不会有人去查看程序日志和读代码,我觉得目前这样做还是OK的,并将这个trick告诉了信得过的同事。
瞎搞,不存在的。