文章目录
  1. 1. GeoHash算法
  2. 2. redis Geo模块应用
  3. 3. 问题及建议
  4. 4. 总结

redis3.2版本里面新增的一个功能就是对GEO(地理位置)的支持。意味着可以用redis来实现查找附件的人的等搜索功能了;

地图元素的位置数据使用二维的经纬度表示,经度范围 (-180, 180],纬度范围 (-90, 90],纬度正负以赤道为界,北正南负,经度正负以本初子午线 (英国格林尼治天文台) 为界,东正西负。

当两个元素的距离不是很远时,可以直接使用勾股定理就能算得元素之间的距离。我们平时使用的「附近的人」的功能,元素距离都不是很大,勾股定理算距离足矣。不过需要注意的是,经纬度坐标的密度不一样 (地球是一个椭圆),勾股定律计算平方差时之后再求和时,需要按一定的系数比加权求和,如果不求精确的话,也可以不必加权。

问题:经度总共360度,维度总共只有180度,为什么距离密度不是2:1?

现在,如果要计算「附近的人」,也就是给定一个元素的坐标,然后计算这个坐标附近的其它元素,按照距离进行排序,该如何下手?

1
假设待计算坐标为(x,y),以这个坐标为圆点, r为半径,进行搜索其附近的元素

如果现在元素的经纬度坐标使用关系数据库 (元素id, 经度x, 纬度y) 存储,你该如何计算?

首先,你不可能通过遍历来计算所有的元素和目标元素的距离然后再进行排序,这个计算量太大了,性能指标肯定无法满足。一般的方法都是通过矩形区域来限定元素的数量,然后对区域内的元素进行全量距离计算再排序。这样可以明显减少计算量。如何划分矩形区域呢? 可以指定一个半径r,使用一条SQL就可以圈出来。当用户对筛出来的结果不满意,那就扩大半径继续筛选。

1
select id from positions where x0-r < x < x0+r and y0-r < y < y0+r

为了满足高性能的矩形区域算法,数据表需要在经纬度坐标加上双向复合索引(x, y),这样可以最大优化查询性能。

但是数据库查询性能毕竟有限,如果「附近的人」查询请求非常多,在高并发场合,这可能并不是一个很好的方案。

GeoHash算法

业界比较通用的地理位置距离排序算法是GeoHash算法,redis也使用GeoHash算法。GeoHash算法将二维的经纬度数据映射到一维的整数,这样所有的元素都将在挂载到一条线上,距离靠近的二维坐标映射到一维后的点之间距离也会很接近。当我们想要计算「附近的人时」,首先将目标位置映射到这条线上,然后在这个一维的线上获取附近的点就行了。

那这个映射算法具体是怎样的呢? 它将整个地球看成一个二维平面,然后划分成了一系列正方形的方格,就好比围棋棋盘。所有的地图元素坐标都将放置于唯一的方格中。方格越小,坐标越精确。然后对这些方格进行整数编码,越是靠近的方格编码越是接近。那如何编码呢?一个最简单的方案就是切蛋糕法。设想一个正方形的蛋糕摆在你面前,二刀下去均分分成四块小正方形,这四个小正方形可以分别标记为 00,01,10,11 四个二进制整数。然后对每一个小正方形继续用二刀法切割一下,这时每个小小正方形就可以使用 4bit 的二进制整数予以表示。然后继续切下去,正方形就会越来越小,二进制整数也会越来越长,精确度就会越来越高。

redis Geo模块应用

Geo地理模块到目前为止提供了6条命令:

序号 命令 备注
1 geoadd 将指定的地理空间位置(纬度, 经度, 名称)添加到指定的key中
2 geodist 返回两个给定位置之间的距离
3 geohash 返回一个或多个位置元素的Geohash表示
4 geopos 从key里返回所有给定位置元素的位置(经度和纬度)
5 georadius 以给定的经纬度为中心, 返回键包含的位置元素当中, 与中心的距离不超过给定最大距离的所有位置元素
6 georadiusbymember 查找给定元素给定范围内的元素值

geoadd

命令: GEOADD key longitude latitude member [longitude latitude member ...]
命令描述: 将指定的地理空间位置(纬度, 经度, 名称)添加到指定的key中;
返回值: 添加到sorted set元素的数目, 但不包括已更新score的元素;

1
2
3
4
5
127.0.0.1:6379> geoadd location 116.111 39.111 position.one
(integer) 1
127.0.0.1:6379> geoadd location 116.333 39.333 position.two 116.555 39.556 position.three
(integer) 2
127.0.0.1:6379>

geodist

命令: GEODIST key member1 member2 [unit]
命令描述: 返回两个给定位置之间的距离。如果两个位置之间的其中一个不存在, 那么命令返回空值。指定单位的参数 unit 必须是以下单位的其中一个:

m 表示单位为米;
km 表示单位为千米;
mi 表示单位为英里;
ft 表示单位为英尺;

1
2
3
4
5
6
127.0.0.1:6379> geodist location position.one position.three m
"62520.6181"
127.0.0.1:6379> geodist location position.one position.three km
"62.5206"
127.0.0.1:6379> geodist location position.one position.five m
(nil)

geohash

命令: GEOHASH key member [member ...]
命令描述: 返回一个或多个位置元素的Geohash表示。通常使用表示位置的元素使用不同的技术, 使用Geohash位置52点整数编码。由于编码和解码过程中所使用的初始最小和最大坐标不同, 编码的编码也不同于标准。此命令返回一个标准的Geohash
返回值: 一个数组, 数组的每个项都是一个Geohash 。 命令返回的Geohash的位置与用户给定的位置元素的位置一一对应。

1
2
3
4
5
127.0.0.1:6379> geohash location position.one position.two position.three position.five
1) "wwfw6pvqn60"
2) "wwfxz0r5760"
3) "wx4ch2by2k0"
4) (nil)

geopos

命令: GEOPOS key member [member ...]
命令描述: 从key里返回所有给定位置元素的位置(经度和纬度);
返回值: GEOPOS命令返回一个数组, 数组中的每个项都由两个元素组成: 第一个元素为给定位置元素的经度, 而第二个元素则为给定位置元素的纬度。当给定的位置元素不存在时, 对应的数组项为空值。

1
2
3
4
5
6
7
127.0.0.1:6379> geopos location position.one position.five position.three
1) 1) "116.11100167036056519"
2) "39.11099969335537452"
2) (nil)
3) 1) "116.55499845743179321"
2) "39.55600040953122942"
127.0.0.1:6379>

georadius

命令: GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count]
命令描述: 以给定的经纬度为中心, 返回键包含的位置元素当中, 与中心的距离不超过给定最大距离的所有位置元素, 范围可以使用以下其中一个单位:

m 表示单位为米;
km 表示单位为千米;
mi 表示单位为英里;
ft 表示单位为英尺;

在给定以下可选项时, 命令会返回额外的信息:

WITHDIST: 在返回位置元素的同时, 将位置元素与中心之间的距离也一并返回。 距离的单位和用户给定的范围单位保持一致。
WITHCOORD: 将位置元素的经度和维度也一并返回。
WITHHASH: 以52位有符号整数的形式, 返回位置元素经过原始Geohash编码的有序集合分值。这个选项主要用于底层应用或者调试, 实际中的作用并不大。

命令默认返回未排序的位置元素。通过以下两个参数, 用户可以指定被返回位置元素的排序方式:

ASC: 根据中心的位置, 按照从近到远的方式返回位置元素;
DESC: 根据中心的位置, 按照从远到近的方式返回位置元素;

在默认情况下, GEORADIUS命令会返回所有匹配的位置元素;
虽然用户可以使用COUNT <count>选项去获取前N 个匹配元素, 但是因为命令在内部可能会需要对所有被匹配的元素进行处理所以在对一个非常大的区域进行搜索时, 即使只使用COUNT选项去获取少量元素, 命令的执行速度也可能会非常慢。 但是从另一方面来说, 使用COUNT选项去减少需要返回的元素数量 对于减少带宽来说仍然是非常有用的。

返回值:
在没有给定任何WITH选项的情况下, 命令只会返回一个像["New York", "Milan","Paris"] 这样的线性(linear)列表。 在指定了WITHCOORD,WITHDIST,WITHHASH`等选项的情况下, 命令返回一个二层嵌套数组, 内层的每个子数组就表示一个元素。
在返回嵌套数组时, 子数组的第一个元素总是位置元素的名字。 至于额外的信息, 则会作为子数组的后续元素, 按照以下顺序被返回:

1.以浮点数格式返回的中心与位置元素之间的距离, 单位与用户指定范围时的单位一致。
2.Geohash整数。
3.由两个元素组成的坐标, 分别为经度和纬度。

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
127.0.0.1:6379> georadius location 116.111 39.111 50 km withcoord withdist withhash
1) 1) "position.one"
2) "0.0001"
3) (integer) 4069074382584591
4) 1) "116.11100167036056519"
2) "39.11099969335537452"
2) 1) "position.two"
2) "31.2350"
3) (integer) 4069124900607885
4) 1) "116.33299738168716431"
2) "39.33300071137491472"
127.0.0.1:6379> georadius location 116.111 39.111 50 km withcoord withdist withhash count 1
1) 1) "position.one"
2) "0.0001"
3) (integer) 4069074382584591
4) 1) "116.11100167036056519"
2) "39.11099969335537452"
127.0.0.1:6379> georadius location 116.111 39.111 50 km withcoord withdist withhash count 2 asc
1) 1) "position.one"
2) "0.0001"
3) (integer) 4069074382584591
4) 1) "116.11100167036056519"
2) "39.11099969335537452"
2) 1) "position.two"
2) "31.2350"
3) (integer) 4069124900607885
4) 1) "116.33299738168716431"
2) "39.33300071137491472"
127.0.0.1:6379>

georadiusbymember

命令: GEORADIUSBYMEMBER key member radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count]
命令描述: 这个命令和GEORADIUS命令一样, 都可以找出位于指定范围内的元素, 但是GEORADIUSBYMEMBER的中心点是由给定的位置元素决定的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 表示对key `location` 以`position.one`为原点, 100km为半径, 帅选出最多5个position 并且对结果进行正向(由近到远)排序
127.0.0.1:6379> georadiusbymember location position.one 100 km count 5 asc
1) "position.one"
2) "position.two"
3) "position.three"
127.0.0.1:6379> georadiusbymember location position.one 100 km count 5 asc withcoord withdist withhash
1) 1) "position.one"
2) "0.0000"
3) (integer) 4069074382584591
4) 1) "116.11100167036056519"
2) "39.11099969335537452"
2) 1) "position.two"
2) "31.2349"
3) (integer) 4069124900607885
4) 1) "116.33299738168716431"
2) "39.33300071137491472"
3) 1) "position.three"
2) "62.5206"
3) (integer) 4069148683788633
4) 1) "116.55499845743179321"
2) "39.55600040953122942"
127.0.0.1:6379>
问题及建议

在一个地图应用中,车的数据、餐馆的数据、人的数据可能会有百万千万条,如果使用redisGeo数据结构,它们将全部放在一个zset 集合中。在redis的集群环境中,集合可能会从一个节点迁移到另一个节点,如果单个key的数据过大,会对集群的迁移工作造成较大的影响,在集群环境中单个key对应的数据量不宜超过1M,否则会导致集群迁移出现卡顿现象,影响线上服务的正常运行。

所以,这里建议Geo的数据使用单独的redis实例部署,不使用集群环境。

如果数据量过亿甚至更大,就需要对Geo数据进行拆分,按国家拆分、按省拆分,按市拆分,在人口特大城市甚至可以按区拆分。这样就可以显著降低单个zset集合的大小。

总结
  • 从地图中元素的二维表示入手,分析引入在redis3.2中提供的Geo地理模块,并对Geo地理模块提供的基础命令原理及使用进行了阐述说明,并进一步通过示例作出了说明;
  • Geo地理模块对于计算地图中元素位置及查找元素非常方便;
  • 但值得注意的时,有关使用经验表明当redis集群中单个key数据量比较大如超出1M时,建议按照业务特性进行拆分,分流到多个redis实例中去,以免在进行迁移时影响运营服务;

此外,可进一步参考


[1] 空间索引 - 各数据库空间索引使用报告
[2] 空间索引 - GeoHash算法及其实现优化

作者署名:朴实的一线攻城狮
本文标题:redis专题09 geo地理位置模块
本文出处:http://researchlab.github.io/2018/10/06/redis-09-geohash/
版权声明:本文由Lee Hong创作和发表,采用署名(BY)-非商业性使用(NC)-相同方式共享(SA)国际许可协议进行许可,转载请注明作者及出处, 否则保留追究法律责任的权利。

@一线攻城狮

关注微信公众号 @一线攻城狮

总访问:
总访客: