Redis Geohash
Redis在3.2版本后增加了地理位置GEO模块, 意味着可以使用Redis来实现摩拜但这[附近的Mobike]、美团和饿了么[附近的餐馆]这样的功能了。
用数据库来算附件的人
地图元素的位置数据使用二维的经纬度表示, 经度范围(-180, 180], 纬度范围(-90,90],纬度正负以赤道为界, 北正南负, 经度正负以本初子午线(应该格林尼天文台)为界, 东正西负。
当两个元素的距离不是很远时, 可以直接使用勾股定理就能直接算得两个元素之间的距离。我们平时使用的[附近的人]的功能, 元素距离都不是很大, 勾股定理算距离足够。 不过需要注意的是, 经纬度坐标的密度不一样(经度总共360度, 纬度总共180度)。勾股定律计算平方差之后在求和时, 需要按一定的系数比加权求和。
现在,如果要计算[附近的人],也就是给定一个元素的坐标, 然后计算这个左边附件的其他元素, 按照距离进行排序, 该楚河处理呢?
如果现在元素的经纬度坐标使用关系数据库(元素id, 经度x, 纬度y) 存储, 你该如何计算?
首先, 你不能通过遍历来计算所有的元素和目标元素的距离然后在进行排序, 这个计算量太大了,性能指标肯定无法满足。 一般的方法都是通过矩形来限定元素的数量, 然后对区域内的元素进行权力距离计算在排序。这样可以明显减少计算量。如果划分矩形区域呢? 可以指定一个半径r, 使用一条SQL可以圈定出来。 当用户对筛出来的结果不满意, 那就扩大半径继续筛选。
select id from tables where x0-r < x < x0+r and y0-r < y < y0+r
为了满足高性能的矩形区域算法, 数据表需要在经纬度坐标加上双向符合索引(x, y),这样可以最大优化查询性能。
但是数据库查询性能毕竟优先, 如果[附近的人]查询请求非常多, 在高并发场合, 这可能并不是一个很好的方案。
GeoHash算法
业界比较通用的地理位置距离排序算法时GeoHash算法, Redis也使用了GeoHash算法。 GeoHash算法将二维的经纬度数据映射到一维的整数, 这样所有的元素都将挂在到一条线上, 距离靠近的二维坐标映射到一维后的点之间距离也会很接近。 当我们想要计算[附近的人], 首先将目标位置映射到这条线上, 然后在这个一维的线上获取附近的点就可以了。
那这个映射算法具体时怎么样的呢? 它将整个地球看出一个二维平面, 然后划分成了一系列正方形的方格, 就比如围棋棋盘。 所有的地图元素坐标都放置于唯一的方格中。 方格越小,坐标越精确。 然后对这些方格进行整数编码, 越是靠近的方格编码越是解决。 那如何编码呢? 一个最简单的方案就是切蛋糕。 设想一个正方形蛋糕摆在你面前, 二刀下去均分成4个小正方形, 这四个小正方形可以标记为00, 01, 10, 11四个二进制整数。 然后对每个小正方形继续用二刀法切割下, 这个每个正方性就可以用4bit的二进制整数表示。然后继续切下去, 正方形就会越来越小, 二进制整数也会越来越长, 经度就会越来越高。
上面的例子中使用的是二刀法, 真实算法中还会有其他很多的刀法, 最终编码出来的整数数字也都不一样。
编码之后, 每个地图元素的坐标都将变成一个整数, 通过这个整数可以还原出元素的坐标, 整数越长, 还远出来的坐标值的损失程度就越小, 对于[附近的人]这个功能而言, 损失的一点精度可以忽略不计。
GeoHash算法会继续对这个整数做一次base32编码(0-9,a-z去掉a,i, l, o四个字母)变成一个字符串。在Redis里面, 经纬度使用52位的整数进行编码, 放进zset里面, zset的value是元素的key, score是GeoHash的52位整数值。zset的score虽然是浮点数, 但是对于52位的整数值, 它可以是无损存储。
在使用Redis进行Geo查询时, 我们要时刻想到它的内部结构实际上只是一个zset(skiplist)。通过zset的score排序就可以直接得到坐标附近的其他元素(实际情况要复杂一些,不过可以这样理解就足够了),通过将score还原成坐标值就可以得到元素的原始坐标。
Redis的Geo指令基本使用
Redis提供Geo指令只有6个, 使用时, 务必再次想起, 它时一个普通的zset结构。
增加
geoadd指令携带集合名称以及多个经纬度名称三元组, 注意这里可以添加多个三元组
127.0.0.1:6379> geoadd company 115.48105 39.996794 juejin
(integer) 1
127.0.0.1:6379> geoadd company 116.514203 39.905409 ireader
(integer) 1
127.0.0.1:6379> geoadd company 116.489033 40.007669 meituan
(integer) 1
127.0.0.1:6379> geoadd company 116.562108 39.787602 jd 116.334255 40.027400 xiaomi
(integer) 2
距离
geodist指令可以用来计算两个元素之间的距离, 携带几个名称、2个名称和距离单位
127.0.0.1:6379> geodist company juejin ireader km
"88.6762"
127.0.0.1:6379> geodist company juejin meituan km
"85.8899"
127.0.0.1:6379> geodist company juejin jd km
"95.1443"
127.0.0.1:6379> geodist company juejin xiaomi km
"72.7633"
127.0.0.1:6379> geodist company juejin juejin km
"0.0000"
距离单位可以是m、km、ml、ft分别代表米、千米、英里和尺。
获取元素的位置
geopos指令可以获取集合中任意元素的经纬度坐标,可以一次获取多个。
127.0.0.1:6379> geopos company juejin
1) 1) "115.48104733228683472"
2) "39.99679348858259686"
127.0.0.1:6379> geopos company ireader
1) 1) "116.5142020583152771"
2) "39.90540918662494363"
127.0.0.1:6379> geopos company juejin ireader
1) 1) "115.48104733228683472"
2) "39.99679348858259686"
2) 1) "116.5142020583152771"
2) "39.90540918662494363"
可以观察到获取的经纬度坐标和geoadd进去的坐标有轻微的误差, 原因是geohash对二维坐标进行的一维映射是有损的, 通过映射在还原回来的值会出现较小的差别。对于[附近的人]这种功能来说, 这点误差根本不是事。
获取元素的hash值
geohash可以获取元素的经纬度编码字符串, 上面提到, 它是base32编码。 可以使用这个编码值取http://geohash.org/${hash}中进行直接定位, 它是geohash的标准编码值。
127.0.0.1:6379> geohash company ireader
1) "wx4g52e1ce0"
127.0.0.1:6379> geohash company juejin
1) "wx45ec4wpq0"
打开地址http://geohash.org/wx4g52e1ce0,观察地图指向的位置是否正确。
附件的公司
georadiusbymember指令是最为关键的指令, 它可以用来查询指定元素附近的其他元素, 它的参数非常复制。
# 范围20公里以内最多2个元素按距离正排, 它不会排除自身
127.0.0.1:6379> georadiusbymember company ireader 20 km count 3 asc
1) "ireader"
2) "meituan"
3) "jd"
# 范围20公里以内最多3个元素倒排
127.0.0.1:6379> georadiusbymember company ireader 20 km count 3 desc
1) "jd"
2) "meituan"
3) "ireader"
# 三个可选参数withcoord withdist withhash 用来携带附加参数
# withdist很有用, 它可以用来显示距离
127.0.0.1:6379> georadiusbymember company ireader 20 km withcoord withdist withhash count 3 asc
1) 1) "ireader"
2) "0.0000"
3) (integer) 4069886008361398
4) 1) "116.5142020583152771"
2) "39.90540918662494363"
2) 1) "meituan"
2) "11.5748"
3) (integer) 4069887179083478
4) 1) "116.48903220891952515"
2) "40.00766997707732031"
3) 1) "jd"
2) "13.7269"
3) (integer) 4069154033428715
4) 1) "116.56210631132125854"
2) "39.78760295130235392"
除了georadiusbymember指令根据元素查询附近的元素, Redis还提供了根据坐标值来查询附近的元素, 这个指令更加有用, 它可以根据用户的定位来计算[附近的车],[附近的餐馆]等。它的参数和georadiusbymember基本一致, 除了将目标元素改成经纬度坐标值。
127.0.0.1:6379> georadius company 116.514203 39.905409 20 km withdist count 3 asc
1) 1) "ireader"
2) "0.0001"
2) 1) "meituan"
2) "11.5748"
3) 1) "jd"
2) "13.7268"
注意事项
在一个地图应用中, 车的数据、餐馆的数据、人的数据可能会有百万千万条, 如果使用Redis的Geo数据结构, 它们将全部放在一个zset集合中。 在Redis集群环境中, 集合可能会从一个节点迁移到另一个节点, 如果单个key的数据过大, 会对集群的迁移工作造成较大影响, 在集群环境中单个key对应的数据量不宜超过1M, 否则会导致集群迁移出现卡顿现象, 影响线上服务的正常允许。
所以,这里建议Geo的数据使用单独的Redis实例部署,不使用集群环境。
如果数据量过亿甚至更大, 就需要对Geo数据进行拆分, 按国家拆分、按省拆分、按市拆分, 在人口特大城市甚至可以按区拆分。 这个就可以显著降低单个zset集合的大小。