背景
此为我当初经历过的一个电商项目里的场景,当时经历了几波大促用户总注册量在亿级,然后为了进一步推广就有了这样一个需求: 拉取用户手机的通讯录列表,判断其中的未注册手机号,展示邀请按钮.
问题整理
现有的查询是通过手机号查询用户信息,但如果仅仅是判断用户是否存在而查出整个用户信息略显浪费,另外最重要的是通讯录里大部分手机号一般来说都是未注册的,这个时候会有明显的缓存穿透问题.
这种快速判断是否存在我的第一反应是记录个以手机号为key的缓存,但是考虑到量级我大致算了一下占用容量于是我放弃了,一亿手机号保守估计占用5.6g,我们是集群的一亿手机号还好,但是如果有三/四亿的话就很浪费内存资源了扩展性十分不理想(事实上三四亿过了段时间就达到了). 容量占用计算方式参考点此
一个dict entry占用 = 24字节 , 一个 key占用 = sds = 9字节 + 字符串内容字节数 , 一个redisObject = 16字节 + value内容
也就说一个redis kv对要占用 49字节 + key内容 + value内容
key 绝大多数为11位手机号,大约占用11字节.
故 60字节 左右为一个手机号的kv对所占用内容.
1亿手机号就是60亿字节 = 5.58g左右
4亿就要22g+了
目标
- 不需要查出整个用户信息,仅需要判断是否存在即可. 目的: 节省io开销
- 不能让大量请求直接查库,尽可能走缓存. 目的: 节省数据库io资源
- 缓存数据量占用不可太大. 目的: 节省缓存(内存)存储资源
方案调研
bitmap标记
快速排除掉简单用key来标记的方案后,bitmap是我马上能想到的下一个方案.
常见手机号11位,假如出现最大值需要千亿偏移量,即需要千亿byte(约90+G)的空间来存储,我们使用的redis. 但是手机号有个特色:并不是均匀分布而是集中分布的,比如杭州移动号段前缀一般是 136,137,138,139...
所以我在这里进行了bitmap分区,没有使用到的号段就不会生成bitmap,然后一个bitmap区间我拍了个脑袋定了个1MB大小即每8388608个号码使用一个bitmap分区.
伪代码
public static class IsPhoneExists {
//低位部分的偏移量
private static final int LOW_OFFSET = 23;
//用于跟手机号求与算出offset
private static final long LOWWER_MOD = (1 << LOW_OFFSET) - 1;
//与手机号求与算出高位部分(不过最后还是要右移,这一步可没有)
private static final long PREFIX_MOD = ~LOWWER_MOD;
//计算分区key
public static String getKey(long phone) {
return String.format("phone_bitmap_%d",(phone & PREFIX_MOD) >>> LOW_OFFSET);
}
//计算偏移量
public static int getOffset(long phone) {
return (int) (phone & LOWWER_MOD);
}
}
布隆过滤器
布隆过滤器(点此查看)是网上缓存穿透解决方案常客.
这里也简单评估下设计量级,若使用basic bloom可以参考上面布隆过滤器介绍中的公式计算搜需要的空间(m为布隆过滤器空间,n为数据量,p为误判率).
需要空间m=y*n跟p的关系
误判率想要达到1%以内,需要的m = 9.59n左右就是近似10倍于数据量的bitmap空间. 10%则是4.8n即5倍左右
由于我们场景存在手机号号段分布比较集中的特点且布隆过滤器为了保证查询结果的准确性,还是得去查一次库.,所以这里我最终选择了bitmap方案.
我一个无中生友遇到的场景则是更适用于布隆过滤器,他们的id是设备号这种分布范围十分大且没啥规律的字符串,但总量也是在亿级别.所以他们无法使用bitmap这种标记方式.
布谷鸟过滤器
看了一下,实现起来有点费劲,弃了.
- 布谷鸟hash解决hash冲突的实现成本较高.
- 数据存储相较于原始值,仅仅是优化成了指纹的大小,空间占用问题上并没有太大的质变.
- 仍有误判率存在.
最后结论
bitmap走起.