geohash的实现原理及工程实践

286 阅读7分钟

前序

今天小宾和同学们分享的知识点是geohash,相信有许多同学或多或少的了解使用过该技术,接下来我会和大家一起深入其底层的实现原理和思想

先抛出小宾使用geohash的应用场景:我工作的主要方向是网约车治理相关,一句话概括为对司乘之间的纠纷订单进行判责及后续处理流程;某天,产品经理踱步慢慢走来:“小宾啊,我们现在有个业务需求,是要实现判定订单起点是否属于禁停区域,以此来对后续司机的异常行为做豁免策略”

小宾微微一笑,这能难倒英俊机智的我么~ 脑袋一转,已计上心来

首先,咱先分析下这个需求,产品会提供一批禁停区域的坐标点 以及 判定是否属于禁停范围的半径距离;实现逻辑是判断订单起点以该半径的圆圈范围内是否有禁停点;最简单粗暴的实现,存储所有禁停点位,每次遍历与订单遍历计算距离,再与半径比较,如小于指定半径,则返回起点禁停

这个方法效率非常低,不仅每个订单的判定都需要遍历所有的禁停点,而且会带来大量的距离计算

那该如何处理这种地理坐标的存储及距离判定逻辑呢,咱接着往下看

geohash的实现原理

核心思想:

geohash将二维的经纬度坐标转换成一位的编码字符串进行存储,且可以根据字符串之间的公共前缀判定其相隔距离(非完全精确)

地球的二维表示

将地球的侧面完全展开成一个矩形,长度为赤道周长,左右维度表示经度范围,上下维度表示纬度范围 image.png 格子分区

基于二维展开的地球,将经度一分为二,0°-180°W 和 0°-180°E,用二进制进行表示;左侧分区为0,右侧分区为1;同样纬度也一分为二,0°-90°N 和 0°-90°S,上侧分区为0,下侧分区为1;继续分别进行二分,每次分隔都用一位表示;则经纬度的二进制字符串表示如下: image.png 合并生成格子

接下来,我们把经纬度的分区进行合并,这样交错的分区就会划分出一个个的格子;同时将其二进制字符串也进行合并,每个格子对应一个二进制字符串;经纬度二进制字符串合并方式:从高位开始,经度+纬度交错合并,最后组成一个一维的二进制字符串

聪明的同学已经发现了,当经纬度划分的越细,其二进制字符串的位数越多,合并后产生的格子也就越多,每个格子表示的面积也就越小;那么当两个格子的公共前缀越多,就代表着这他们相隔越近,例如0101、0111、0100、0110 都拥有着公共前缀 01;因此,他们都从属于由 01表示的这个大格子当中 image.png 由此,我们可以知道,当格子的二进制字符串的公共前缀越长,两个格子相隔就越近

编码压缩

经过上述的操作后,我们已经将三维世界中的地理坐标,先建模成二维平面的表示;再根据二进制的表示方法,将其简化为一维的表示;但这仍然不够,因此继续采用编码将其进一步的压缩,geohash中采用的是base32的编码格式

每5bit为一组,将其表示的十进制值与base32进行映射,geohash的base32编码使用字符A-Z和数字2-7(或0-9),但不包括a、i、l、o等字母,以避免混淆‌。例如,二进制序列11100 11101 00100 01111 0000 01101对应的Base32编码为wx4g0e‌,映射关系如下: image.png

geohash的局限性

边缘性问题

geohash中,二分的次数越多,其生成的格子就越小,表示精度就越高;然而,无论二分多少次,其都无法完全等同于坐标点,就类似于经典的芝诺悖论中的无穷二分

因此,geohash中表示的格子永远是存在误差的,只是理论上我们可以通过不断的切割提高其表示的精确度;其中,一个常见的误差场景就是格子的边缘性问题

如下图中,A、B、C三个点,其中A和B划分到一个格子中,C划分到其相邻的格子;点A和点B的geohash值完全相等,因此在geohash中,A和B会被认为更接近,因为他们有更长的公共前缀;而在现实中,A和C的距离才是更近的,这就是格子划分所带来的偏差

理论上我们可以通过继续二分,划分更小的格子,将AC划入一个格子中,但这个问题是永远存在的 image.png 三维到二维的畸变问题

在上述将地球三维转换成平面二维的展开时,聪明的小宾已经发现这种方式是存在误差的;越靠近南北极,误差越大,越靠近赤道,误差越小;最极端的情况,南极点和北极点被拉成了和赤道等长的线

小宾的个人理解,误差较小的建模是将地图展开为一个椭圆形的二维表示,但这种表示就无法在后续格子划分中做到均匀分割了;由此可以看出geohash的实现,是舍弃了一些精确性的;在一些geohash的实现中,也对这些误差进行补偿校准的逻辑

半径范围判断问题

回到文章最初,还记得产品经理给小宾提的那个需求么

需要实现半径圆圈范围的判断,而小宾该如何使用geohash进行实现呢;有些同学已经想到了,根据订单起点所属的格子,往外一圈一圈的扩展格子,就可以获取一定半径内所有的坐标点了

但这其中存在一个问题,如果我们想要完全获取的半径内的坐标点,其可能会覆盖更多的错误坐标,如下图所示: image.png 因此,我们就需要做额外的判断,将扩展覆盖格子的所有坐标取出后,再分别与起点做一次距离计算,筛选出距离小于半径的坐标点进行返回

redis geohash

相信同学们和小宾一起探讨过geohash的实现原理后,对于这个需求已经可以快速搞定,然后愉快的进行摸鱼了

那么,在redis中已经原生的支持了geohash的结构了,我们可以通过其提供的命令快速的进行坐标点的添加、获取以及半径内坐标点查找的能力

  • GEOADD:添加一个坐标到geohash中,
GEOADD key longitude latitude member
// key geohash的数据对象key
// longitude 经度
// latitude 纬度
// member 该点对应的名称
  • GEOHASH:获取point的信息
GEOHASH key member
// key geohash的数据对象key
//  member 该点对应的名称
  • GEORADIUS:获取该点指定半径内的所有坐标点
GEORADIUS key longitude latitude radius m|km|ft|mi
// key geohash的数据对象key
// longitude 经度
// latitude 纬度
// radius 查询半径
// m|km|ft|mi 距离单位

redis中geohash数据的实际存储实现是基于zset,将坐标所属的格子geohash值转换成十进制值作为zset的score,用于跳表skiplist的索引构建,实现logN的查询效率

redis中的GEORADIUS命令实现逻辑,就是上述提到的判断流程,先扩展格子至完全覆盖该半径,再遍历格子中所有的坐标点,与传入的坐标进行距离判断,返回所有距离小于半径的坐标点

到此,小宾就和大家一起完成了对geohash底层实现原理的学习,后续小宾还会和大家分享更多的技术知识,为摸鱼的崇高理想奋斗(另,摸鱼不是工作不饱和,是高效工作的体现,hh)