在之前的博客中,我们已经学习了 Redis 中的五种基础数据结构和六种底层数据结构。随着 Redis 在江湖上的名气越来越大,又有不少的英雄豪杰加入了 Redis 这一大帮派。

这个家族又迎来了三位新的成员:Bitmaps、HyperLogLog 和 Geo,三者各怀绝技,进一步巩固了Redis在数据江湖中的地位。

接下来就让我们来看一下这三位的实力把。

Bitmaps :位操作的巧匠

根据官网介绍:

Bitmaps are not an actual data type, but a set of bit-oriented operations defined on the String type which is treated like a bit vector. Since strings are binary safe blobs and their maximum length is 512 MB, they are suitable to set up to 2^32 different bits.

Bitmap 不是 Redis 中的实际数据类型,而是在 String 类型上定义的一组面向位的操作,将其视为位向量。由于字符串是二进制安全的块,且最大长度为 512 MB,它们适合用于设置最多 2^32 个不同的位。

Bitmap 即位图数据结构,都是操作二进制位来进行记录,只有0 和 1 两个状态。

用来解决什么问题?

思考一个问题,在一个场景下,我们需要存储大规模的布尔数据类型,比如用户的签到信息、用户是否在线、某个时间段用户是否观看了某个视频等等。使用传统的数据结构可能会消耗大量的内存。

那要怎么实现我们想要的功能?

使用 Bitmaps 。bitmap 最大的优势之一是存储信息时,它经常可以极大的节省空间。例如,一个用户的系统中,使用递增的 id 来表示不同的用户,这时候 bitmap 使用 512MB 内存就可以记录 40 亿用户的一个比特信息(例如,1是男生,0是女生,一个男生的id为19,那么这个 bitmap 的第 19 位就是 1)。

介绍

Bitmap 存储的是连续的二进制数字(0 和 1),通过 Bitmap, 只需要一个 bit 位来表示某个元素对应的值或者状态,key 就是对应元素本身 。我们知道 8 个 bit 可以组成一个 byte,所以 Bitmap 本身会极大的节省储存空间。

你可以将 Bitmap 看作是一个存储二进制数字(0 和 1)的数组,数组中每个元素的下标叫做 offset(偏移量)。

img

常用命令

命令 介绍
SETBIT key offset value 设置指定 offset 位置的值
GETBIT key offset 获取指定 offset 位置的值
BITCOUNT key start end 获取 start 和 end 之前值为 1 的元素个数
BITOP operation destkey key1 key2 … 对一个或多个 Bitmap 进行运算,可用运算符有 AND, OR, XOR 以及 NOT

使用

使用bitmap 来记录 周一到周日的打卡! 周一:1 周二:0 周三:0 周四:1 ……

1
2
3
4
5
6
7
8
9
10
11
12
13
14
127.0.0.1:6379> setbit sign 0 1
(integer) 0
127.0.0.1:6379> setbit sign 1 1
(integer) 0
127.0.0.1:6379> setbit sign 2 0
(integer) 0
127.0.0.1:6379> setbit sign 3 1
(integer) 0
127.0.0.1:6379> setbit sign 4 0
(integer) 0
127.0.0.1:6379> setbit sign 5 0
(integer) 0
127.0.0.1:6379> setbit sign 6 1
(integer) 0

查看某一天是否有打卡!

1
2
3
4
127.0.0.1:6379> getbit sign 3
(integer) 1
127.0.0.1:6379> getbit sign 5
(integer) 0

统计操作,统计 打卡的天数!

1
2
127.0.0.1:6379> bitcount sign # 统计这周的打卡记录,就可以看到是否有全勤!
(integer) 3

应用场景

需要保存状态信息(0/1 即可表示)的场景

  • 举例:用户签到情况、活跃用户情况、用户行为统计(比如是否点赞过某个视频)。
  • 相关命令:SETBITGETBITBITCOUNTBITOP

HyperLogLog:基数估计的神算

为什么要使用 HyperLogLog

在我们实际开发的过程中,可能会遇到这样一个问题,当我们需要统计一个大型网站的独立访问次数时,该用什么的类型来统计?

如果我们使用 Redis 中的集合来统计,当它每天有数千万级别的访问时,将会是一个巨大的问题。因为这些访问量不能被清空,我们运营人员可能会随时查看这些信息,那么随着时间的推移,这些统计数据所占用的空间会越来越大,逐渐超出我们能承载最大空间。

例如,我们用 IP 来作为独立访问的判断依据,那么我们就要把每个独立 IP 进行存储,以 IP4 来计算,IP4 最多需要 15 个字节来存储信息,例如:110.110.110.110。当有一千万个独立 IP 时,所占用的空间就是 15 bit*10000000 约定于 143MB,但这只是一个页面的统计信息,假如我们有 1 万个这样的页面,那我们就需要 1T 以上的空间来存储这些数据,而且随着 IP6 的普及,这个存储数字会越来越大,那我们就不能用集合的方式来存储了,这个时候我们需要开发新的数据类型 HyperLogLog 来做这件事了。

介绍

HyperLogLog(下文简称为 HLL)是 Redis 2.8.9 版本添加的数据结构,它用于高性能的基数(去重)统计功能,它的缺点就是存在极低的误差率。

HLL 具有以下几个特点:

  • 能够使用极少的内存来统计巨量的数据,它只需要 12K 空间就能统计 2^64 的数据;
  • 统计存在一定的误差,误差率整体较低,标准误差为 0.81%;
  • 误差可以被设置辅助计算因子进行降低。

Redis 对 HyperLogLog 的存储结构做了优化,采用两种方式计数:

  • 稀疏矩阵:计数较少的时候,占用空间很小。
  • 稠密矩阵:计数达到某个阈值的时候,占用 12k 的空间。

基本使用

HLL 的命令只有 3 个,但都非常的实用,下面分别来看。

添加元素

1
2
3
4
127.0.0.1:6379> pfadd key "redis"
(integer) 1
127.0.0.1:6379> pfadd key "java" "sql"
(integer) 1

相关语法:

1
pfadd key element [element ...]

此命令支持添加一个或多个元素至 HLL 结构中。

统计不重复的元素

1
2
3
4
5
6
7
8
127.0.0.1:6379> pfadd key "redis"
(integer) 1
127.0.0.1:6379> pfadd key "sql"
(integer) 1
127.0.0.1:6379> pfadd key "redis"
(integer) 0
127.0.0.1:6379> pfcount key
(integer) 2

从 pfcount 的结果可以看出,在 HLL 结构中键值为 key 的元素,有 2 个不重复的值:redis 和 sql,可以看出结果还是挺准的。

相关语法:

1
pfcount key [key ...]

此命令支持统计一个或多个 HLL 结构。

合并一个或多个 HLL 至新结构

新增 k 和 k2 合并至新结构 k3 中,代码如下:

1
2
3
4
5
6
7
8
127.0.0.1:6379> pfadd k "java" "sql"
(integer) 1
127.0.0.1:6379> pfadd k2 "redis" "sql"
(integer) 1
127.0.0.1:6379> pfmerge k3 k k2
OK
127.0.0.1:6379> pfcount k3
(integer) 3

相关语法:

1
pfmerge destkey sourcekey [sourcekey ...]

pfmerge 使用场景

当我们需要合并两个或多个同类页面的访问数据时,我们可以使用 pfmerge 来操作。

代码实战

接下来我们使用 Java 代码来实现 HLL 的三个基础功能,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import redis.clients.jedis.Jedis;

public class HyperLogLogExample {
public static void main(String[] args) {
Jedis jedis = new Jedis("127.0.0.1", 6379);
// 添加元素
jedis.pfadd("k", "redis", "sql");
jedis.pfadd("k", "redis");
// 统计元素
long count = jedis.pfcount("k");
// 打印统计元素
System.out.println("k:" + count);
// 合并 HLL
jedis.pfmerge("k2", "k");
// 打印新 HLL
System.out.println("k2:" + jedis.pfcount("k2"));
}
}

以上代码执行结果如下:

1
2
k:2
k2:2

算法原理

HyperLogLog 算法来源于论文 HyperLogLog the analysis of a near-optimal cardinality estimation algorithm,想要了解 HLL 的原理,先要从伯努利试验说起,伯努利实验说的是抛硬币的事。一次伯努利实验相当于抛硬币,不管抛多少次只要出现一个正面,就称为一次伯努利实验。

我们用 k 来表示每次抛硬币的次数,n 表示第几次抛的硬币,用 k_max 来表示抛硬币的最高次数,最终根据估算发现 n 和 k_max 存在的关系是 n=2^(k_max),但同时我们也发现了另一个问题当试验次数很小的时候,这种估算方法的误差会很大,例如我们进行以下 3 次实验:

  • 第 1 次试验:抛 3 次出现正面,此时 k=3,n=1;
  • 第 2 次试验:抛 2 次出现正面,此时 k=2,n=2;
  • 第 3 次试验:抛 6 次出现正面,此时 k=6,n=3。

对于这三组实验来说,k_max=6,n=3,但放入估算公式明显 3≠2^6。为了解决这个问题 HLL 引入了分桶算法和调和平均数来使这个算法更接近真实情况。

分桶算法是指把原来的数据平均分为 m 份,在每段中求平均数在乘以 m,以此来消减因偶然性带来的误差,提高预估的准确性,简单来说就是把一份数据分为多份,把一轮计算,分为多轮计算。

而调和平均数指的是使用平均数的优化算法,而非直接使用平均数。

例如小明的月工资是 1000 元,而小王的月工资是 100000 元,如果直接取平均数,那小明的平均工资就变成了 (1000+100000)/2=50500‬ 元,这显然是不准确的,而使用调和平均数算法计算的结果是 2/(1⁄1000+1⁄100000)≈1998 元,显然此算法更符合实际平均数。

所以综合以上情况,在 Redis 中使用 HLL 插入数据,相当于把存储的值经过 hash 之后,再将 hash 值转换为二进制,存入到不同的桶中,这样就可以用很小的空间存储很多的数据,统计时再去相应的位置进行对比很快就能得出结论,这就是 HLL 算法的基本原理,想要更深入的了解算法及其推理过程,可以看去原版的论文。

应用场景

数量量巨大(百万、千万级别以上)的计数场景

  • 举例:热门网站每日/每周/每月访问 ip 数统计、热门帖子 uv 统计、
  • 相关命令:PFADDPFCOUNT

Geo:地理位置的游侠

受过高等教育的我们都知道,我们所处的任何位置都可以用经度和纬度来标识,经度的范围 -180 到 180,纬度的范围为 -90 到 90。纬度以赤道为界,赤道以南为负数,赤道以北为正数;经度以本初子午线(英国格林尼治天文台)为界,东边为正数,西边为负数。

Redis 在 3.2 版本中增加了 GEO 类型用于存储和查询地理位置,GEO 本质上是基于 ZSet 实现的。

关于 GEO 的命令不多,主要包含以下 6 个:

  1. geoadd:添加地理位置
  2. geopos:查询位置信息
  3. geodist:距离统计
  4. georadius:查询某位置内的其他成员信息
  5. geohash:查询位置的哈希值
  6. zrem:删除地理位置

下面我们分别来看这些命令的使用。

使用

添加地理位置

我们先用百度地图提供的经纬度查询工具,地址:

http://api.map.baidu.com/lbsapi/getpoint/index.html

如下图所示:

百度经纬度查询工具.png

找了以下 4 个地点,添加到 Redis 中:

  1. 天安门:116.404269,39.913164
  2. 月坛公园:116.36,39.922461
  3. 北京欢乐谷:116.499705,39.874635
  4. 香山公园:116.193275,39.996348

代码如下:

1
2
3
4
5
6
7
8
127.0.0.1:6379> geoadd site 116.404269 39.913164 tianan
(integer) 1
127.0.0.1:6379> geoadd site 116.36 39.922461 yuetan
(integer) 1
127.0.0.1:6379> geoadd site 116.499705 39.874635 huanle
(integer) 1
127.0.0.1:6379> geoadd site 116.193275 39.996348 xiangshan
(integer) 1

相关语法:

1
geoadd key longitude latitude member [longitude latitude member ...]

重点参数说明如下:

  • longitude 表示经度
  • latitude 表示纬度
  • member 是为此经纬度起的名字

此命令支持一次添加一个或多个位置信息。

查询位置信息

1
2
3
127.0.0.1:6379> geopos site tianan
1) 1) "116.40541702508926392"
2) "39.91316289865137179"

相关语法:

1
geopos key member [member ...]

此命令支持查看一个或多个位置信息。

距离统计

1
2
127.0.0.1:6379> geodist site tianan yuetan km
"3.9153"

可以看出天安门距离月坛公园的直线距离大概是 3.9 km,我们打开地图使用工具测试一下咱们的统计结果是否准确,如下图所示:

天安门到月坛公园距离统计图.png

可以看出 Redis 的统计和使用地图工具统计的距离是完全吻合的。

注意:此命令统计的距离为两个位置的直线距离。

相关语法:

1
geodist key member1 member2 [unit]

unit 参数表示统计单位,它可以设置以下值:

  • m:以米为单位,默认单位;
  • km:以千米为单位;
  • mi:以英里为单位;
  • ft:以英尺为单位。

查询某位置内的其他成员信息

1
2
3
127.0.0.1:6379> georadius site 116.405419 39.913164 5 km
1) "tianan"
2) "yuetan"

此命令的意思是查询天安门(116.405419,39.913164)附近 5 公里范围内的成员列表。

相关语法:

1
georadius key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC]

可选参数说明如下。

1. WITHCOORD

说明:返回满足条件位置的经纬度信息。

示例代码:

1
2
3
4
5
6
7
127.0.0.1:6379> georadius site 116.405419 39.913164 5 km withcoord
1) 1) "tianan"
2) 1) "116.40426903963088989"
2) "39.91316289865137179"
2) 1) "yuetan"
2) 1) "116.36000186204910278"
2) "39.92246025586381819"

2. WITHDIST

说明:返回满足条件位置与查询位置的直线距离。

示例代码:

1
2
3
4
5
127.0.0.1:6379> georadius site 116.405419 39.913164 5 km withdist
1) 1) "tianan"
2) "0.0981"
2) 1) "yuetan"
2) "4.0100"

3. WITHHASH

说明:返回满足条件位置的哈希信息。

示例代码:

1
2
3
4
5
127.0.0.1:6379> georadius site 116.405419 39.913164 5 km withhash
1) 1) "tianan"
2) (integer) 4069885552230465
2) 1) "yuetan"
2) (integer) 4069879797297521

4. COUNT count

说明:指定返回满足条件位置的个数。

例如,指定返回一条满足条件的信息,代码如下:

1
2
127.0.0.1:6379> georadius site 116.405419 39.913164 5 km count 1
1) "tianan"

5. ASC|DESC

说明:从近到远|从远到近排序返回。

示例代码:

1
2
3
4
5
6
127.0.0.1:6379> georadius site 116.405419 39.913164 5 km desc
1) "yuetan"
2) "tianan"
127.0.0.1:6379> georadius site 116.405419 39.913164 5 km asc
1) "tianan"
2) "yuetan"

当然以上这些可选参数也可以一起使用,例如以下代码:

1
2
3
4
5
127.0.0.1:6379> georadius site 116.405419 39.913164 5 km withdist desc
1) 1) "yuetan"
2) "4.0100"
2) 1) "tianan"
2) "0.0981"

5. 查询哈希值

1
2
127.0.0.1:6379> geohash site tianan
1) "wx4g0cgp000"

相关语法:

1
geohash key member [member ...]

此命令支持查询一个或多个地址的哈希值。

6. 删除地理位置

1
2
127.0.0.1:6379> zrem site xiaoming
(integer) 1

相关语法:

1
zrem key member [member ...]

此命令支持删除一个或多个位置信息。

代码实战

下面我们用 Java 代码,来实现查询附近的人,完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import redis.clients.jedis.GeoCoordinate;
import redis.clients.jedis.GeoRadiusResponse;
import redis.clients.jedis.GeoUnit;
import redis.clients.jedis.Jedis;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class GeoHashExample {
public static void main(String[] args) {
Jedis jedis = new Jedis("127.0.0.1", 6379);
Map<String, GeoCoordinate> map = new HashMap<>();
// 添加小明的位置
map.put("xiaoming", new GeoCoordinate(116.404269, 39.913164));
// 添加小红的位置
map.put("xiaohong", new GeoCoordinate(116.36, 39.922461));
// 添加小美的位置
map.put("xiaomei", new GeoCoordinate(116.499705, 39.874635));
// 添加小二
map.put("xiaoer", new GeoCoordinate(116.193275, 39.996348));
jedis.geoadd("person", map);
// 查询小明和小红的直线距离
System.out.println("小明和小红相距:" + jedis.geodist("person", "xiaoming",
"xiaohong", GeoUnit.KM) + " KM");
// 查询小明附近 5 公里的人
List<GeoRadiusResponse> res = jedis.georadiusByMemberReadonly("person", "xiaoming",
5, GeoUnit.KM);
for (int i = 1; i < res.size(); i++) {
System.out.println("小明附近的人:" + res.get(i).getMemberByString());
}
}
}

以上程序执行的结果如下:

1
2
小明和小红相距:3.9153 KM
小明附近的人:xiaohong

应用场景

Redis 中的 GEO 经典使用场景如下:

  1. 查询附近的人、附近的地点等;
  2. 计算相关的距离信息。

总结

以上是 Redis 后续新增的三种数据结构的介绍和基本使用方法。在实际开发中,我们要实现的很多功能其实都要用到这三种数据结构。其实博客项目看似好像是一个烂大街的玩具,但其实如果真的要开发一款能推向市场的博客系统,还是要费很大的功夫的。

越往深处学就会发现它们的应用就在我们身边,这可能就是计算机不好入行的原因吧,刚开始学的全是一些抽象的东西,想要去实现一个我们平常使用的功能基本是不可能的,只有在深入学习各种框架、技术之后才能更好地理解身边的技术实现。

今天写的这几篇博客其实都有一点抽象,主要是因为之前在学 Redis 时,因为刚入门,所以很多东西都要去深入学习,最近几天看到的内容都是一个功能的实现,没有深挖的内容,所以在记录博客时就直接照着别人的总结写了一遍。你说这有用吗,应该比直接看一遍有用。

这两天都在学 Java 的基础知识,感觉和 Go 差的太多了,很麻烦,东西很杂。昨晚想通了一个问题,既然决定直接备战秋招了,时间还有差不多两三个月,其实可以不用那么急躁,囫囵吞枣式的学习只会带来更快的遗忘。

参考

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/Redis%20%e6%a0%b8%e5%bf%83%e5%8e%9f%e7%90%86%e4%b8%8e%e5%ae%9e%e6%88%98/20%20%e6%9f%a5%e8%af%a2%e9%99%84%e8%bf%91%e7%9a%84%e4%ba%ba%e2%80%94%e2%80%94GEO.md

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/Redis%20%e6%a0%b8%e5%bf%83%e5%8e%9f%e7%90%86%e4%b8%8e%e5%ae%9e%e6%88%98/22%20%e4%bc%98%e7%a7%80%e7%9a%84%e5%9f%ba%e6%95%b0%e7%bb%9f%e8%ae%a1%e7%ae%97%e6%b3%95%e2%80%94%e2%80%94HyperLogLog.md

https://javaguide.cn/database/redis/redis-data-structures-02.html

https://pdai.tech/md/db/nosql-redis/db-redis-data-type-special.html