1、GEOHash——如何“摇一摇”
1-1、为什么需要GEOHash
现在很多APP都有“摇一摇”、“附近的人”、网约车离我有多远等类似的功能。
那就不可避免需要进行一系列的地理坐标转换为距离的计算。
获取地理坐标不难,只需要用户授权就能轻松获取到很精确的经纬度;
计算距离也不难:两点之间坐标差的平方相加,再开方即可(的markdown太差了,公式也不支持)。
问题在于,用户数量剧增以后(百万、千万、亿……),每次进行全量距离计算本身就变成了一个巨大的负担,大量乘方、开方这样的浮点运算,不管多么强壮的算力都会被压垮。
1-2、什么是GEOHash
GEOHash是解决这一问题的一种算法。
GEOHash的核心思想是将二维的经纬度转换成一维的字符串,这样无论使用何种方式存储用户的位置数据,都可以很方便地建立索引,大大简化计算。
GEOHash有三个主要特点:
字符串越长,表示的范围越精确。编码长度为8时,精度在19米左右,而当编码长度为9时,精度在2米左右。
字符串相似的表示距离相近,利用字符串的前缀匹配,可以查询附近的地理位置。
GEOHash编码后的字符串,可以反向解码出原来的经纬度。
1-3、GEOHash编码的简易原理
GEOHash的编码过程并不复杂,首先使用分形的思想对经纬度做处理(二维空间转换为分形维),转为二进制字符串;然后偶数位放经度,奇数位放纬度,得到一个新的二进制字符串;最后对该二进制字符串做Base32编码处理即可(或者使用Base64)。
但是“为什么”要这样编码,就涉及到计算机图形学的一些思想(GEOHash应用了Peano曲线),本篇侧重应用,原理的具体说明此处略去。
1-4、GEOHash的边缘问题
GEOHash编码时,实际上就是把所有区域分割成大小相同的矩形块(分得越细,字符串就越长,精度越精确)。
但是不管分割得怎么细,都是一个矩形区域,会出现区域内靠近边缘的某个点,与另一片区域中靠近边缘的点离得更近,但是被误判的情况。
毕竟同一片区域内的hash码是相同的,不管离得多远,都会被判定为离得最近。
解决方法是在查询时,除了使用定位点的GeoHash码进行匹配外,还要使用周围8个区域的GeoHash编码一并匹配。
2、Redis中GEOHash的应用
Redis 从3.2版本开始,追加了GEO数据类型用于存储用户信息,并且提供了各种用于地理计算的方法。
(BTW,对Redis GEO贡献最大的是其实是国内的开发者。也是因为我们对这个功能的需求最大)
下面是相关的全部命令列表:
GEOADD:增加地理位置的坐标(支持批量操作)
时间复杂度O(log(N))
GEOADD key longitude latitude member [longitude latitude member ...]
- key标识一个地理位置的集合。
- longitude是经度,latitude是纬度。
- member是该地理位置的名称(自定义)。
应用举例:
geoadd cities 121.47 31.23 shanghai
geoadd cities 116.40 39.90 beijing 118.78 32.07 nanjing
GEOPOS:获取地理位置的坐标(支持批量操作)
时间复杂度O(log(N))
GEOPOS key member [member ...]
GEODIST:获取两个地理位置的距离
时间复杂度O(log(N))
GEODIST key member1 member2 [m|km|ft|mi]
单位可以指定为以下四种类型:
- m:米,距离单位默认为米,不传递该参数则单位为米
- km:公里
- mi:英里
- ft:英尺
应用举例:
geodist cities beijing shanghai
GEORADIUS:根据给定地理位置坐标获取指定范围内的地理位置集合
时间复杂度O(N+log(M)),N为指定半径范围内的元素个数,M为要返回的个数
GEORADIUS key longitude latitude radius [m|km|ft|mi] [WITHCOORD] [WITHDIST] [ASC|DESC] [WITHHASH] [COUNT count]
- longitude是经度,latitude是纬度。
- radius表示范围距离。
- 距离单位可以为m|km|ft|mi
- WITHCOORD:传入WITHCOORD参数,则返回结果会带上匹配位置的经纬度。
- WITHDIST:传入WITHDIST参数,则返回结果会带上匹配位置与给定地理位置的距离。
- ASC|DESC:默认结果是未排序的,传入ASC为从近到远排序,传入DESC为从远到近排序。
- WITHHASH:传入WITHHASH参数,则返回结果会带上匹配位置的hash值。
- COUNT count:传入COUNT参数,可以返回指定数量的结果。
GEORADIUSBYMEMBER:根据给定地理位置获取指定范围内的地理位置集合
时间复杂度O(N+log(M)),N为指定半径范围内的元素个数,M为要返回的个数
GEORADIUSBYMEMBER key member radius [m|km|ft|mi] [WITHCOORD] [WITHDIST] [ASC|DESC] [WITHHASH] [COUNT count]
GEORADIUS 命令的参数是坐标,而本命令的参数是地理位置名称,其他无区别。
应用举例:
georadiusbymember cities shanghai 50 km
GEOHASH:获取地理位置的GEOHash值(支持批量操作)
时间复杂度O(log(N))
GEOHASH key member [member ...]
3、关于Redis中GEO的一些说明
redis使用的GEOHash编码长度为26位,可以精确到0.59m的精度。
Redis中的GEO核心主要是两点:
- 使用GEOHash保存地理位置的坐标
- 使用有序集合(ZSET)保存地理位置的集合
内部的实际存储使用的是ZSET,进一步说明如下:
GEO命令中的key就是ZSET的名字
Redis没有提供地理位置的删除命令。可以利用ZSET的ZREM命令进行删除: zrem cities shanghai
执行GEOADD命令时,会先按照标准的GEOHash计算方法计算hash值,然后将地理位置名称(member)作为集合的member,将GEOHash值作为score,执行ZADD命令插入到ZSET中
GEORADIUS和GEORADIUSBYMEMBER。内部实现实际上就是先执行ZRANGE命令,然后判断结果是否符合命令中指定的距离即可。另外就是这两个命令已经自行解决了GEOHash的“边缘问题”,会自动关联周围的八片区域,无需额外处理。
4、Redis以外的GEOHash实现
4-1、MySQL
MySQL从 5.7.4 labs 版本开始增加了对于空间索引的支持,通过R树实现。
如果不具备升级MySQL的条件,就需要增加类似 geo_hash 的字段,追加地理位置时手动计算hash码,搜索附近的人时手动解决GEOHash的边缘问题,从算力和复杂度上来说都不能令人满意。
4-2、MongoDB + 2d索引
MongoDB主要是通过它的两种地理空间索引 2dsphere 和 2d来实现地理位置的操作。
两种索引的底层依然是基于Geohash,但与通用的Geohash还有一些不同。
2dsphere 索引仅支持球形表面的几何形状查询。
2d 索引支持平面几何形状和一些球形查询。虽然2d 索引支持某些球形查询,但 2d 索引对这些球形查询时,可能会出错。所以球形查询尽量选择 2dsphere索引。
尽管两种索引的方式不同,但只要坐标跨度不太大,计算出的距离误差几乎可以忽略不计。
对经纬度数据创建2d索引(db.coll.createIndex)以后,只要使用geoNear命令就可以实现附近的人的功能了。