一文搞定redis-GeoHash空间索引

1,149 阅读10分钟

redis-Geo是什么

Redis 的 GEO 特性在 Redis 3.2 之后推出,这个功能可以将用户给定的地理位置信息储存起来,并对这些信息进行操作,将指定的地理空间项目(纬度,经度,名称)添加到指定的键。数据作为排序集存储到密钥中。

相信大家都用过打车软件、或者使用软件搜索过附近的人、附近的店等等,这种都离不开基于位置服务(Location-Based Service,LBS)的应用。此类应用都是基于经纬度来查询附近的目标,Redis GEO就适用于此类场景。

image.png

geo

geo使用

  • geoadd : 添加(纬度、经度、名称)三元组

  • GEOADD key [ NX | XX] [CH] longitude latitude member [ longitude latitude member ...]

127.0.0.1:6379> geoadd people 116.332548 40.01116 wang
(integer) 1
127.0.0.1:6379> geoadd people 116.3176 39.999001  li
(integer) 1
127.0.0.1:6379> geoadd people 116.354107 39.987891 yang 116.323637 39.964945  zhao
(integer) 2
  • geodist : 计算两个元素之间的距离
    计算集合两个元素之间的距离,单位可以是m、km、ml、ft
    分别代表米、千米、英里和尺

  • GEODIST key member1 member2 [ M | KM | FT | MI]

127.0.0.1:6379> geodist people wang li m
"1857.5019"
127.0.0.1:6379> geodist people wang li km
"1.8575"
127.0.0.1:6379> geodist people wang zhao km
"5.1962"
  • geohash : 获取元素经纬度坐标经过geohash算法生成的base32编码值
    • GEOHASH key member [member ...]
127.0.0.1:6379> geohash people wang
1) "wx4ex5ycbu0"
127.0.0.1:6379> geohash people li
1) "wx4ewceghd0"
127.0.0.1:6379> geohash people c
1) "wx4erxw6r50"

len("wx4erxw6r50") = 11 why? 
  • georadius:根据坐标点查找附近位置的元素

    • GEORADIUS key longitude latitude radius M | KM | FT | MI [WITHCOORD] [WITHDIST] [WITHHASH] [ COUNT count [ANY]] [ ASC | DESC] [STORE key] [STOREDIST key]

      • key: key名称
      • WITHDIST: 在返回位置元素的同时, 将位置元素与中心之间的距离也一并返回。 距离的单位和用户给定的范围单位保持一致。
      • count: 取多少个结果
127.0.0.1:6379> georadius people 116.310988  40.012265  50 km withdist count 10 asc
1) 1) "li"
   2) "1.5792"
2) 1) "wang"
   2) "1.8407"
3) 1) "yang"
   2) "4.5658"
4) 1) "zhao"
   2) "5.3725"

geo使用注意事项:

  • 在一个应用中,数据可能会有百万千万条,如果全部放在一个 zset 集合中。在 Redis 的集群环境中,集合可能会从一个节点迁移到另一个节点,单个 key 的数据过大,会对集群的迁移工作造成较大的影响,在集群环境中单个 key 对应的数据量不宜超过 1M,否则会导致集群迁移出现卡顿现象,影响线上服务的正常运行,建议 Geo 的数据使用单独的 Redis 实例部署,不使用集群环境。
  • 数据量过亿,甚至更大,就需要对Geo数据进行拆分,按国家拆分、按省拆分、按市拆分、按区拆分。这样就可以显著降低单个zset集合的大小,zset集合大小,也进行合适地切分。

geohash

geohash算法是什么

GeoHash本质上是空间索引的一种方式,基本原理是将地球理解为一个二维平面,将平面递归分解成更小的子块,每个子块在一定经纬度范围内拥有相同的编码。以GeoHash方式建立空间索引,可以提高对空间poi数据进行经纬度检索的效率。

其它:滴滴打车使用的google s2算法, 基于b tree 的R tree,基于二叉树的四叉树,还有网格索引

为什么需要geohash算法

直接在数据库中计算经纬度效率太过于低下,计算量太大,无关的数据太多

geohash编码

原理

转化过程: 地球(三维)->地图(二维)->一维数组

二维信息编码成了一个一维信息有三个好处:

  1. 编码后数据长度变短,利于节省存储。
  2. 利于使用前缀检索
  3. 当分割的足够细致,能够快速的对双方距离进行快速查询

编码

对地图按规律进行切分

经度的取值范围是[-180,180], 纬度取值范围是[-90,90]。

算经度:

算纬度:

经纬度合并:

纬度(奇数): 10111000110001111001
经度(偶数):11010010110001000100
合并: 11100 11101 00100 01111 00000 01101 01011 00001

偶数位放经度,奇数位放纬度,重新组合经度和纬度的二进制串,生成新的:111001100111100000110011110110

大概流程:

纬度[-90, 0] 用0表示,[0, 90]用1表示;经度[-180, 0] 用0表示,[0, 180]用1表示。经度在前纬度在后,这样四个部分都有了一个二进制编码。

再继续进行二等分

两次等分后的矩形需要用 4bit 来表示。前两位是第一次等分后所在大矩形的编码,后两位表示第二次分割出的小矩形在大矩形中的位置。

转为geo base32编码:

geo base32编码规则是5位换1, 将上面的结果转为base32编码,

当geohash 越长时, geohash编码就越准确,下图是编码长度与距离误差表

redis中实现geohash编码:

redis中geohash base32后是11位长度, 实际它只有52位长度,不足55位,所以变geohash base32时会补0凑够55位.

为什么是52位?因为在redis中是把地理位置编码后的二进制值存入zset数据结构中,double类型的尾数部分长度为52位。

redis-zset:

zset为有序(有限score排序,score相同则元素字典序),自动去重的集合数据类型,其底层实现为 字典(dict) + 跳表(skiplist),当数据比较少的时候用ziplist编码结构存储。

  • 同时满足以下两个条件采用ziplist存储:

  • 有序集合保存的元素数量小于默认值128个

  • 有序集合保存的所有元素的长度小于默认值64字节

  • skiplist+dict:


z阶曲线

geohash 的优点很明显,它利用 Z 阶曲线进行编码。而 Z 阶曲线可以将二维或者多维空间里的所有点都转换成一维曲线。并且 Z 阶曲线还具有局部保序性。

  • ①前缀相同的位数越多,两个位置越相邻;
  • ②已知一个数字代表的区域可以方便地计算它们相邻的区域;
  • ③支持用任意的精度查找指定范围内的目标。

Z 阶曲线有一个比较严重的问题,虽然有局部保序性,但是它也有突变性。在每个 Z 字母的拐角,都有可能出现顺序的突变。

看上图中标注出来的蓝色的点点。每两个点虽然是相邻的,但是距离相隔很远。

问题:

临界问题

由于GeoHash是将区域划分为一个个规则矩形,并对每个矩形进行编码,这样在查询附近POI信息时会导致以下问题,比如红色的点是我们的位置,绿色的两个点分别是附近的两个餐馆,但是在查询的时候会发现距离较远餐馆的GeoHash编码与我们一样(因为在同一个GeoHash区域块上),而较近餐馆的GeoHash编码与我们不一致。这个问题往往产生在边界处

解决方案:

解决的思路很简单,查询时,除了使用定位点的GeoHash编码进行匹配外,**还使用周围8个区域的GeoHash编码,**这样可以避免这个问题。

z阶曲线突变问题

同上面

redis解决方案:

redis源码中src/geohash.c中geohashNeighbors()的具体实现,geohashNeighbors使用了geohash_move_x和geohash_move_y两个函数实现了geohash左右和上下的移动,这样可以很容易组合出8个邻域的geohash值了

static void geohash_move_x(GeoHashBits *hash, int8_t d) {
    if (d == 0)
        return;
    
    uint64_t x = hash->bits & 0xaaaaaaaaaaaaaaaaULL;
    uint64_t y = hash->bits & 0x5555555555555555ULL;
    
    uint64_t zz = 0x5555555555555555ULL >> (64 - hash->step * 2);
    
    if (d > 0) {
        x = x + (zz + 1);
    } else {
        x = x | zz;
        x = x - (zz + 1);
    }
    
    x &= (0xaaaaaaaaaaaaaaaaULL >> (64 - hash->step * 2));
    hash->bits = (x | y);
}

static void geohash_move_y(GeoHashBits *hash, int8_t d) {
    if (d == 0)
        return;
    
    uint64_t x = hash->bits & 0xaaaaaaaaaaaaaaaaULL;
    uint64_t y = hash->bits & 0x5555555555555555ULL;
    
    uint64_t zz = 0xaaaaaaaaaaaaaaaaULL >> (64 - hash->step * 2);
    if (d > 0) {
        y = y + (zz + 1);
    } else {
        y = y | zz;
        y = y - (zz + 1);
    }
    y &= (0x5555555555555555ULL >> (64 - hash->step * 2));
    hash->bits = (x | y);
}

工具:

redis-GeoADD

主流程:

/src/geo.c

一,建立空间索引

redis将GeoHash的bit长度设置为52位,这样我们就可以将GeoHash编码转换成double 64类型存入 SortedSet 数据结构中

  • 使用坐标转换的思想

二,数据入库:

  • 选择十进制存储优势

  • 方便范围查询

  • 方便存储,索引长度适中

总结梳理:

Redis 的 sorted set 支持 64位 的 double 类型的 score,将经纬度通过 GeoHash 算法获取到二进制 GeoHash 码,并将其转成十进制作为这个点的 score 存入 Redis 的 sorted set中;

注意:

redis geohash使用的是长度为52的bit, 当使用geoHash命令是返回的字符长度是11

base32编码却是以5bit为基础长度作为映射的

按照正常逻辑发展: 11 * 5 = 55bit 才是正确的

事实上redis会对这里有特殊处理

5个bit为一组,头部的bit右移的位数最多,越到尾部越少,呈递减

当求52位最后2位时候,右移的数字是负数,

c语言中右移负数不会报错,但是编译时会提示该行为未定义,

在不同的操作系统,不同的gcc版本右移都可能造成不一样的结果输出,redis4.0运行时的输出都是0

在redis6.0的版本才修复了这个问题

左边4.0版本,右边6.0版本

redis-geohash-radius

主流程:

一,统一换算距离单位为米

二,根据查询点获取geohash,然后查九宫格

1.根据查询范围半径获取精度:geohashEstimateStepsByRadius

精度是由地图的划分次数决定的,划分次数多了,范围就小了,查询的出的数据就不全;划分次数少了,范围就会大了,我们对数据过滤时就会有过多的损耗。

墨卡托投影:

使用墨卡托投影可以估算出来我们需要的层级,地球的表面可以作为一个正方形来看,它的边是地球周长中最长的一个。现实世界,地球最长的周长就是赤道周长,墨卡托投影的长边是 40075452.74M;

于是我们拿正方形的一个边来不停地进行二次划分,直到划分后的结果刚好比范围半径长,那么它构成的一个方块,便是我们需要的方格。

注意点:
  1. 因为地球是球型在绘制地图时采用墨卡托投影法(Mercator Projection)在靠近南北两极的地方投影面积比较大,所以在高纬度区域需要减少精度
  2. 经度的取值范围是[-180,180]纬度的取值范围是[-90,90], 所以在经纬度等分相同次数后我们得到的矩形总是东西长南北短。为了解决这个问题我们返回的精度总是奇数(precision*2 - 1), 这样经度比纬度多分割一次就可以得到长宽基本相等的矩形了。
代码:

/src/geohash_helper.c

2.获取地图经纬度范围并对原始经纬度编码:geohashGetCoordRange&geohashEncode

3.根据geohash获取其周围八个geohash矩形:geoNeighbors

4.对geohash进行解码,获取到geohash矩形的经纬度范围:geohashDecode

5.过滤不需要的neighbors

三,过滤在该范围内的所有点

1.求每个九宫格的最大分数,最小分数

2.去zset中查询对应的点