基数统计
基数统计:统计一个集合中不重复元素的个数,常见于计算独立用户数(UV)。
实现基数统计最直接的方法,就是采用集合(Set)这种数据结构,当一个元素从未出现过时,便在集合中增加一个元素;如果出现过,那么集合仍保持不变。
当页面访问量巨大,就需要一个超大的 Set 集合来统计,将会浪费大量空间。
另外,这样的数据也不需要很精确,到底有没有更好的方案呢?
这个问题问得好,Redis 提供了 HyperLogLog
数据结构就是用来解决种种场景的统计问题。
HyperLogLog
是一种不精确的去重基数方案,它的统计规则是基于概率实现的,标准误差 0.81%,这样的精度足以满足 UV 统计需求了。
关于 HyperLogLog 的原理过于复杂,如果想要了解的请移步:
网站的 UV
通过 Set 实现
一个用户一天内多次访问一个网站只能算作一次,所以很容易就想到通过 Redis 的 Set 集合来实现。
用户编号 89757 访问 「Redis 为什么这么快 」时,我们将这个信息放到 Set 中。
SADD Redis为什么这么快:uv 89757
当用户编号 89757 多次访问「Redis 为什么这么快」页面,Set 的去重功能能保证不会重复记录同一个用户 ID。
通过 SCARD
命令,统计「Redis 为什么这么快」页面 UV。指令返回一个集合的元素个数(也就是用户 ID)。
SCARD Redis为什么这么快:uv
通过 Hash 实现
码老湿,还可以利用 Hash 类型实现,将用户 ID 作为 Hash 集合的 key,访问页面则执行 HSET 命令将 value 设置成 1。
即使用户重复访问,重复执行命令,也只会把这个 userId 的值设置成 “1"。
最后,利用 HLEN
命令统计 Hash 集合中的元素个数就是 UV。
如下:
HSET redis集群:uv userId:89757 1 // 统计 UV HLEN redis集群
HyperLogLog 王者方案
码老湿,Set 虽好,如果文章非常火爆达到千万级别,一个 Set 就保存了千万个用户的 ID,页面多了消耗的内存也太大了。同理,Hash数据类型也是如此。咋办呢?
利用 Redis 提供的 HyperLogLog
高级数据结构(不要只知道 Redis 的五种基础数据类型了)。这是一种用于基数统计的数据集合类型,即使数据量很大,计算基数需要的空间也是固定的。
每个 HyperLogLog
最多只需要花费 12KB 内存就可以计算 2 的 64 次方个元素的基数。
Redis 对 HyperLogLog
的存储进行了优化,在计数比较小的时候,存储空间采用系数矩阵,占用空间很小。
只有在计数很大,稀疏矩阵占用的空间超过了阈值才会转变成稠密矩阵,占用 12KB 空间。
PFADD
将访问页面的每个用户 ID 添加到 HyperLogLog
中。
PFADD Redis主从同步原理:uv userID1 userID 2 useID3
PFCOUNT
利用 PFCOUNT
获取 「Redis主从同步原理」页面的 UV值。
PFCOUNT Redis主从同步原理:uv
PFMERGE 使用场景
HyperLogLog
除了上面的 PFADD
和 PFCOIUNT
外,还提供了 PFMERGE
,将多个 HyperLogLog
合并在一起形成一个新的 HyperLogLog
值。
语法
PFMERGE destkey sourcekey [sourcekey ...]
使用场景
比如在网站中我们有两个内容差不多的页面,运营说需要这两个页面的数据进行合并。
其中页面的 UV 访问量也需要合并,那这个时候 PFMERGE
就可以派上用场了,也就是同样的用户访问这两个页面则只算做一次。
如下所示:Redis、MySQL 两个 Bitmap 集合分别保存了两个页面用户访问数据。
PFADD Redis数据 user1 user2 user3 PFADD MySQL数据 user1 user2 user4 PFMERGE 数据库 Redis数据 MySQL数据 PFCOUNT 数据库 // 返回值 = 4
将多个 HyperLogLog 合并(merge)为一个 HyperLogLog , 合并后的 HyperLogLog 的基数接近于所有输入 HyperLogLog 的可见集合(observed set)的并集。
user1、user2 都访问了 Redis 和 MySQL,只算访问了一次。
排序统计
Redis 的 4 个集合类型中(List、Set、Hash、Sorted Set),List 和 Sorted Set 就是有序的。
- List:按照元素插入 List 的顺序排序,使用场景通常可以作为 消息队列、最新列表、排行榜;
- Sorted Set:根据元素的 score 权重排序,我们可以自己决定每个元素的权重值。使用场景(排行榜,比如按照播放量、点赞数)。
最新评论列表
码老湿,我可以利用 List 插入的顺序排序实现评论列表
比如微信公众号的后台回复列表(不要杠,举例子),每一公众号对应一个 List,这个 List 保存该公众号的所有的用户评论。
每当一个用户评论,则利用 LPUSH key value [value ...]
插入到 List 队头。
LPUSH 码哥字节 1 2 3 4 5 6
接着再用 LRANGE key star stop
获取列表指定区间内的元素。
> LRANGE 码哥字节 0 4 1) "6" 2) "5" 3) "4" 4) "3" 5) "2"
注意,并不是所有最新列表都能用 List 实现,对于因为对于频繁更新的列表,list类型的分页可能导致列表元素重复或漏掉。
比如当前评论列表 List ={A, B, C, D}
,左边表示最新的评论,D 是最早的评论。
LPUSH 码哥字节 D C B A
展示第一页最新 2 个评论,获取到 A、B:
LRANGE 码哥字节 0 1 1) "A" 2) "B"
按照我们想要的逻辑来说,第二页可通过 LRANGE 码哥字节 2 3
获取 C,D。
如果在展示第二页之前,产生新评论 E,评论 E 通过 LPUSH 码哥字节 E
插入到 List 队头,List = {E, A, B, C, D }。
现在执行 LRANGE 码哥字节 2 3
获取第二页评论发现, B 又出现了。
LRANGE 码哥字节 2 3 1) "B" 2) "C"
出现这种情况的原因在于 List 是利用元素所在的位置排序,一旦有新元素插入,List = {E,A,B,C,D}
。
原先的数据在 List 的位置都往后移动一位,导致读取都旧元素。
小结
只有不需要分页(比如每次都只取列表的前 5 个元素)或者更新频率低(比如每天凌晨统计更新一次)的列表才适合用 List 类型实现。
对于需要分页并且会频繁更新的列表,需用使用有序集合 Sorted Set 类型实现。
另外,需要通过时间范围查找的最新列表,List 类型也实现不了,需要通过有序集合 Sorted Set 类型实现,如以成交时间范围作为条件来查询的订单列表。