Redis各种数据类型巧用
写在前面
最近参与了一个社交系统的前期需求评审会议,里面涉及到各种社交应用场景,使用Redis无疑是最合适不过的了。通常会保存这样的信息:一个key关联一个数据集合,同时要对集合中的数据进行各种操作,诸如统计、排序等。
那么本篇笔者将结合工作实际,列举六种典型使用Redis的业务场景,如下所示:
(1)判断用户登录状态;(2)统计用户连续签到情况;(3)统计每天新增和第二天用户留存数;(4)统计网站访客量(Unique Visitor,UV);(5)最新评论列表;(6)积分排行榜。
一般来说,我们面临的用户数量和访问量都是巨大的,如百万、千万级别用户数量,或者千万甚至亿级别的访问量,因此必须选择能够高效统计大量数据的集合类型。不过在此之前,首先需要了解常用的统计模式,并使用合理的数据类型来解决实际问题。
这里我们一般会使用如下四种统计类型:二值状态统计、基数统计、排序统计和聚合统计。
二值状态统计
二值状态统计概念
二值状态统计,即集合中的元素只有0和1这两种状态,统计对应状态出现的次数。
举个例子,用户在进行打卡签到的时候,只有签到(1)或未签到(0)这两种;判断用户是否登录,也只有已登录(1)或未登录(0)这两种,它们均合适使用二值状态来进行统计。
判断用户登陆态
可以使用BitMap来判断海量用户中某个用户是否登录,它提供了GETBIT
、SETBIT
等命令,通过一个偏移值offset对bit数组的offset位置的bit位进行读写操作,请注意offset从0开始。
这样只需使用一个login_status
集合来存储用户登录状态,然后将用户ID作为offset,如果用户在线则设置为1,下线则设置为0。后续开发者就可以通过GETBIT
来判断对应用户是否在线。
SETBIT
命令用法如下:
1 | SETBIT <key> <offset> <value> |
用于设置或者清空 key 的 value 在 offset 处的 bit 值(只能是 0 或者 1)。
GETBIT
命令用法如下:
1 | GETBIT <key> <offset> |
用于获取 key 的 value 在 offset 处的 bit 位的值,注意当 key 不存在时,返回 0。
举个例子,当我们需要判断userId为1001用户的登录情况,此时步骤如下:
(1)假定用户已经登录,往login_status
集合中userId为1001的bit位处设置值为1:
1 | SETBIT login_status 1001 1 |
(2)判断该用户是否登录,返回值为1表示已登录,0表示未登录:
1 | GETBIT login_status 1001 |
(3)用户进行退出操作,将对应的offset值设置为0:
1 | SETBIT login_status 1001 0 |
统计用户每个月的签到情况
在签到统计中,每个用户每天的签到使用1个Bit位表示,那么一年的签到只需365个Bit位。而一个月最多只有31天你,那么最多只要31个Bit位。
现在有一个需求,统计userId为10010的用户在2021年8月份的签到打卡情况,此时该如何操作呢?可以将key设置为userId:sign:{userId}:{yyyyMM}
这一格式,月份每一天的值减去1就可以作为offset,因为offset从0开始。
第一步,假定userId为1001的用户在2021年8月18号签到打卡了:
1 | SETBIT userId:sign:1001:202108 17 1 |
第二步,判断userId为1001的用户在2021年8月18号是否进行了签到打卡:
1 | GETBIT userId:sign:1001:202108 17 |
第三步,统计userId为1001的用户在2021年8月的签到打卡次数,可以使用BITCOUNT
命令,该命令用于统计给定bit数组中,值为1的bit位的数量:
1 | BITCOUNT userId:sign:1001:202108 |
这样就实现了用户每个月签到打卡情况的统计,总的来说还是较为简单。
统计用户首次打卡时间
现在有个需求,获取userId为1001的用户在2021年8月份首次签到打卡的时间。
Redis提供了BITPOS
命令,用于返回第一个值为bitValue的offset的位置:
1 | BITPOS key bitValue [start] [end] |
默认情况下,BITPOS
命令会检测整个位图,开发者可通过可选的start和end参数来指定需要检测的范围。
可以通过如下命令来获取userId为1001的用户,在2021年8月份首次签到打卡的时间:
1 | BITPOS userId:sign:1001:202108 1 |
可以看到返回结果如下:
1 | > BITPOS userId:sign:1001:202108 1 |
请注意返回的是offset,实际值应该是offset+1,即第18天首次签到。
统计连续签到用户总数
现在有一份数据,记录了一个亿用户连续7天的签到打卡记录,那么如何统计出这连续7天都连续打卡的用户总数呢?
前面我们说过,将每天的日期作为 Bitmap的key,userId作为offset,如果用户签到打卡了,那么将offset位置处的bit设置为1。那么这个key所对应集合的每个bit 位的数据,则是一个用户在该日期的打卡记录。
那么就存在 7 个这样的 Bitmap,可以对这 7 个 Bitmap 的对应的 bit 位做与运算。同样userID的offset都是一样的,当一个userID在 7 个 Bitmap 对应的 offset 位置的 bit的值为 1 ,就说明该用户 7 天连续打卡。之后将结果保存到一个新Bitmap 中,然后通过 BITCOUNT
命令来统计 bit值为 1 的个数,就能得到连续打卡 7 天的用户总数。
实际上Redis 提供了BITOP
命令,用于对一个或者多个key的 Bitmap进行位与操作:
1 | BITOP operation destkey key [key ...] |
operation可以是 AND、OR、NOT、XOR等。BITOP 处理不同长度的字符串时,较短的那个字符串所缺少的部分会被看作 0 。空的 key 也被看作是包含 0 的字符串序列:
定义了3 个 Bitmap,并将对应的 bit 位做与操作,然后将结果保存到新的 Bitmap中。上述操作指令表示将 3个 bitmap 进行 AND 操作,并将结果保存到 destmap 中,然后对 destmap 执行 BITCOUNT 操作。
1 | // 与操作 |
可以看到一个包含一亿bit位的 Bitmap占用的内存开销,大约是 12 MB 的内存(10^8/8/1024/1024),那么7 天的 Bitmap 的内存开销约为84 MB。不过开发者最好给 Bitmap 设置过期时间,让 Redis 删除过期的打卡数据,这样可以节省内存。
小结
当开发者在实际开发过程中遇到只需统计数据的二值状态时,如判断用户是否登录、IP是否存在黑名单、打卡是否签到等就可以考虑使用BitMap。BitMap只需一个 bit 位就能表示 0 和 1,这在统计海量数据时,将极大减少内存的占用。
基数统计
基数统计概念
基数统计,即统计一个集合中不重复元素的个数,常见于计算独立用户数(UV)。
实现基数统计最直接的方法就是使用集合(Set)这种数据结构,当一个元素从未在此集合中出现过,那么便将其添加到集合中,否则不集合保持不变。如果页面访问量巨大,就需要一个非常大的Set来进行统计,这必然会浪费大量的空间。
实际上这种数据不一定要很精准,可以采用Redis提供的HyperLogLog
这一数据结构来解决这种场景的统计问题。
HyperLogLog
是一种不精确的去重基数方案,其统计规则是基于概率实现的,标准误差 0.81%,这样的精度足以满足UV统计需求。
网站的 UV
采用Set实现
一个用户一天内多次访问一个网站只能算作一次,因此最先想到的就是使用Redis提供的Set集合来实现。
举个例子,ID为1001的用户访问index页面时,可以将这个信息放到集合中:
1 | SADD index:uv 1001 |
当这个ID为1001的用户多次访问首页时,Set集合的去重功能可以保证不会重复记录同一个用户ID。之后可通过SCARD命令来统计index页面的uv,该命令返回集合中元素的个数,也就是用户访问数:
1 | SCARD index:uv |
采用Hash实现
小明说也可以采用Hash这一数据结构来实现,将用户ID作为Hash集合的key,那么访问某个页面则执行HSET命令将value的值设置为1。这样即使用户重复访问,重复执行命令,也只会将这个userId的值设置为1。
1 | HSET index:uv userId:1001 1 |
最后利用HLEN
命令来统计Hash集合中的元素个数,也就是用户访问数:
1 | HLEN index:uv |
采用HyperLogLog实现
Set集合尽管好用,但是如果首页访问量过大,那么一个Set中就保存了上千万个用户的ID,这还仅仅是首页,还有其他页面,这对内存的消耗太大了,同理Hash这种数据结构也是一样的。
此时就可以使用HyperLogLog这种数据结构,这是一种用于基数统计的数据集合类型,即使数据量很大,计算基数所需的空间也是固定的。每个HyperLogLog最多花费12KB的内存就可以计算2的64次方个元素的基数。
Redis对HyperLogLog的存储进行了优化,在计数较小时,存储空间采用系数矩阵,占用空间很小。只有在计数很大,稀疏矩阵占用的空间超过了阈值,才会转变成稠密矩阵,占用 12KB 空间。
开发者可使用PFADD
命令,将访问index页面的每个用户ID添加到HyperLogLog中:
1 | PFADD index:uv userId1 userId2 userId3 userId4 |
然后使用PFCOUNT
命令,统计ndex页面的UV值:
1 | PFCOUNT index:uv |
当然了,HyperLogLog除了提供上面使用的PFADD
和PFCOUNT
命令,还提供了PFMERGE
命令,用于将多个HyperLogLog合并在一起形成一个新的HyperLogLog。PFMERGE
命令语法格式如下:
1 | PFMERGE destkey sourcekey [sourcekey ...] |
举个例子,在网站上有两个内容差不多的页面,运营人员说需要这两个页面的数据进行合并,也包含UV量,此时就可以使用PFMERGE
命令,注意此时同一个用户访问这两个页面只算访问一次。
1 | PFADD index:uv userId1 userId2 userId3 //3 |
可以看到这里我们将多个HyperLogLog进行了合并输出为一个新的HyperLogLog,新HyperLogLog的基数接近于所有输入HyperLogLog的可见集合的并集。
排序统计
我们知道,Redis的4种集合类型(List、Set、Hash和SortedSet)中,只有List和SortedSet是有序的。
- List按照元素插入List的顺序进行排序,通常用于实现消息队列、最新列表、排行榜等;
- SortedSet则是根据元素的score权重进行排序,开发者可自己决定每个元素的权重值,通常用于实现按照一定规则的排行榜(积分数、点赞数、播放量等)。
最新评论列表
功能实现
开发者可以使用List的插入顺序来实现评论列表。举个例子,如微信公众平台的后台回复列表,每一个公众号的文章都对应一个List,这个List就保存了该文章所对应的用户评论。
当一个用户评论index这个文章时,可以使用LPUSH命令将评论插入到List头部:
1 | LPUSH index zhangsan lisi wangwu |
接着我们再使用LRANGE命令来获取列表指定区间内的元素:
1 | LRANGE index 0 3 |
输出结果如下所示:
1 | 1) "wangwu" |
请注意,并不是所有的最新列表都能使用List来实现,对于一些频繁更新的列表,不太建议使用List,因为List类型的分页可能导致列表元素重复或者漏掉。
举个例子,假设当前评论列表为List = {A,B,C,D}
,其中左侧表示最新评论,即D是最早的评论:
1 | LPUSH list D C B A |
接下来第一页展示最新两个评论,获取到A和B:
1 | LRANGE list 0 1 |
输出结果如下所示:
1 | 1) "A" |
按照我们预想的逻辑,第二页可通过LRANGE list 2 3
来获取C和D:
1 | LRANGE list 2 3 |
输出结果如下所示:
1 | 1) "C" |
这是没有问题的,但是如果在展示第二页评论之前,又往列表里面添加了一个新的评论E,评论E通过如下命令插入到List队首:
1 | LPUSH list E |
那么此时List就变成了List = {A,B,C,D}
,此时我们再来执行之前预想的逻辑,第二页可通过LRANGE list 2 3
来获取C和D:
1 | LRANGE list 2 3 |
输出结果如下所示:
1 | 1) "B" |
可以看到评论B又出现了,原因在于List是利用元素所在的位置进行排序,而一旦有新的元素插入,那么原有数据在List中的位置都会往后移动一位,进而导致读取了旧的元素。
注意事项
因此只有在不需要分页(如每次只读取列表的前10个元素)或者更新频率比较低(如每天凌晨统计更新一下)的列表才适合使用List数据类型来实现。
对于需要分页且更新较为频繁的列表,就必须使用有序集合 Sorted Set类型来实现。
此外,List 类型无法实现通过时间范围查找的最新列表,此功能需要通过有序集合 Sorted Set 类型实现,举个例子以成交时间范围作为条件来查询的订单列表。
排行榜
功能实现
对于最新列表的场景,List和Sorted Set都可以实现,那为啥还使用List呢?直接使用Sorted Set那不是更好,它还可以设置score权重值,排序更为灵活。
原因在于Sorted Set 类型占用的内存容量是 List 类型的数倍之多,因此对于列表数量不多的情况,可以用 Sorted Set 类型来实现。如一周的音乐榜单,此时需要实时更新播放量,并且需要分页展示。除此以外,排序是根据播放量来决定的,此时List就无法满足。
开发者可以歌曲ID保存到 Sorted Set 集合中,score 设置成每首歌曲的播放量,该歌曲每播放一次则设置score = score +1
。
举个例子,开发者可以使用ZADD
命令,将《城府》和《稻香》这两首歌曲播放量放到musicTop集合中:
1 | ZADD musicTop 100000 城府 9999999 稻香 |
然后《城府》这首歌每播放一次,就使用ZINCRBY
命令,将score进行加1操作:
1 | ZINCRBY musicTop 1 城府 |
执行结果返回”100001”,这就说明播放次数确实增加了。接着我们需要获取音乐播放量排名前10的歌曲,目前最大播放量是N,可通过如下命令来获取:
1 | ZRANGEBYSCORE musicTop N-9 N WITHSCORES |
开发者可通过ZREVRANGE key start stop [WITHSCORES]
命令来将集合中的元素按照score的值进行递减(从大到小)进行排序。具有相同score的值的成员,则按照字典的逆序进行排列:
1 | ZREVRANGE musicTop 0 0 WITHSCORES |
注意事项
即使集合中的元素更新较为频繁,那么Sorted Set也能通过 ZRANGEBYSCORE
命令来准确地获取到按序排列的数据。
当开发者遇到需要展示最新列表、排行榜等业务场景时,如果数据更新频繁或者需要分页显示,建议优先考虑使用 Sorted Set。
聚合统计
聚合统计概念
聚合统计,指的是统计多个集合元素的聚合结果,常用的有:
(1)统计多个元素的共有数据(交集);
(2)统计多个元素的所有数据(并集);
(3)统计两个集合其中某个独有的元素(差集)。
Redis的Set类型支持集合内的增删改查,底层使用Hash数据结构,因此无论是add、remove 都是 O(1) 时间复杂度。并且支持多个集合间的交集、并集、差集等操作,利用这些集合操作,可以很方便的解决上面提到的统计问题。
共同好友(交集)
举个例子,新浪微博中共同关注的人就是聚合统计中的交集。我们可以将用户Id作为key,该用户关注的用户Id作为Set集合中的value。
首先模拟两个用户关注的好友:
1 | SADD userId:1001 1003 1004 1008 |
然后统计这两个用户共同关注的好友,就可以使用集合的交集命令:
1 | SINTERSTORE userId:1001-1002 userId:1001 userId:1002 |
之后两个用户交集都存在了userId:1001-1002这一集合中。
每日新增用户数(差集)
举个例子,统计某个App每天新增的用户注册数,只需对近两天的总注册用户量集合进行取差集操作即可。
如2021-06-09的总注册用户量存在于key=user:20210609
集合中,2021-06-10的总注册用户量存在于key=user:20210610
集合中:
1 | SADD user:20210609 1001 1002 1003 |
然后统计每日新增用户,就可以使用集合的差集命令:
1 | SDIFFSTORE user:20210610-0609 user:20210610 user:20210609 |
之后两个用户差集都存在了userId:20210610-0609这一集合中,上面统计的是06-10比06-09号多的人数。
实际上,新浪微博还有一个可能认识的人这一功能,也可以使用差集实现,即将你关注的好友所关注的人减去你们共同关注的人即是可能认识的人。
总共新增好友(并集)
举个例子,统计某个App在2021-06-09和2021-06-10这两天,总的用户注册数,只需对近两天的总注册用户量集合进行取并集操作即可。
如2021-06-09的总注册用户量存在于key=user:20210609
集合中,2021-06-10的总注册用户量存在于key=user:20210610
集合中:
1 | SADD user:20210609 1001 1002 1003 |
然后统计每日新增用户,就可以使用集合的差集命令:
1 | SUNIONSTORE user:20210610+0609 user:20210610 user:20210609 |
之后两个用户并集都存在了userId:20210610+0609这一集合中。
注意事项
Set的交集、并集、差集等操作计算的复杂度较高,在数据量比较大的情况下,直接执行这些操作可能会导致Redis实例阻塞。可以考虑专门部署一个集群用于统计,让它专门负责聚合计算或者是将数据读取到客户端,在客户端完成聚合统计,这样可以避免由于阻塞而导致其他服务无法快速响应的局面。