楔子
查询附近的人或者商家是一个非常常用并且实用的功能,比如:我们经常使用高德地图、百度地图或者其它地图,去查询想去的目的地在什么位置,并且还会显示距离。
如果去的地方有多个,比如我们想去招商银行,但是附近有多个招商银行,那么地图会显示附近的所有银行,并默认按照距离进行排序,然后我们可以选择距离最近的一个。
我们以在美团上搜索自助餐为例:
我们看到美团把商店和离我们当前位置的距离都显示在了上面,当然它没有纯粹地按照距离排序,而是按照距离以及其它指标(比如价格、人气、评分等等)进行的综合排序。当然它怎么排序的我们不关心,我们想要知道的是如何查询附近的人、或者商店呢?
而想实现这些功能,离不开基于位置信息服务(Location-Based Service,LBS)的应用。LBS 应用访问的数据是和人或物关联的一组经纬度信息,而且要能查询相邻的经纬度范围。为此 Redis 在 3.2 版本中,提供了一个新的类型:GEO,用于存储和查询地理位置信息(也就是经纬度),它非常适合应用在 LBS 服务的场景中。
但在学习它的底层结构和用法之前,我们必须要了解一下关于经纬度方面的地理知识,否则下面的内容学起来会很费劲。
经纬度
我们知道地球上的任何一个位置都可以使用经度和纬度来标识:纬度的范围是 -90 度到 90 度,经度的范围是 -180 度到 180 度。纬度以赤道为界,赤道以北,纬度为正数;赤道以南,纬度为负数。经度以本初子午线(英国格林尼治天文台)为界,东边为正数,西边为负数。
首先纬线和经线都有无数条,假定地球是一个规则的球体,那么我们看到所有的纬线都是一个和赤道平行的圆,并且它们还是一个同心圆。当然赤道也是纬线、也是一个圆,并且它是最长的一条纬线,只是为了便于标记,把这条最长的纬线称之为赤道。并且纬线越靠近南北两极,其周长就越小,直到缩短为 0。
而经线显然长度都是一样的,将南极和北极两点相连得到就是经线,经线就是球上的一个半圆。而本初子午线显然也属于经线,它的长度和其它经线也是一样的,并且如果将经线对应的半圆补齐,那么所有经线对应的圆都和赤道垂直,并且它们的圆心都重合。
总结一下纬线和经线
1)指示方向:纬线指示东西方向(横向),经线指示南北方向(纵向)。
2)是否等长:纬线是不等长的,赤道最长,越靠近两极,长度越短,直到为 0;而所有经线的长度是都是相等的。纬线可以看做是地球上的某一点随地球自转形成的轨迹,所有的纬线都相互平行,并与经线垂直。
3)纬线是一个完整的圆,但经线是一个半圆,如果将这个半圆补齐,那么这个圆上的两条经线的度数相加正好等于180。比如:两条经线组成了一个和赤道垂直的一个圆,其中一条经线的度数是西经 20 度,那么另一条经线的度数就是东经 160 度。
另外从这里我们也能看出为什么纬度是 -90 到 90,而经度是 -180 到 180。
首先绕地球一圈相当于转了 360 度,而经线是个半圆,想象本初子午线绕着地球旋转,它必须要完整地转一圈才可以回到原来的位置,所以是 360,根据方向分为 -180 到 180。
而纬线的话,想象一下赤道从北极跑到南极,所以是 180,因为纬线本身就是个圆。或者我们只看纬线的一半的话,那么相当于它只围绕着地球转了半圈,因此根据方向的话,范围是 -90 到 90。
南、北半球
赤道以北称为北半球(赤道上方,纬度为正),赤道以南称为南半球(赤道下方,纬度为负),赤道的纬度为 0。所以一般纬度为负数的话,比如 -78,我们会说南纬 78 度;纬度为正的话,比如 87,我们会说北纬 87 度。
本初子午线的东边经度为正,西边为负。所以经度为负数的话,比如 -32,我们会说西经 32 度;经度为正数的话,比如 54,我们会说东经 54 度。
但东半球和西半球并不是以本初子午线为准,而是以西经 20 度、东经 160 度为分界线。西经 20 度以东、东经 160 度以西的部分是东半球;西经 20 度以西、东经 160 度以东的部分是西半球。
不好理解的话,想象一下上北下南左西右东,而且地球是自西向东转的,也就是按照逆时针转动的。西经 20 度的经线逆时针转动(向东)、东经 160 度所在经线顺时针转动(向西),直到重合,然后它们扫过的部分就是东半球;西半球同理,就是西经 20 度所在经线顺时针转动、东经 160 度所在经线逆时针转动,扫过的部分。
有了纬度和经度,我们就能表示地球上的任何一个位置,并且还可以计算两个位置之间的距离。
GEO 类型以及相关操作
所以我们只需要查询出附近几个点和自己的距离,再进行排序就可以实现查询附近商家的功能了。然而使用 Redis 让这一切更简单了,Redis 为我们提供了专门用来存储地理位置(经纬度)的类型 GEO,我们使用它以及它内置的方法就可以轻松地实现查询附近的人或商家的功能。
GEO类型是专门用来存储地理位置信息的,不过关于 GEO 的命令不多,主要包含以下 6 个:
- geoadd:添加地理位置;
- geopos:查询地理位置;
- geodist:距离统计;
- georadius:查询某位置内的其它成员;
- geohash:查询地理位置的编码值;
- zrem:删除地理位置;
我们来测试一下。
添加地理位置
命令:geoadd key 经度1 纬度1 地点1 经度2 纬度2 地点2 ···,一次性可以添加任意多个。
我们先用百度地图提供的经纬度查询工具,随便找4个点吧。
- 天安门:116.404269,39.913164
- 月坛公园:116.36,39.922461
- 北京欢乐谷:116.499705,39.874635
- 香山公园:116.193275,39.996348
然后进行添加,代码如下:
> geoadd position 116.404269 39.913164 T_A_M (integer) 1 > geoadd position 116.36 39.922461 Y_T_G_Y (integer) 1 > geoadd position 116.499705 39.874635 B_J_H_L_G (integer) 1 > geoadd position 116.193275 39.996348 X_S_G_Y (integer) 1
以上就添加完毕了,当然为了看起来清晰,我们这里是写了 4 条命令。其实一条就可以了,因为 geoadd 支持一次添加多个地理位置信息。
查询地理位置
添加的时候使用命令:geoadd key 经度 维度 地点;查询的时候使用命令:geopos key 地点,即可返回经纬度。并且正如添加的时候,可以一次性添加多个,查询的时候,也可以一次性查询多个。
> geopos position T_A_M X_S_G_Y 1) 1) "116.40426903963088989" 2) "39.91316289865137179" 2) 1) "116.19327574968338013" 2) "39.99634737765855874"
基于 key 和 地点即可查询对应的经纬度,如果地点没有设置,那么会返回 nil,表示没有该地点的经纬度信息。
距离统计
如何计算两个位置之间的距离呢?使用命令:geodist key 位置1 位置2 km 即可算出两个位置之间距离多少千米,结尾的 km 表示单位,这里选择千米。除了 km 之外,还有 m 表示米、mi 表示英里、ft 表示英尺,一般我们使用 km 和 m 就可以了。
> geodist position T_A_M X_S_G_Y km "20.2293"
这里 Redis 告诉我们天安门和香山公园之间的距离为 20.2293 千米,有兴趣的话可以自己用地图测试一下看看准不准,当然最好使用经纬度进行定位。
注意:此命令统计的距离为两个位置的直线距离。
查询某位置内的其它成员信息
重点来了,我们还可以查询某个地点附近的成员。
我们实际演示一下:
# 返回 position 中距离 (116.405419 39.913164) # 不超过 5km 的成员,当然成员都是 position 中的已有成员 > georadius position 116.405419 39.913164 5 km withcoord 1) 1) "T_A_M" 2) 1) "116.40426903963088989" 2) "39.91316289865137179" 2) 1) "Y_T_G_Y" 2) 1) "116.36000186204910278" 2) "39.92246025586381819"
这里的返回内容我们指定的是 withcoord,表示返回的是符合条件的成员的经纬度。
> georadius position 116.405419 39.913164 5 km withdist 1) 1) "T_A_M" 2) "0.0981" 2) 1) "Y_T_G_Y" 2) "4.0100"
返回内容指定为 withdist,表示返回的是符合条件的成员和指定位置之间的直线距离。
> georadius position 116.405419 39.913164 5 km withhash 1) 1) "T_A_M" 2) (integer) 4069885552230465 2) 1) "Y_T_G_Y" 2) (integer) 4069879797297521
返回内容我们指定的是 withhash,表示返回的是符合条件的成员的编码值。
然后我们再看看限制返回数量。
# 设置半径为 50km,显然全部返回了 > georadius position 116.405419 39.913164 50 km withdist 1) 1) "Y_T_G_Y" 2) "4.0100" 2) 1) "X_S_G_Y" 2) "20.3165" 3) 1) "T_A_M" 2) "0.0981" 4) 1) "B_J_H_L_G" 2) "9.1163" # 指定返回的数量 > georadius position 116.405419 39.913164 50 km withdist count 1 1) 1) "T_A_M" 2) "0.0981"
最后是排序
> georadius position 116.405419 39.913164 50 km withdist 1) 1) "Y_T_G_Y" 2) "4.0100" 2) 1) "X_S_G_Y" 2) "20.3165" 3) 1) "T_A_M" 2) "0.0981" 4) 1) "B_J_H_L_G" 2) "9.1163" # 升序排 > georadius position 116.405419 39.913164 50 km withdist asc 1) 1) "T_A_M" 2) "0.0981" 2) 1) "Y_T_G_Y" 2) "4.0100" 3) 1) "B_J_H_L_G" 2) "9.1163" 4) 1) "X_S_G_Y" 2) "20.3165" # 降序排 > georadius position 116.405419 39.913164 50 km withdist desc 1) 1) "X_S_G_Y" 2) "20.3165" 2) 1) "B_J_H_L_G" 2) "9.1163" 3) 1) "Y_T_G_Y" 2) "4.0100" 4) 1) "T_A_M" 2) "0.0981"
查询地理位置的编码值
> geohash position T_A_M X_S_G_Y 1) "wx4g0cgp000" 2) "wx4es117ee0"
可以同时查询多个地理位置(会额外做一个 Base32 编码)。
删除地理位置
命令:zrem key 地点1 地点2 ···
> geohash position Y_T_G_Y 1) "wx4epgdv0t0" > geopos position Y_T_G_Y 1) 1) "116.36000186204910278" 2) "39.92246025586381819" # 删除 position 里的 Y_T_G_Y 成员 > zrem position Y_T_G_Y (integer) 1 # 再次查询,返回 nil > geohash position Y_T_G_Y 1) (nil) > geopos position Y_T_G_Y 1) (nil)
以上就是 GEO 的一些操作命令,不管什么类型,至少在使用层面是不难的。
GEO 的底层结构
每个数据类型的底层结构,一定是基于存储的数据的特点设计的。我们以滴滴打车为例,分析一下 LBS 服务中经纬度的存取特点。
- 每一辆网约车都有自己的唯一编号,网约车需要将自己所在地的经纬度信息上报给叫车应用。
- 用户在叫车的时候,叫车应用会根据用户所在地的经纬度,查找附近的车辆并进行匹配。
- 一旦匹配成功,叫车应用就会根据车辆的编号,获取车辆的信息并返回给用户。
可以看到,一辆车(或一个用户)一定处在地球上的某个位置,所以会对应一组经纬度,并且随着车(用户)的移动,相应的经纬度也在不断变化。因此服务要能根据车 ID(用户 ID)快速定位到车(用户)此刻所处的位置,也就是经纬度,并且当位置发生改变后,也要能快速地对经纬度信息进行更新。
比如我们平时使用的导航也是如此,像高德地图 APP。当我们输入目的地的时候,APP 怎么知道我们距离目的地有多远,并且移动的过程中 APP 上显示的距离也在不断变化,当我们走反了它还能提醒我们。
其实原因很简单,APP 会不断地通过手机的 GPS 定位系统,将我们所处位置的经纬度信息上报给高德的后台服务,服务计算出我们和目的地之间的距离,然后返回给 APP,APP 再进行更新。如果通过距离等信息判断出我们走的方向,和已有的方向不一样,那么它还会重新规划路线。
因此 GEO 这种类型至少要满足能够快速地对经纬度进行查找和更新,那么你会想到哪些结构呢?毫无疑问,Hash 和 ZSet。
我们先来看看如果是 Hash 类型会是什么样子:
对于 Hash 来说,快速地对于 value 进行查找和更新是可以实现的,到这里,Hash 类型看起来是一个不错的选择。但问题是,对于一个 LBS 应用来说,除了记录经纬度信息之外,还需要根据用户的经纬度信息在车辆的 Hash 集合中进行范围查询。
然而一旦涉及到范围查询,就意味着集合中的元素需要有序,但 Hash 类型的元素是无序的,显然不能满足我们的要求。
再来看看 ZSet,ZSet 类型也支持一个 key 对应一个 value 的记录模式,其中 key 是 ZSet 中的元素,而 value 则是元素的权重分数。更重要的是,ZSet 可以根据元素的权重分数排序,支持范围查询,这样就能满足 LBS 服务中查找相邻位置的需求了。
实际上,GEO 类型的底层数据结构就是用 ZSet 来实现的,我们还是借着叫车应用的例子来加深下理解。
但这样就产生了一个问题,ZSet 的 score 应该是浮点数才对,这样才能够进行排序,而保存一个经纬度显然是不行的。那该怎么做呢?还记得 geohash 这个命令吗,负责对经纬度进行编码,ZSet 的 score 存放的其实是编码后的结果。
GEOHash 的编码方法
为了能高效地对经纬度进行比较,Redis 采用了业界广泛使用的 GEOHash 编码方法,这个方法的基本原理就是二分区间,区间编码。当我们要对一组经纬度编码,会先对经度和维度分别编码,然后再将这两个编码合并为一个最终编码。
先来看看如何对经度编码,经度位于 -180 到 180,我们可以划分出两个子区间,分别是 -180 到 0 和 0 到 180,这里称它为左区间和右区间。然后就看要编码的经度值是落在左区间还是右区间,落在左区间就用 0 表示,落在右区间就用 1 表示。这样一来,每做完一次二分区,我们就能得到一个编码值。
然后我们对经度值所属的区间按照相同的规则再做一次二分区,看它是落在左区间还是右区间,这样又能得到一个编码值。当做完 N 次的二分区之后,经度值就可以用一个 N bit 的数表示了。
举个例子,假设我们要编码的经度值为 116.37,我们生成 5 位编码值,也就是做 5 次分区,看看是什么样子的。
5 次分区过后,我们就得到经度的编码值 11010。而对纬度的编码方式与之同理,只不过初始范围变成了 -90 到 90,比如对纬度值 39.86 编码会得到 10111。
当经纬度值都编码完成后,我们再将它们组合在一起,组合规则是:最终编码值的偶数位依次是经度的编码值,奇数位依次是纬度的编码值。
比如上面计算出的经度值和纬度值的编码分别是11010和10111,那么合起来就是 1110011101。
使用 GEOHash 编码后,就可以将经纬度转成一个数值来保存,这样就可以作为 ZSet 的 score 了。
然后这里需要说明一下,我们上面做的是 5 次分区,但很明显这是不够精确的,因为经纬度即使变化一点点,距离也会有很大差别。
在纬度相等的情况下,经度每隔 0.00001 度,距离相差约 1 米;在经度相等的情况下,纬度每隔 0.00001 度,距离相差约 1.1 米。
因此在实际应用中,5 次分区是肯定不够的,当然不管多少次,整个过程是没有变化的,这里我们就以 5 次为例。
还有一个问题,我们在使用 geohash 命令查看编码值的时候,发现它打印的是一个字符串啊。不要奇怪,这是因为 Redis 又对编码值做了一个 Base32 编码,准确来说,Base32 编码之后的结果才是真正的编码值。
那么问题来了,这个编码值该怎么用呢?假设我们做了 4 次分区,那么就可以划分出一个 4 * 4 的网格区域。
网格的南北(上下)方向体现的是纬度的变化,往北(上)则纬度的二进制位加 1,往南则减 1;网格的东西(左右)方向体现的是经度的变化,往东(右)则经度的二进制位加 1,往西则减 1。
总之分区次数越多,Base32 编码之后的 GEOHash 编码值越长,网格数量越多,每个网格的宽度和高度越小,位置越精确。
- 编码长度为 1,每个网格宽度为 5000km,每个网格高度为 5000km;
- 编码长度为 2,每个网格宽度为 1250km,每个网格高度为 625km;
- 编码长度为 3,每个网格宽度为 156km,每个网格高度为 156km;
- 编码长度为 4,每个网格宽度为 39.1km,每个网格高度为 19.5km;
- 编码长度为 5,每个网格宽度为 4.89km,每个网格高度为 4.89km;
- 编码长度为 6,每个网格宽度为 1.22km,每个网格高度为 0.61km;
- 编码长度为 7,每个网格宽度为 153m,每个网格高度为 153m;
- 编码长度为 8,每个网格宽度为 38.2m,每个网格高度为 19.1m;
- 编码长度为 9,每个网格宽度为 4.77m,每个网格高度为 4.77m;
- 编码长度为 10,每个网格宽度为 1.19m,每个网格高度为 0.596m;
在计算的时候将二维网格按照二进制从小到大的顺序映射到一维,但由于存在误差(比如 0111 和 1000 相邻、但网格不相邻),所以为了避免查询不准确问题,我们可以同时查询给定经纬度所在的方格周围的 4 个或 8 个方格。
小结
GEO 是 Redis 3.2 版本中新增的功能,只有升级到 3.2+ 才能使用,它可以记录经纬度形式的地理位置信息,被广泛的应用在 LBS 服务中。并且 GEO 没有设计新的底层数据结构,而是直接使用的 ZSet。
GEO 之所以能使用 ZSet 作为底层结构,是因为通过 GEOHash 实现了经纬度到 score 的转变,而实现的关键就在于二维区间的划分以及对区间进行编码。当经纬度落在某个区间后,就用区间的二进制编码值来表示,并将其作为 ZSet 元素的权重分数。
这样一来,我们就可以把经纬度保存到 ZSet 中,利用ZSet 提供的按权重进行有序范围查找的特性,实现 LBS 服务中频繁使用的搜索附近的需求。
本文参考自:
- 极客时间蒋德钧:《Redis 核心技术与实战》
- https://blog.csdn.net/usher_ou/article/details/122716877