redis对集合提供了相关的集合操作,比如取差集,并集,等,但是单纯的来看这些操作还是比较枯燥的,所以我们来借助具体的业务场景来学习下。接下来,我们就开始吧!
主要分为如下四种:
聚合统计:即统计多个元素的聚合结果,比如交集,并集,差集等。
二值状态统计:值只有是和否两种情况的统计,比如打卡和未打卡,签到和未签到,同意和不同意等。
基数统计:去重求和,比如UV等。
接下里我们通过不同的统计模式来学习各种不同的集合操作。
假定这样的场景,需要统计网站每日的新增用户数以及第二天的用户留存数(某日新增用户在第二天也登陆了,则为第二天的留存)
,我们定义使用Set来存储如下的信息来完成需求:
key:"user:id",存储所有登录过的用户信息
key:"user:id:YYYYMMdd",存储某天登陆过的用户信息
key:"user:new:YYYYMMdd",存储某天的新增用户
key:"user:retain:YYYYMMdd":存储某天的留存用户,即前一天新增,当天登录的用户
网站上线后第一天比如说是20221030
,当天登录用户ID为1
,2
,3
,如下操作模拟当日登录用户如下:
127.0.0.1:6379> sadd user:id:20221030 1
(integer) 1
127.0.0.1:6379> sadd user:id:20221030 2
(integer) 1
127.0.0.1:6379> sadd user:id:20221030 3
(integer) 1
127.0.0.1:6379> smembers user:id:20221030
1) "1"
2) "2"
3) "3"
然后某一时刻,比如第二天凌晨2点计算昨天的新增用户,在user:id:20221030
集合中的用户就是20221030的所有登录用户,计算如下:
127.0.0.1:6379> sdiffstore user:new:20221030 user:id:20221030 user:id
(integer) 3
127.0.0.1:6379> smembers user:new:20221030
1) "1"
2) "2"
3) "3"
就保存了20221030的新增用户到user:new:20221030
,接下来我们还需要将登录用户合并到所有用户集合中,即进行并集操作,如下:
127.0.0.1:6379> sunionstore user:id user:id user:id:20221030
(integer) 3
127.0.0.1:6379> smembers user:id
1) "1"
2) "2"
3) "3"
此时user:id
集合中就保存了所有的登录用户信息,然后我们来计算20221030
的用户留存,在20221029
日新增并且在20221030
有登录的用户即为30日的留存用户,即取交集(当然29肯定是没有数据的,但是不影响我们的计算结果,并且这种方式我们的程序也不需要对这种边界情况做特殊处理,而是对其进行兼容),如下:
127.0.0.1:6379> sinterstore user:retain:20221030 user:id:20221030 user:new:20221029
(integer) 0
127.0.0.1:6379> smembers user:retain:20221030
(empty list or set)
可以看到20221030并没有留存用户。接着时间来到20221031
,假设当天登录的用户信息为1
,3
,5
,6
,如下操作模拟当日登录用户:
127.0.0.1:6379> sadd user:id:20221031 1
(integer) 1
127.0.0.1:6379> sadd user:id:20221031 3
(integer) 1
127.0.0.1:6379> sadd user:id:20221031 5
(integer) 1
127.0.0.1:6379> sadd user:id:20221031 6
(integer) 1
127.0.0.1:6379> smembers user:id:20221031
1) "1"
2) "3"
3) "5"
4) "6"
然后某一时刻,比如第二天凌晨2点计算昨天的新增用户,在当日登录ID集合user:id:20221031
中有而在总用户ID集合user:id
中没有的即为当日的新增用户,即取差集,计算如下:
127.0.0.1:6379> sdiffstore user:new:20221031 user:id:20221031 user:id
(integer) 2
127.0.0.1:6379> smembers user:new:20221031
1) "5"
2) "6"
就保存了20221031的新增用户到user:new:20221031
,接下来我们还需要将登录用户合并到所有用户集合中,即进行并集操作,如下:
127.0.0.1:6379> sunionstore user:id user:id user:id:20221031
(integer) 5
127.0.0.1:6379> smembers user:id
1) "1"
2) "2"
3) "3"
4) "5"
5) "6"
此时user:id
集合中就保存了所有的登录用户信息,然后我们来计算20221031
的用户留存,在20221030
日新增登录用户ID并且在20221031
也登录的用户即为31日的留存用户,即取交集,如下:
127.0.0.1:6379> sinterstore user:retain:20221031 user:id:20221031 user:new:20221030
(integer) 2
127.0.0.1:6379> smembers user:retain:20221031
1) "1"
2) "3"
可以看到留存用户是1
,3
,后续就是重复这个过程就行了。
注意:计算交集,并集,差集等的过程时间复杂度很高,占用大量的CPU,可能会导致服务器实例的阻塞,所以在线上环境中建议在从库执行该类操作,或者是将数据查询出来在应用程序中执行,个人更加倾向于使用后者,毕竟让数据库做纯粹的数据存取工作就够了。
二值状态指的是值只有1和0两种,比如打卡场景,1代表打卡了0代表没有打卡,在redis中提供了BitMap ,接下来我们通过不同的场景来看下。
设置key格式为userid:[具体用户ID]:YYYYMM
,如下模拟签到:
127.0.0.1:6379> setbit userid:1001:202210 2 1
(integer) 0
127.0.0.1:6379> setbit userid:1001:202210 3 1
(integer) 0
127.0.0.1:6379> setbit userid:1001:202210 12 1
(integer) 0
如下统计签到次数:
127.0.0.1:6379> bitcount userid:1001:202210 0 -1
(integer) 3
假设网站要搞一个活动,1亿用户连续签到10天,其中有一个需求项是活动结束后要统计完成签到10天
的人数,这里假定用户ID是从0开始严格递增的,此时我们可以按照天来定义一个长度为一亿bit的BitMap,如果是对应的比特位是1,则代表对应的用户签到了,否则没有签到,最终我们定义十个这样的bitmap代表10天所有用户用户的签到信息,这里一亿个比特位大小大概是100000000/8/1024/1024=12.5M*10=125M
,从内存的使用量上来说其实是可以接受的,那么如何统计完成连续签到的人数呢,我们需要用到bitop来对这10个bitmap执行AND运算,只有连续每天都签到用户对应的位置才都会为1,AND的结果也才会为1,之后我们对bitop获取的bitmap执行bitcount,获取其中为1的个数就是我们想要的结果了。这里我们模拟3个用户,连续签到3天,假设用户ID分别是0
,1
,2
,演示如下:
127.0.0.1:6379> setbit sign:day:01 0 1
(integer) 0
127.0.0.1:6379> setbit sign:day:01 1 1
(integer) 0
127.0.0.1:6379> setbit sign:day:01 2 1
(integer) 0
所以该天的bitmap结果是1110 0000
。
127.0.0.1:6379> setbit sign:day:02 1 1
(integer) 0
所以该天的bitmap结果是0100 0000
。
127.0.0.1:6379> setbit sign:day:03 0 1
(integer) 0
127.0.0.1:6379> setbit sign:day:03 1 1
(integer) 0
127.0.0.1:6379> setbit sign:day:03 2 1
(integer) 0
所以该天的bitmap结果是1110 0000
。
127.0.0.1:6379> bitop AND sign:all sign:day:01 sign:day:02 sign:day:03
(integer) 1
127.0.0.1:6379> bitcount sign:all 0 -1
(integer) 1
sign:all结果是0100 0000
,所以完成签到的人数为1。
其他的二值状态场景,比如用户在不在线,方案赞成反对,面试通过不通过等,都可以使用Bitmap来实现,Bitmap使用非常少的内存就可以解决大数据量下的二值状态场景问题,因此是工作中的一把利器。
基数统计就是统计一个集合中不重复的元素的个数,典型的场景比如统计每天的UV数。此时对于少量数据我们可以直接采用Set,Hash的集合类型来直接存储,因为其天生的具有去重的功能,但是当数据量到达一定级别之后,这些数据结构都会占用非常多的内存控件,比如每天会有1000万的用户点击,则就要存储1000万个元素,此时就需要考虑其他的设计方案了,可以考虑如下的两种方案:
1:HyperLogLog
2:BitMap
接下来我们分别看下这两种方案如何实现。
假定每天set存储1000万个元素,假定每个元素大小8byte,则每天大小就是10000000*8byte=80000000byte=80000Kb=80M,每天80M也是一个不小的内存消耗了。而hyperloglog的消耗在k级别,bitmap消耗在1M左右,相比于直接使用集合都要小得多。
HyperLogLog是一种基数估算的算法,在LogLog算法的基础上进行了增强,如下模拟用户点击的过程:
127.0.0.1:6379> pfadd somepage:uv:20221010 111 222 # 用户111 222点击了
(integer) 1
127.0.0.1:6379> pfadd somepage:uv:20221010 111 333 444 # 用户111 333 444点击了
(integer) 1
127.0.0.1:6379> pfadd somepage:uv:20221010 333 222 555 # 用户333 222 555点击了
(integer) 1
统计UV:
127.0.0.1:6379> pfcount somepage:uv:20221010
(integer) 5
这样就获取了UV值为5,注意这里虽然获取的是一个精确的值,这是因为我们数据量较小,当数据量比较大时,就会有一定的误差了,大概1000万会存在1到2万误差的样子,如果允许这种少量误差的话就可以考虑使用HyperLogLog,否则我们也可以考虑使用BitMap。
采用BitMap的话,我们首先就可以将该问题转换为二值状态统计,即某用户点击或者是没有点击某页面
,假定用户ID是连续的整数(因为要使用其作为BitMap的偏移量,当然我们也可以额外维护这样的一个userid->整数的对应关系)
,如下模拟用户1
,2
,3
,4
,5
的点击过程:
127.0.0.1:6379> setbit www.baidu.com:uv:20221010 1 1 # 用户1点击了
(integer) 0
127.0.0.1:6379> setbit www.baidu.com:uv:20221010 2 1 # 用户2点击了
(integer) 0
127.0.0.1:6379> setbit www.baidu.com:uv:20221010 2 1 # 用户2点击了
(integer) 1
127.0.0.1:6379> setbit www.baidu.com:uv:20221010 3 1 # 用户3点击了
(integer) 0
127.0.0.1:6379> setbit www.baidu.com:uv:20221010 5 1 # 用户5点击了
(integer) 0
127.0.0.1:6379> setbit www.baidu.com:uv:20221010 1 1 # 用户1点击了
(integer) 1
127.0.0.1:6379> setbit www.baidu.com:uv:20221010 4 1 # 用户4点击了
(integer) 0
接下来统计UV,即位为1的个数:
127.0.0.1:6379> bitcount www.baidu.com:uv:20221010
(integer) 5
这样不管数据量多大,获取的肯定是准确的数字,并且一般不会有内存问题。虽然计算准确但是对内存的消耗相比于HyperLogLog还是更多的,12KB的内存HyperLogLog最大可以表示2^64次方个基数,而BitMap只能表示12*1000*8=96000
,即96000个基数。如果是页面点击的场景,就是计算最多96000个用户的UV。但是这个内存上的消耗差距,对于现代计算机几乎可以忽略,因为12M的数据,BitMap就能存储将近一亿个用户的点击信息了,而12M数据显然不算是特别大的内存消耗,所以如果是在工作遇到这种基数统计场景的话,建议选择使用BitMap方案,因为相比于hyperloglog统计更加准确,且内存消耗可控。
参考文章列表: