前言
前面,介绍了 Redis 的 5 大基本数据类型:String、List、Hash、Set、Sorted Set,它们可以满足绝大多数的数据存储需求,但是在面对海里数据统计时,它们的内存开销很大。所以对于一些特殊的场景,它们是无法支持的。所以,Redis 还提供了 3 种扩展数据类型,分别是 Bitmap、HyperLogLog、GEO。今天再介绍下 GEO。
1.面向 LBS 应用的 GEO 数据类型
日常生活中,“附近停车场”、打车软件的叫车,这些都离不开基于位置信息服务(LBS)的应用。LBS 应用访问的数据是和人或物关联的一组经纬度信息,且要能查询相邻的经纬度范围,Redis 的 GEO 就非常适合应用在 LBS 服务的场景中。
1.1 GEO 底层结构
在设计一个数据类型的底层结构时,首先要知道,要处理的数据有什么访问特点。所以,需要先搞清楚位置信息到底是怎么存取的。
以叫车服务为例,来分析下 LBS 应用中经纬度的存取特点:
- 每辆网约车都有一个编号(如 1001),网约车需要将自己的经纬度信息(如
117.273521
,39.884737
)发给叫车应用。 - 用户在叫车的时候,叫车应用会根据用户的经纬度信息(
117.273521
,39.884740
)查找用户附件的车辆,并进行匹配。 - 等把位置相近的用户和车辆匹配上以后,叫车应用就会根据车辆的编号,获取车辆的信息,并返回给用户。
可以看到,一辆车(或一个用户)对应一组经纬度,并且随着车(或用户)的位置移动,相应的经纬度也会变化。
这种数据属于一个 key (例如车辆 ID) 对应一个 value(一组经纬度)。当有很多车辆信息需要保存时,就需要有一个集合来保存一系列的 key 和 value。Hash 集合类型可以快速存取一系列 key 和 value,正好可以记录一系列车辆 ID 和经纬度的对应关系,如下所示:
此外,Hash 类型的 HSET 操作,可以快速的更新车辆变化的经纬度信息。
目前看来,Hash 类型是一个不错的选择。但是,一个 LBS 应用除了记录经纬度信息外,还需要根据用户经纬度信息在车辆的 Hash 集合中进行范围查找。一旦涉及到范围查询,就意味着集合中的数据是有序的,但 Hash 类型是无需的,不能满足要求。
Sorted Set 类型也支持一个 key 对应一个 value 的记录模式,其中,key 就是 Sorted Set 中的元素,而 value 则是元素的权重分数。此外,Sorted Set 可以根据元素的权重分数排序,支持范围查询。这就能满足 LBS 服务中查找相邻位置的需求了。
而 GEO 类型的底层数据结构就是用 Sorted Set 来实现的。咱们还是接着叫车应用例子,用 Sorted Set 来保存车辆的经纬度信息时,Sorted Set 的元素是车辆 ID,元素的权重分数是经纬度信息,如下图所示:
此时问题是,Sorted Set 元素的权重分数是一个浮点数(float 类型),而一组经纬度包含的精度和纬度两个值,是没法直接保存为一个浮点数。这就要用到 GEO 类型中的 GeoHash 编码了。
1.2 GeoHash 编码方法
Redis 采用了 GeoHash 编码方法,这个方法的基本原理就是“二分区间,区间编码”。当我们要对一组经纬度进行 GeoHash 编码时,要先对经度和纬度分别编码,然后把经纬度各自的编码组合成一个最终编码。
首先,看下经度和纬度的单独编码过程。
- 对于一个地理位置来说,经度范围是 [-180, 180]。
- GeoHash 编码会把一个经度值编码成一个 N 位的二进制值,我们来对经度范围 [-180, 180] 做 N 次的二分区操作,其中 N 可以自定义。
- 在进行第一次区分时,经度范围 [-180, 180] 会被分成两个子区间:[-180, 0) 和 [0, 180]。此时,看一下编码的经度值是落在了左分区还是右分区。 如果是落在做分区,我们就用 0 表示;如果落在右分区,就用 1 表示。这样一来,每做完一次二分区,我们就可以得到 1 位编码值。
- 再对经度值所属的分区,再做一次二分区,同时再次查看经度落在了新二分区的做分区还是右分区,按照刚才的规则再做 1 位编码。当做完 N次的二分区后,经度值就可以用一个 N bit 的数来表示了。
举个例子,假设我们要编码的经度值是 117.273521
,我们用 5 位编码值(也就是 N = 5,做 5 次分区)。
5. 先做第一次二分区操作,把经度区间 [-180, 180] 分成两个子区间:[-180, 0) 和 [0, 180],此时,经度值 117.273521
是属于右分区 [0, 180],所以,我们用 1 表示第一次二分区后的编码值。
6. 再做第二次二分区:把经度值 117.273521
所属分区 [0, 180] 区间,分成 [0, 90) 和 [90, 180]。此时经度值 117.273521
还是属于右分区 [90, 180],编码值仍为 1。
7. 第三次二分区,经度值 117.273521
落在了左分区 [90, 135) 中,所以,第三次分区后的编码值就是 0。
8. 第四次二分区,经度值 117.273521
落在了右分区 [112.5, 135]中,所以第四次编码值就是 1。
9. 第五次二分区,经度值 117.273521
落在了左分区 [112.5, 123.75) 中,所以第五次次编码值就是 0。
最终,做完 5 次分区后,我们把经度值 117.273521
的 GeoHash 编码值为 11010。
对维度的编码方式,和对经度一样,只是经度的范围是 [-90, 90],GeoHash 编码的值为 10111,下标展示了对纬度值 39.884737
的编码过程。
分区次数 | 最小维度值 | 二分区中间值 | 最大维度值 | 维度39.884737 所在区间 | 维度的GeoHash编码 |
---|---|---|---|---|---|
第一次 | -90 | 0 | 90 | [0, 90] | 1 |
第二次 | 0 | 45 | 90 | [0, 45) | 0 |
第三次 | 0 | 22.5 | 45 | [22.5, 45] | 1 |
第四次 | 22.5 | 33.75 | 45 | [33.75, 45] | 1 |
第五次 | 33.75 | 39.375 | 45 | [39.375, 45) | 1 |
我们再把一组经纬度值都编完码后,再把它们组合在一起,组合的规则是:
- 最终编码值的偶数位上依次是经度的编码值
- 奇数位上依次是纬度的编码值
- 其中偶数位从 0 开始,奇数位从 1 开始。
我们把刚刚计算的经纬度(117.273521
, 39.884737
)的各自编码值 11010 和 10111 ,组合之后:
- 第 0 位 :经度的第 0 位 ,为 1
- 第 1 位:纬度度的第 0 位,为 1
- 第 2 位:经度的第 1 位,为 1
- 依次类推,就能得到最终的编码值: 1
1
10
01
11
01
(加粗的为经度编码值,其他是纬度编码值)
用了 GeoHash 编码后,原本无法表示权重的经纬度,就可以用 1110011101
这个值来表示,就可以保存为 Sorted Set 的权重分数了。
其实,使用 GeoHash 编码后,就相当于把整个地理空间划分成一个个放个,每个放个对应了一个 GeoHash 中的一个分区。举个例子。 我们把经度区间 [-180,180] 做一次二分区,把维度区间 [-90,90] 做一次二分区间,就会得到 4 个分区。我们看看经度和纬度范围及对应的 GeoHash 组合编码:
- 分区一:[-180, 0) 和 [-90, 0),编码 00
- 分区二:[-180, 0) 和 [0, 90),编码 01
- 分区三:[0, 180) 和 [-90, 0),编码 10
- 分区三:[0, 180) 和 [0, 90),编码 11
这 4 个分区对应了 4 个方格,每个方格覆盖了一定范围内的经纬度,分区越多,每个方格能覆盖到的地理位置就越小,也就越精准。我们把所有方格的编码值映射到一维空间内,相邻方格的 GeoHash 编码值基本也是接近的,如下所示:
所以,我们使用 Sorted Set 范围查询得到的相近编码值,在实际的地理空间上,也是相邻的方格,这就可以实现 LBS 应用搜索附件人或物的功能了。
不过,需要注意的是,有的编码值虽然在大小上接近,但实际对应的方格确距离比较远。例如,我们用 4 魏来做 GeoHash 编码,把经度区间 [-180,180] 和纬度区间 [-90,90] 分成了 4 个分区,一共 16 个分区。编码值为 0111 和 1000 的两个方格就离的比较远,如下所示:
所以,为了避免查询不准确问题,我们可以同时查询给定经纬度所在的方格周围的 4 个或 8 个方格。
好了,现在我们知道 GEO 类型是把经纬度所在区间编码作为 Sorted Set 中元素的权重分数,把和经纬度相关的车辆 ID 作为 Sorted Set 中元素本身的值保存下来,这样相邻经纬度的查询就可以通过编码值的大小范围来实现了。
1.3 如何操作 GEO 类型
在使用 GEO 类型的时,我们经常会用到两个命令,分别是 GETADD 和 GEORADIUS。
- GETADD:把一组经纬度和相对应的一个 ID 记录到 GEO 类型集合中;
- GEORADIUS:会根据输入的经纬度未知,查找这个纬度为中心的一定范围内的其他元素。
假设车辆 ID 是 1001,经纬度位置是 (117.273521
, 39.884737
),我们可以用一个 GEO 集合保存所有车辆的经纬度,集合 key 是 cars:locations
。执行下面的这个命令,就可以把 ID 号为 1001 的车辆的当前经纬度位置存入 GEO 集合中:
GEOADD cars:locations 117.273521 39.884737 1001
当用户想要查看自己附近的网约车是,LBS 应用就可以使用 GEORADIUS 命令。例如,LBS 应用执行下面的命令时,Redis 会根据输入的用户的经纬度信息( 117.273521
, 39.884740
),查找这个经纬度为中心的 5 公里内的车辆信息,并返回给 LBS 应用。
GEORADIUS cars:locations 117.273521 39.884740 5 km ASC COUNT 10
当然,你可以修改 “5” 这个参数,来返回更大或更小范围内的车辆信息。此外,还可以进一步限定返回的车辆信息。
- 比如我们可以使用 ASC 选项,让返回的车辆按照距离这个中心位置从近到源的方式来排序,以边防选择最近的车辆;
- 还可以使用 COUNT 选项,指定返回的车辆信息的数量。毕竟,5 公里内的车辆可能有很多,如果返回全部信息,会占用比较多的数据带宽,这个选项可以帮助控制返回的数据量,节省带宽。
可以看到,使用 GEO 数据类型可以非常轻松地操作经纬度这种信息。