Redis 中的高级数据结构
前面我们学习 Redis 中的5中基础数据类型,今天我又来了,没错,就是 Redis 中剩余的3中高级数据类型,废话不多说,我们开始吧。
Bitmap
Bitmap 翻译过来就是位图,那它有什么作用呢?
我们首先想这么一个场景,二值状态统计,这个二值状态就是指集合元素的取值就只有0和1两种。比如:在签到打卡的场景中,我们只用记录签到(1)或未签到(0),这就是典型的二值状态。在我们签到统计时,每个用户的一天的签到用1个bit位就能表示,有多少天就只需要多少个bit位,不需要使用太复杂的集合类型,这时我们就可以使用 Bitmap。
那 Bitmap 的实现原理又是啥?
Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。String 类型是会保存为二进制的字节数组,所以,Redis 就把字节数组的每个 bit 位利用起来,用来表示一个元素的二值状态,可以把 Bitmap 看作是一个 bit 数组。
Bitmap 提供了 GETBIT/SETBIT 操作,使用一个偏移值 offset 对 bit 数据的某一个 bit 位进行读和写,这个偏移值 offset 是从 0 开始的,最小就是0。当使用 SETBIT 对一个 bit 位进行写操作时,这个 bit 位会被设置为1。Bitmap 还提供了BITCOUNT 操作,用来统计这个 bit 数组中所有“1” 的个数。
那么该怎么具体使用 Bitmap 进行签到统计呢?
假设我们要统计 ID 3000 的用户在 2020 年 8 月份的签到情况,就可以按照下面的步骤进行操作。
1)执行下面的命令,记录该用户 8 月 3 号已签到。
SETBIT uid:sign:3000:202008 2 1
2)检查该用户 8 月 3 日是否签到。
GETBIT uid:sign:3000:202008 2
3)统计该用户在 8 月份的签到次数。
BITCOUNT uid:sign:3000:202008
这样,我们就知道该用户在 8 月份的签到情况了,是不是很简单呢?接下来,你可以再思考一个问题:如果记录了 1 亿个用户 10 天的签到情况,你有办法统计出这 10 天连续签到的用户总数吗?
在介绍具体的方法之前,我们要先知道,Bitmap 支持用 BITOP 命令对多个 Bitmap 按位做“与”“或”“异或”的操作,操作的结果会保存到一个新的 Bitmap 中。 我们用与运算举个例子:
假如我们对三个 Bitmap bm1、bm2、bm3对应 bit 做“与”操作,结果保存到了一个新的 Bitmap 中,这个结果 Bitmap 的 key 被设为“resmap”。
回到上面的问题,在统计 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/1024),10 天的 Bitmap 的内存开销约为 120MB,内存压力不算太大。不过,在实际应用时,最好对 Bitmap 设置过期时间,让Redis 自动删除不再需要的签到记录,以节省内存开销。
由此可看出,在记录海量数据时,Bitmap 能有效地节省内存空间。
HyperLogLog
在介绍 HyperLogLog 之前,我们先讲一下基数统计。基数统计就是指统计一个集合中不重复的元素个数。
以下面这个场景为例,我们来看看使用基数统计的场景:
比如统计网页的 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 类型也会消耗很大的内存空间。那么,有什么办法既能完成统计,还能节省内存吗?
于是,我们的 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 的统计规则是基于概率完成的,所以它给出的统计结果是有一定误差的,标准误算率是 0.81%。这也就意味着,你使用HyperLogLog 统计的 UV 是 100 万,但实际的 UV 可能是 101 万。虽然误差率不算大,但是,如果你需要精确统计结果的话,最好还是继续用 Set 或 Hash 类型。
GEO
我们还是以一个场景为例,来看看 GEO 是个什么东东。
在生活中,我们肯定用过打车软件,或摇一摇等,对于这些场景,我们肯定是需要知道自己的地理位置的,用专业点的话说就是这些功能都是基于位置信息服务(Location-Based Service,LBS)的应用。LBS 应用访问的数据是和人或物关联的一组经纬度信息,而且要能查询相邻的经纬度范围,GEO 就非常适合应用在LBS 服务的场景中,我们来看一下它的底层结构。
GEO 类型的底层数据结构是用 Sorted Set 来实现的,我们以上面叫车的例子来看:
用 Sorted Set 来保存车辆的经纬度信息时,Sorted Set 的元素是车辆 ID,元素的权重分数是经纬度信息,如下图所示:
这时问题来了,Sorted Set 元素的权重分数是一个浮点数(float 类型),而一组经纬度包含的是经度和纬度两个值,是没法直接保存为一个浮点数的,那具体该怎么进行保存呢?
这就要用到 GEO 类型中的 GeoHash 编码了,关于这个编码感兴趣的同学可以自己去了解,这里就不说了。
下面说说 GEO 类型的使用。
在使用 GEO 类型时,我们经常会用到两个命令,分别是 GEOADD 和 GEORADIUS。
- GEOADD 命令:用于把一组经纬度信息和相对应的一个 ID 记录到 GEO 类型集合中。
- GEORADIUS 命令:会根据输入的经纬度位置,查找以这个经纬度为中心的一定范围内的其他元素。当然,我们可以自己定义这个范围。
我们还是以叫车的例子来看看这两个命令的使用:
假设车辆 ID 是 33,经纬度位置是(116.034579,39.030452),我们可以用一个 GEO 集合保存所有车辆的经纬度,集合 key 是 cars:locations。执行下面的这个命令,就可以把 ID 号为 33 的车辆的当前经纬度位置存入 GEO 集合中:
GEOADD cars:locations 116.034579 39.030452 33
当用户想要寻找自己附近的网约车时,LBS 应用就可以使用 GEORADIUS 命令。
例如,LBS 应用执行下面的命令时,Redis 会根据输入的用户的经纬度信息(116.054579,39.030452 ),查找以这个经纬度为中心的 5 公里内的车辆信息,并返回给 LBS 应用。当然, 你可以修改“5”这个参数,来返回更大或更小范围内的车辆信息。
GEORADIUS cars:locations 116.054579 39.030452 5 km ASC COUNT 10
另外,我们还可以进一步限定返回的车辆信息。比如,我们可以使用 ASC 选项,让返回的车辆信息按照距离这个中心位置从近到远的方式来排序,以方便选择最近的车辆;还可以使用 COUNT 选项,指定返回的车辆信息的数量。毕竟,5 公里范围内的车辆可能有很多,如果返回全部信息,会占用比较多的数据带宽,这个选项可以帮助控制返回的数据量,节省带宽。
可以看到,使用 GEO 数据类型可以非常轻松地操作经纬度这种信息。
好啦,学习的时光过得总是飞快,相信你又学到了不少可以糊弄面试官的东西了。
巨人的肩膀:
极客时间 Redis 核心原理与实战