You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

266 lines
18 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 12 | 有一亿个keys要统计应该用哪种集合
你好,我是蒋德钧。
在Web和移动应用的业务场景中我们经常需要保存这样一种信息一个key对应了一个数据集合。我举几个例子。
* 手机App中的每天的用户登录信息一天对应一系列用户ID或移动设备ID
* 电商网站上商品的用户评论列表:一个商品对应了一系列的评论;
* 用户在手机App上的签到打卡信息一天对应一系列用户的签到记录
* 应用网站上的网页访问信息:一个网页对应一系列的访问点击。
我们知道Redis集合类型的特点就是一个键对应一系列的数据所以非常适合用来存取这些数据。但是在这些场景中除了记录信息我们往往还需要对集合中的数据进行统计例如
* 在移动应用中,需要统计每天的新增用户数和第二天的留存用户数;
* 在电商网站的商品评论中,需要统计评论列表中的最新评论;
* 在签到打卡中,需要统计一个月内连续打卡的用户数;
* 在网页访问记录中需要统计独立访客Unique VisitorUV量。
通常情况下,我们面临的用户数量以及访问量都是巨大的,比如百万、千万级别的用户数量,或者千万级别、甚至亿级别的访问信息。所以,我们必须要选择能够非常高效地统计大量数据(例如亿级)的集合类型。
**要想选择合适的集合,我们就得了解常用的集合统计模式。**这节课,我就给你介绍集合类型常见的四种统计模式,包括聚合统计、排序统计、二值状态统计和基数统计。我会以刚刚提到的这四个场景为例,和你聊聊在这些统计模式下,什么集合类型能够更快速地完成统计,而且还节省内存空间。掌握了今天的内容,之后再遇到集合元素统计问题时,你就能很快地选出合适的集合类型了。
## 聚合统计
我们先来看集合元素统计的第一个场景:聚合统计。
所谓的聚合统计,就是指统计多个集合元素的聚合结果,包括:统计多个集合的共有元素(交集统计);把两个集合相比,统计其中一个集合独有的元素(差集统计);统计多个集合的所有元素(并集统计)。
在刚才提到的场景中统计手机App每天的新增用户数和第二天的留存用户数正好对应了聚合统计。
要完成这个统计任务我们可以用一个集合记录所有登录过App的用户ID同时用另一个集合记录每一天登录过App的用户ID。然后再对这两个集合做聚合统计。我们来看下具体的操作。
记录所有登录过App的用户ID还是比较简单的我们可以直接使用Set类型把key设置为user:id表示记录的是用户IDvalue就是一个Set集合里面是所有登录过App的用户ID我们可以把这个Set叫作累计用户Set如下图所示
![](https://static001.geekbang.org/resource/image/99/ca/990e56babf199d9a7fa4c7343167ecca.jpg)
需要注意的是累计用户Set中没有日期信息我们是不能直接统计每天的新增用户的。所以我们还需要把每一天登录的用户ID记录到一个新集合中我们把这个集合叫作每日用户Set它有两个特点
1. key是 user:id 以及当天日期,例如 user:id:20200803
2. value是Set集合记录当天登录的用户ID。
![](https://static001.geekbang.org/resource/image/a6/9e/a63dd95d5e44bf538fe960e67761b59e.jpg)
在统计每天的新增用户时我们只用计算每日用户Set和累计用户Set的差集就行。
我借助一个具体的例子来解释一下。
假设我们的手机App在2020年8月3日上线那么8月3日前是没有用户的。此时累计用户Set是空集当天登录的用户ID会被记录到 key为user:id:20200803的Set中。所以user:id:20200803这个Set中的用户就是当天的新增用户。
然后我们计算累计用户Set和user:id:20200803 Set的并集结果结果保存在user:id这个累计用户Set中如下所示
```
SUNIONSTORE user:id user:id user:id:20200803
```
此时user:id这个累计用户Set中就有了8月3日的用户ID。等到8月4日再统计时我们把8月4日登录的用户ID记录到user:id:20200804 的Set中。接下来我们执行SDIFFSTORE命令计算累计用户Set和user:id:20200804 Set的差集结果保存在key为user:new的Set中如下所示
```
SDIFFSTORE user:new user:id:20200804 user:id
```
可以看到这个差集中的用户ID在user:id:20200804 的Set中存在但是不在累计用户Set中。所以user:new这个Set中记录的就是8月4日的新增用户。
当要计算8月4日的留存用户时我们只需要再计算user:id:20200803 和 user:id:20200804两个Set的交集就可以得到同时在这两个集合中的用户ID了这些就是在8月3日登录并且在8月4日留存的用户。执行的命令如下
```
SINTERSTORE user:id:rem user:id:20200803 user:id:20200804
```
当你需要对多个集合进行聚合计算时Set类型会是一个非常不错的选择。不过我要提醒你一下这里有一个潜在的风险。
Set的差集、并集和交集的计算复杂度较高在数据量较大的情况下如果直接执行这些计算会导致Redis实例阻塞。所以我给你分享一个小建议**你可以从主从集群中选择一个从库,让它专门负责聚合计算,或者是把数据读取到客户端,在客户端来完成聚合统计**,这样就可以规避阻塞主库实例和其他从库实例的风险了。
## 排序统计
接下来,我们再来聊一聊应对集合元素排序需求的方法。我以在电商网站上提供最新评论列表的场景为例,进行讲解。
最新评论列表包含了所有评论中的最新留言,**这就要求集合类型能对元素保序**,也就是说,集合中的元素可以按序排列,这种对元素保序的集合类型叫作有序集合。
在Redis常用的4个集合类型中List、Hash、Set、Sorted SetList和Sorted Set就属于有序集合。
**List是按照元素进入List的顺序进行排序的而Sorted Set可以根据元素的权重来排序**我们可以自己来决定每个元素的权重值。比如说我们可以根据元素插入Sorted Set的时间确定权重值先插入的元素权重小后插入的元素权重大。
看起来好像都可以满足需求,我们该怎么选择呢?
我先说说用List的情况。每个商品对应一个List这个List包含了对这个商品的所有评论而且会按照评论时间保存这些评论每来一个新评论就用LPUSH命令把它插入List的队头。
在只有一页评论的时候我们可以很清晰地看到最新的评论但是在实际应用中网站一般会分页显示最新的评论列表一旦涉及到分页操作List就可能会出现问题了。
假设当前的评论List是{A, B, C, D, E, F}其中A是最新的评论以此类推F是最早的评论在展示第一页的3个评论时我们可以用下面的命令得到最新的三条评论A、B、C
```
LRANGE product1 0 2
1) "A"
2) "B"
3) "C"
```
然后再用下面的命令获取第二页的3个评论也就是D、E、F。
```
LRANGE product1 3 5
1) "D"
2) "E"
3) "F"
```
但是如果在展示第二页前又产生了一个新评论G评论G就会被LPUSH命令插入到评论List的队头评论List就变成了{G, A, B, C, D, E, F}。此时再用刚才的命令获取第二页评论时就会发现评论C又被展示出来了也就是C、D、E。
```
LRANGE product1 3 5
1) "C"
2) "D"
3) "E"
```
之所以会这样关键原因就在于List是通过元素在List中的位置来排序的当有一个新元素插入时原先的元素在List中的位置都后移了一位比如说原来在第1位的元素现在排在了第2位。所以对比新元素插入前后List相同位置上的元素就会发生变化用LRANGE读取时就会读到旧元素。
和List相比Sorted Set就不存在这个问题因为它是根据元素的实际权重来排序和获取数据的。
我们可以按评论时间的先后给每条评论设置一个权重值然后再把评论保存到Sorted Set中。Sorted Set的ZRANGEBYSCORE命令就可以按权重排序后返回元素。这样的话即使集合中的元素频繁更新Sorted Set也能通过ZRANGEBYSCORE命令准确地获取到按序排列的数据。
假设越新的评论权重越大目前最新评论的权重是N我们执行下面的命令时就可以获得最新的10条评论
```
ZRANGEBYSCORE comments N-9 N
```
所以在面对需要展示最新列表、排行榜等场景时如果数据更新频繁或者需要分页显示建议你优先考虑使用Sorted Set。
## 二值状态统计
现在我们再来分析下第三个场景二值状态统计。这里的二值状态就是指集合元素的取值就只有0和1两种。在签到打卡的场景中我们只用记录签到1或未签到0所以它就是非常典型的二值状态
在签到统计时每个用户一天的签到用1个bit位就能表示一个月假设是31天的签到情况用31个bit位就可以而一年的签到也只需要用365个bit位根本不用太复杂的集合类型。这个时候我们就可以选择Bitmap。这是Redis提供的扩展数据类型。我来给你解释一下它的实现原理。
Bitmap本身是用String类型作为底层数据结构实现的一种统计二值状态的数据类型。String类型是会保存为二进制的字节数组所以Redis就把字节数组的每个bit位利用起来用来表示一个元素的二值状态。你可以把Bitmap看作是一个bit数组。
Bitmap提供了GETBIT/SETBIT操作使用一个偏移值offset对bit数组的某一个bit位进行读和写。不过需要注意的是Bitmap的偏移量是从0开始算的也就是说offset的最小值是0。当使用SETBIT对一个bit位进行写操作时这个bit位会被设置为1。Bitmap还提供了BITCOUNT操作用来统计这个bit数组中所有“1”的个数。
那么具体该怎么用Bitmap进行签到统计呢我还是借助一个具体的例子来说明。
假设我们要统计ID 3000的用户在2020年8月份的签到情况就可以按照下面的步骤进行操作。
第一步执行下面的命令记录该用户8月3号已签到。
```
SETBIT uid:sign:3000:202008 2 1
```
第二步检查该用户8月3日是否签到。
```
GETBIT uid:sign:3000:202008 2
```
第三步统计该用户在8月份的签到次数。
```
BITCOUNT uid:sign:3000:202008
```
这样我们就知道该用户在8月份的签到情况了是不是很简单呢接下来你可以再思考一个问题如果记录了1亿个用户10天的签到情况你有办法统计出这10天连续签到的用户总数吗
在介绍具体的方法之前我们要先知道Bitmap支持用BITOP命令对多个Bitmap按位做“与”“或”“异或”的操作操作的结果会保存到一个新的Bitmap中。
我以按位“与”操作为例来具体解释一下。从下图中可以看到三个Bitmap bm1、bm2和bm3对应bit位做“与”操作结果保存到了一个新的Bitmap中示例中这个结果Bitmap的key被设为“resmap”
![](https://static001.geekbang.org/resource/image/41/7a/4151af42513cf5f7996fe86c6064f97a.jpg)
回到刚刚的问题在统计1亿个用户连续10天的签到情况时你可以把每天的日期作为key每个key对应一个1亿位的Bitmap每一个bit对应一个用户当天的签到情况。
接下来我们对10个Bitmap做“与”操作得到的结果也是一个Bitmap。在这个Bitmap中只有10天都签到的用户对应的bit位上的值才会是1。最后我们可以用BITCOUNT统计下Bitmap中的1的个数这就是连续签到10天的用户总数了。
现在我们可以计算一下记录了10天签到情况后的内存开销。每天使用1个1亿位的Bitmap大约占12MB的内存10^8/8/1024/102410天的Bitmap的内存开销约为120MB内存压力不算太大。不过在实际应用时最好对Bitmap设置过期时间让Redis自动删除不再需要的签到记录以节省内存开销。
所以如果只需要统计数据的二值状态例如商品有没有、用户在不在等就可以使用Bitmap因为它只用一个bit位就能表示0或1。在记录海量数据时Bitmap能够有效地节省内存空间。
## 基数统计
最后我们再来看一个统计场景基数统计。基数统计就是指统计一个集合中不重复的元素个数。对应到我们刚才介绍的场景中就是统计网页的UV。
网页UV的统计有个独特的地方就是需要去重一个用户一天内的多次访问只能算作一次。在Redis的集合类型中Set类型默认支持去重所以看到有去重需求时我们可能第一时间就会想到用Set类型。
我们来结合一个例子看一看用Set的情况。
有一个用户user1访问page1时你把这个信息加到Set中
```
SADD page1:uv user1
```
用户1再来访问时Set的去重功能就保证了不会重复记录用户1的访问次数这样用户1就算是一个独立访客。当你需要统计UV时可以直接用SCARD命令这个命令会返回一个集合中的元素个数。
但是如果page1非常火爆UV达到了千万这个时候一个Set就要记录千万个用户ID。对于一个搞大促的电商网站而言这样的页面可能有成千上万个如果每个页面都用这样的一个Set就会消耗很大的内存空间。
当然你也可以用Hash类型记录UV。
例如你可以把用户ID作为Hash集合的key当用户访问页面时就用HSET命令用于设置Hash集合元素的值对这个用户ID记录一个值“1”表示一个独立访客用户1访问page1后我们就记录为1个独立访客如下所示
```
HSET page1:uv user1 1
```
即使用户1多次访问页面重复执行这个HSET命令也只会把user1的值设置为1仍然只记为1个独立访客。当要统计UV时我们可以用HLEN命令统计Hash集合中的所有元素个数。
但是和Set类型相似当页面很多时Hash类型也会消耗很大的内存空间。那么有什么办法既能完成统计还能节省内存吗
这时候就要用到Redis提供的HyperLogLog了。
HyperLogLog是一种用于统计基数的数据集合类型它的最大优势就在于当集合元素数量非常多时它计算基数所需的空间总是固定的而且还很小。
在Redis中每个 HyperLogLog只需要花费 12 KB 内存,就可以计算接近 2^64 个元素的基数。你看和元素越多就越耗费内存的Set和Hash类型相比HyperLogLog就非常节省空间。
在统计UV时你可以用PFADD命令用于向HyperLogLog中添加新元素把访问页面的每个用户都添加到HyperLogLog中。
```
PFADD page1:uv user1 user2 user3 user4 user5
```
接下来就可以用PFCOUNT命令直接获得page1的UV值了这个命令的作用就是返回HyperLogLog的统计结果。
```
PFCOUNT page1:uv
```
关于HyperLogLog的具体实现原理你不需要重点掌握不会影响到你的日常使用我就不多讲了。如果你想了解一下课下可以看看[这条链接](http://en.wikipedia.org/wiki/HyperLogLog)。
不过有一点需要你注意一下HyperLogLog的统计规则是基于概率完成的所以它给出的统计结果是有一定误差的标准误算率是0.81%。这也就意味着你使用HyperLogLog统计的UV是100万但实际的UV可能是101万。虽然误差率不算大但是如果你需要精确统计结果的话最好还是继续用Set或Hash类型。
## 小结
这节课我们结合统计新增用户数和留存用户数、最新评论列表、用户签到数以及网页独立访客量这4种典型场景学习了集合类型的4种统计模式分别是聚合统计、排序统计、二值状态统计和基数统计。为了方便你掌握我把Set、Sorted Set、Hash、List、Bitmap、HyperLogLog的支持情况和优缺点汇总在了下面的表格里希望你把这张表格保存下来时不时地复习一下。
![](https://static001.geekbang.org/resource/image/c0/6e/c0bb35d0d91a62ef4ca1bd939a9b136e.jpg)
可以看到Set和Sorted Set都支持多种聚合统计不过对于差集计算来说只有Set支持。Bitmap也能做多个Bitmap间的聚合计算包括与、或和异或操作。
当需要进行排序统计时List中的元素虽然有序但是一旦有新元素插入原来的元素在List中的位置就会移动那么按位置读取的排序结果可能就不准确了。而Sorted Set本身是按照集合元素的权重排序可以准确地按序获取结果所以建议你优先使用它。
如果我们记录的数据只有0和1两个值的状态Bitmap会是一个很好的选择这主要归功于Bitmap对于一个数据只用1个bit记录可以节省内存。
对于基数统计来说如果集合元素量达到亿级别而且不需要精确统计时我建议你使用HyperLogLog。
当然Redis的应用场景非常多这张表中的总结不一定能覆盖到所有场景。我建议你也试着自己画一张表把你遇到的其他场景添加进去。长久积累下来你一定能够更加灵活地把集合类型应用到合适的实践项目中。
## 每课一问
依照惯例我给你留个小问题。这节课我们学习了4种典型的统计模式以及各种集合类型的支持情况和优缺点我想请你聊一聊你还遇到过其他的统计场景吗用的是怎样的集合类型呢
欢迎你在留言区写下你的思考和答案,和我交流讨论。如果你身边还有需要解决这些统计问题的朋友或同事,也欢迎你把今天的内容分享给他/她,我们下节课见。