共计 7969 个字符,预计需要花费 20 分钟才能阅读完成。
在挪动利用的业务场景中,咱们须要保留这样的信息:一个 key 关联了一个数据汇合,同时还要对汇合中的数据进行统计排序。
常见的场景如下:
- 给一个 userId,判断用户登陆状态;
- 两亿用户最近 7 天的签到状况,统计 7 天内间断签到的用户总数;
- 统计每天的新增与第二天的留存用户数;
- 统计网站的对访客(Unique Visitor,UV)量
- 最新评论列表
- 依据播放量音乐榜单
通常状况下,咱们面临的用户数量以及访问量都是微小的,比方百万、千万级别的用户数量,或者千万级别、甚至亿级别的访问信息。
所以,咱们必须要抉择可能十分高效地统计大量数据(例如亿级)的汇合类型。
如何抉择适合的数据汇合,咱们首先要理解罕用的统计模式,并使用正当的数据了性来解决理论问题。
四种统计类型:
- 二值状态统计;
- 聚合统计;
- 排序统计;
- 基数统计。
本文将用到 String、Set、Zset、List、hash 以外的拓展数据类型 Bitmap
、HyperLogLog
来实现。
文章波及到的指令能够通过在线 Redis 客户端运行调试,地址:https://try.redis.io/,超不便的说。
寄语
多分享多付出,后期多给他人发明价值并且不计回报,从久远来看,这些付出都会成倍的回报你。
特地是刚开始跟他人单干的时候,不要去计较短期的回报,没有太大意义,更多的是锤炼本人的视线、视角以及解决问题的能力。
二值状态统计
码哥,什么是二值状态统计呀?
也就是汇合中的元素的值只有 0 和 1 两种,在签到打卡和用户是否登陆的场景中,只需记录 签到 (1)
或 未签到 (0)
, 已登录 (1)
或未登陆(0)
。
如果咱们在判断用户是否登陆的场景中应用 Redis 的 String 类型实现(key -> userId,value -> 0 示意下线,1 – 登陆),如果存储 100 万个用户的登陆状态,如果以字符串的模式存储,就须要存储 100 万个字符串了,内存开销太大。
对于二值状态场景,咱们就能够利用 Bitmap 来实现。比方登陆状态咱们用一个 bit 位示意,一亿个用户也只占用 一亿 个 bit 位内存 ≈(100000000 / 8/ 1024/1024)12 MB。
大略的空间占用计算公式是:($offset/8/1024/1024) MB
什么是 Bitmap 呢?
Bitmap 的底层数据结构用的是 String 类型的 SDS 数据结构来保留位数组,Redis 把每个字节数组的 8 个 bit 位利用起来,每个 bit 位 示意一个元素的二值状态(不是 0 就是 1)。
能够将 Bitmap 看成是一个 bit 为单位的数组,数组的每个单元只能存储 0 或者 1,数组的下标在 Bitmap 中叫做 offset 偏移量。
为了直观展现,咱们能够了解成 buf 数组的每个字节用一行示意,每一行有 8 个 bit 位,8 个格子别离示意这个字节中的 8 个 bit 位,如下图所示:
8 个 bit 组成一个 Byte,所以 Bitmap 会极大地节俭存储空间。 这就是 Bitmap 的劣势。
判断用户登陆态
怎么用 Bitmap 来判断海量用户中某个用户是否在线呢?
Bitmap 提供了 GETBIT、SETBIT
操作,通过一个偏移值 offset 对 bit 数组的 offset 地位的 bit 位进行读写操作,须要留神的是 offset 从 0 开始。
只须要一个 key = login_status 示意存储用户登陆状态汇合数据,将用户 ID 作为 offset,在线就设置为 1,下线设置 0。通过 GETBIT
判断对应的用户是否在线。50000 万 用户只须要 6 MB 的空间。
SETBIT 命令
SETBIT <key> <offset> <value>
设置或者清空 key 的 value 在 offset 处的 bit 值(只能是 0 或者 1)。
GETBIT 命令
GETBIT <key> <offset>
获取 key 的 value 在 offset 处的 bit 位的值,当 key 不存在时,返回 0。
如果咱们要判断 ID = 10086 的用户的登陆状况:
第一步,执行以下指令,示意用户已登录。
SETBIT login_status 10086 1
第二步,查看该用户是否登陆,返回值 1 示意已登录。
GETBIT login_status 10086
第三步,登出,将 offset 对应的 value 设置成 0。
SETBIT login_status 10086 0
用户每个月的签到状况
在签到统计中,每个用户每天的签到用 1 个 bit 位示意,一年的签到只须要 365 个 bit 位。一个月最多只有 31 天,只须要 31 个 bit 位即可。
比方统计编号 89757 的用户在 2021 年 5 月份的打卡状况要如何进行?
key 能够设计成 uid:sign:{userId}:{yyyyMM}
,月份的每一天的值 – 1 能够作为 offset(因为 offset 从 0 开始,所以 offset = 日期 - 1
)。
第一步,执行上面指令示意记录用户在 2021 年 5 月 16 号打卡。
SETBIT uid:sign:89757:202105 15 1
第二步,判断编号 89757 用户在 2021 年 5 月 16 号是否打卡。
GETBIT uid:sign:89757:202105 15
第三步,统计该用户在 5 月份的打卡次数,应用 BITCOUNT
指令。该指令用于统计给定的 bit 数组中,值 = 1 的 bit 位的数量。
BITCOUNT uid:sign:89757:202105
这样咱们就能够实现用户每个月的打卡状况了,是不是很赞。
如何统计这个月首次打卡工夫呢?
Redis 提供了 BITPOS key bitValue [start] [end]
指令,返回数据表示 Bitmap 中第一个值为 bitValue
的 offset 地位。
在默认状况下,命令将检测整个位图,用户能够通过可选的 start
参数和 end
参数指定要检测的范畴。
所以咱们能够通过执行以下指令来获取 userID = 89757 在 2021 年 5 月份 首次打卡 日期:
BITPOS uid:sign:89757:202105 1
须要留神的是,咱们须要将返回的 value + 1 示意首次打卡的天,因为 offset 从 0 开始。
间断签到用户总数
在记录了一个亿的用户间断 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 operation destkey key [key ...]
这个指令用于对一个或者多个 键 = key 的 Bitmap 进行位元操作。
opration
能够是 and
、OR
、NOT
、XOR
。当 BITOP 解决不同长度的字符串时,较短的那个字符串所短少的局部会被当做 0
。
空的 key
也被看作是蕴含 0
的字符串序列。
便于了解,如下图所示:
3 个 Bitmap,对应的 bit 位做「与」操作,后果保留到新的 Bitmap 中。
操作指令示意将 三个 bitmap 进行 AND 操作,并将后果保留到 destmap 中。接着对 destmap 执行 BITCOUNT 统计。
// 与操作
BITOP AND destmap bitmap:01 bitmap:02 bitmap:03
// 统计 bit 位 = 1 的个数
BITCOUNT destmap
简略计算下 一个一亿个位的 Bitmap 占用的内存开销,大概占 12 MB 的内存(10^8/8/1024/1024),7 天的 Bitmap 的内存开销约为 84 MB。同时咱们最好给 Bitmap 设置过期工夫,让 Redis 删除过期的打卡数据,节俭内存。
小结
思路才是最重要,当咱们遇到的统计场景只须要统计数据的二值状态,比方用户是否存在、ip 是否是黑名单、以及签到打卡统计等场景就能够思考应用 Bitmap。
只须要一个 bit 位就能示意 0 和 1,在统计海量数据的时候将大大减少内存占用。
基数统计
基数统计:统计一个汇合中不反复元素的个数,常见于计算独立用户数(UV)。
实现基数统计最间接的办法,就是采纳汇合(Set)这种数据结构,当一个元素从未呈现过期,便在汇合中减少一个元素;如果呈现过,那么汇合仍放弃不变。
当页面访问量微小,就须要一个超大的 Set 汇合来统计,将会节约大量空间。
另外,这样的数据也不须要很准确,到底有没有更好的计划呢?
这个问题问得好,Redis 提供了 HyperLogLog
数据结构就是用来解决种种场景的统计问题。
HyperLogLog
是一种不准确的去重基数计划,它的统计规定是基于概率实现的,标准误差 0.81%,这样的精度足以满足 UV 统计需要了。
对于 HyperLogLog 的原理过于简单,如果想要理解的请移步:
- https://www.zhihu.com/questio…
- https://en.wikipedia.org/wiki…
网站的 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 类型实现,如以成交工夫范畴作为条件来查问的订单列表。
排行榜
码老湿,对于最新列表的场景,List 和 Sorted Set 都能实现,为啥还用 List 呢?间接应用 Sorted Set 不是更好,它还能设置 score 权重排序更加灵便。
起因是 Sorted Set 类型占用的内存容量是 List 类型的数倍之多,对于列表数量不多的状况,能够用 Sorted Set 类型来实现。
比方要一周音乐榜单,咱们须要实时更新播放量,并且须要分页展现。
除此以外,排序是依据播放量来决定的,这个时候 List 就无奈满足了。
咱们能够将音乐 ID 保留到 Sorted Set 汇合中,score
设置成每首歌的播放量,该音乐每播放一次则设置 score = score +1。
ZADD
比方咱们将《青花瓷》和《花田错》播放量增加到 musicTop 汇合中:
ZADD musicTop 100000000 青花瓷 8999999 花田错
ZINCRBY
《青花瓷》每播放一次就通过 ZINCRBY
指令将 score + 1。
> ZINCRBY musicTop 1 青花瓷
100000001
ZRANGEBYSCORE
最初咱们须要获取 musicTop 前十 播放量音乐榜单,目前最大播放量是 N,可通过如下指令获取:
ZRANGEBYSCORE musicTop N-9 N WITHSCORES
65 哥:可是这个 N 咱们怎么获取呀?
ZREVRANGE
可通过 ZREVRANGE key start stop [WITHSCORES]
指令。
其中元素的排序按 score
值递加 (从大到小) 来排列。
具备雷同 score
值的成员按字典序的逆序 (reverse lexicographical order) 排列。
> ZREVRANGE musicTop 0 0 WITHSCORES
1) "青花瓷"
2) 100000000
小结
即便汇合中的元素频繁更新,Sorted Set 也能通过 ZRANGEBYSCORE
命令精确地获取到按序排列的数据。
在面对须要展现最新列表、排行榜等场景时,如果数据更新频繁或者须要分页显示,倡议优先思考应用 Sorted Set。
聚合统计
指的就是统计多个汇合元素的聚合后果,比如说:
- 统计多个元素的共有数据(交加);
- 统计两个汇合其中的一个独有元素(差集统计);
- 统计多个汇合的所有元素(并集统计)。
码老湿,什么样的场景会用到交加、差集、并集呢?
Redis 的 Set 类型反对汇合内的增删改查,底层应用了 Hash 数据结构,无论是 add、remove 都是 O(1) 工夫复杂度。
并且反对多个汇合间的交加、并集、差集操作,利用这些汇合操作,解决上边提到的统计问题。
交加 - 独特好友
比方 QQ 中的独特好友正是聚合统计中的交加。咱们将账号作为 Key,该账号的好友作为 Set 汇合的 value。
模仿两个用户的好友汇合:
SADD user: 码哥字节 R 大 Linux 大神 PHP 之父
SADD user: 大佬 Linux 大神 Python 大神 C++ 菜鸡
统计两个用户的独特好友只须要两个 Set 汇合的交加,如下命令:
SINTERSTORE user: 独特好友 user: 码哥字节 user: 大佬
命令的执行后,「user: 码哥字节」、「user: 大佬」两个汇合的交加数据存储到 user: 独特好友这个汇合中。
差集 - 每日新增好友数
比方,统计某个 App 每日新增注册用户量,只须要对近两天的总注册用户量汇合取差集即可。
比方,2021-06-01 的总注册用户量寄存在 key = user:20210601
set 汇合中,2021-06-02 的总用户量寄存在 key = user:20210602
的汇合中。
如下指令,执行差集计算并将后果寄存到 user:new
汇合中。
SDIFFSTORE user:new user:20210602 user:20210601
执行结束,此时的 user:new 汇合将是 2021/06/02 日新增用户量。
除此之外,QQ 上有个可能意识的人性能,也能够应用差集实现,就是把你敌人的好友汇合减去你们独特的好友即是可能意识的人。
并集 - 总共新增好友
还是差集的例子,统计 2021/06/01 和 2021/06/02 两天总共新增的用户量,只须要对两个汇合执行并集。
SUNIONSTORE userid:new user:20210602 user:20210601
此时新的汇合 userid:new 则是两日新增的好友。
小结
Set 的差集、并集和交加的计算复杂度较高,在数据量较大的状况下,如果间接执行这些计算,会导致 Redis 实例阻塞。
所以,能够专门部署一个集群用于统计,让它专门负责聚合计算,或者是把数据读取到客户端,在客户端来实现聚合统计,这样就能够躲避因为阻塞导致其余服务无奈响应。
往期举荐
Redis 外围篇:唯快不破的机密
Redis 日志篇:无畏宕机疾速复原的杀手锏
Redis 高可用篇:你管这叫主从架构数据同步原理?
Redis 高可用篇:你管这叫 Sentinel 哨兵集群原理
Redis 高可用篇:Cluster 集群能撑持的数据有多大?