本文主要讲述了当前用户众测服务中白名单控制相关的原理以及优化过程。同时借此分享一些在优化过程中使用的Golang小技巧以及遇到的一些问题,不一定具有普适性,或能给大家在日常搬砖中些许的灵感。
简单来说,指的就是一种让外部用户参与非正式版本测试的行为。这在我们日常使用app中或多或少可能见过带有“参与内测”之类的弹窗字眼,这可能就是一种用户众测弹窗。那么本文涉及的白名单是个什么东西呢?其实就是我们的外部用户。既然是非正式版,那就是说并不是所有线上用户都可以参与,这里就涉及到一个白名单控制逻辑。白名单的内容,我们取用户手机的device_id在我们的用户众测服务中,通过device_id我们可以控制用户参与众测,控制的内容主要为:
1.弹窗,通过众测平台的任务,可以对白名单下发类似下方的弹窗,用户可以选择下载测试版本进行体验,也可拒绝。
2.H5页面:承接众测任务展示,奖励下发、积分获取等功能(如下所示),通常在app内“我的”tab处展示页面的入口。
3.黄点提示:当有新任务时,透出黄点提示,用户点击后黄点消失。
从上文可知描述,白名单说白了就是device_id
集合,更直接的,device_id
是一个int64的值,从而白名单不就是一个包含有若干in64数值的Set,很直观的一个想法就是存一个集合,自然而然, redis/abase
等+内存缓存是非常显而易见的解决方案。从而,在客户端请求过来时,能够下发众测相关的配置,简单明了, 一个Get
操作完事了。比如一个基于redis
的方案可能如下:
redis
的HSET
,通过key+value+filed的形式存储(SET
也可以)能够快速的判断一个device_id
是不是应该是展示sdk入口;
更进一步,还可以将白名单缓存一份到内存中;
从而,这个判断逻辑要么是一个纯内存操作,要么就是读一下redis
,想想都觉得快,so easy!But too young and too naive!来看一下这样有什么问题。
白名单的数量:当前头部app,如今日头条、抖音、火山小视频等,白名单用户数量已达到百万级别,并且呈不断增长的趋势;显然上述基于redis
方案, 不管是使用HSET
还是SET
,都对应的是同一个key,这key势必是一个大key;
热key问题,所谓热key : QPS特别高的一个key称为热key。从线上全量用户的角度来看,如抖音、头条这种亿级别的app来看,白名单数量只占极少数,上述方案来看,所有用户都需要访问这个唯一的key
,显然,是一个热key。
为了解决上述大key、热key问题,当前项目使用了一种基于分级key的解决方案。将上述的单个key拆分成多个key进行存储,原来key存储子key,以期解决大key、热key问题,实际事与愿违,白费力气,甚至不如不改(没有实测过)。
一级的key存储二级key(key_1, key_2, ..., key_n)
二级key_i使用redis set存储实际device_id
key_i限制value的数量上限(项目中使用4000)
来看一下实际的存储过程。
去每个子key中“找空位”插入,超过子key数量限制时,新增一个子key;
挨个去每个子key下查询判断是不是存在;
挨个遍历每个子key,进行删除操作;
PS: 同样地,效率极其低下。
device_id
,无法直接定位到子key,所以只能挨个遍历。如果能够直接定位到子key,就可以解决效率的问题。因此有了如下改进的方案:hash
函数对device_id
进行映射,直接定位到子key, 从而(如下),相同余数的用户在同一个子key中。同时解决了上述提到的两个问题;直接通过hash函数取模的方式定位到子key;
2.4 入口检测方案
存在的问题:
名单之间传输的数据量大;
双端服务内存占用大,造成服务内存告警;
由于数据未做去重处理,导致数据量不断增长,接口时延高;
在业务服务端和众测服务端之间,不全量同步整个大白名单;
名单仅存储在众测服务端中,提供接口(RPC)给业务服务端判断单个用户是否在白名单中;
redis
都是不命中的,也就是说是无效访问,自然而然,Bloom Filter是一个很好的选择, 故优化后的访问方案如下:sdk页面接口时延由之前的秒级(高达10s)->ms级(平均低于200ms);
平台接口时延由20s+ -> 600ms-,同时由于重构python ->go,机器实例240+减少至40;
Bloom filter过滤效果很好, 能够过滤95%以上的数据请求;
go func() {...}()
f
以及context
f
函数sync.pool
的运用, 上述三种结构使用sync.pool
管理, 减少GC时间
如果需要等待处理接口,则可使用sync
包中的WaitGroup
或者使用channel
进行等待执行结果如下:
waiter := sync.WaitGroup{}
for b := uint64(0); b < cache.maxBucketCnt; b++ {
subKey := fmt.Sprintf("%+v-%+v", cache.key, b)
waiter.Add(1)
gopool.CtxGo(context.Background(), func() {
defer waiter.Done()
//业务处理逻辑
})
}
waiter.Wait()
//======
workChan := make(chan struct{}, workCnt)
done := make(chan struct{})
gopool.CtxGo(ctx, func() {
//业务处理逻辑
workChan <- struct{}{}
})
for i := 0; i < workCnt; i++ {
_ = <-workChan
}
WaitGroup
方式,如果需要获取每个goroutine的处理结果,则需要锁的开销;
channel
的方式则可以直接传递处理结果, 符合go的设计哲学:通过通信来实现共享内存,而不是通过共享内存来实现通信;
timer := time.NewTimer(100 * time.Millisecond) //设置100ms超时时间,保证最长时延不超过100ms
workCnt := 1
workChan := make(chan struct{}, workCnt)
done := make(chan struct{})
gopool.CtxGo(ctx, func() {
//业务处理逻辑
workChan <- struct{}{}
})
gopool.CtxGo(ctx, func() {
for i := 0; i < workCnt; i++ {
_ = <-workChan
}
close(done)
})
select {
case <-timer.C:
//handler超时
case <-done:
}
// Group represents a class of work and forms a namespace in
// which units of work can be executed with duplicate suppression.
type Group struct {
mu sync.Mutex // protects m
m map[string]*call // lazily initialized
}