Redis GEO 地理位置的使用与原理解析以及Java实现GEOHash算法

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第25天,点击查看活动详情

详细介绍了Redis GEO存储地理位置信息的使用方式以及基本原理,基于Java如何实现GEOHash算法。

Redis GEO 主要用于存储地理位置信息,并对存储的信息进行操作,该功能在 Redis 3.2 版本新增。

Redis GEO可被用来计算两个经纬度点位之间的物理距离,常见的应用就是“附近的人”的功能,摇一摇附近的人功能,或者外卖中的骑手等距客户多少米的功能,或者周边商家、车辆功能等等,需要计算真实距离的场景都可以使用。

在 Redis 6.2.0的版本中提供了8个相关的操作命令,而且都是很简单的。

@[toc]

1 GEOADD添加坐标

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

将指定的一个或者多个地理空间项(经度、纬度、名称)添加到指定的key。数据作为排序集存储在key中,这样就可以使用 GEOSEARCH 和GEORADIUSBYMEMBER命令查询这些项目。

该命令采用标准格式 x,y 的参数,因此必须在纬度之前指定经度。可编入索引的坐标有限制:非常靠近极点的区域不可编入索引。

  1. 有效经度为 -180 到 180 度。
  2. 有效纬度是从 -85.05112878 到 85.05112878 度。

返回新添加到键里面的空间元素数量,不包括那些已经存在但是被更新的元素。

当用户尝试索引指定范围之外的坐标时,该命令将报告错误。这里没有 GEODEL 命令,因为地理索引结构只是一个Sorted Set,因此可以使用 ZREM 删除元素。

如下案例,我们尝试添加几个地理位置:故宫、天安门、日坛公园、天坛公园、北京站(jingweidu.bmcx.com/):

127.0.0.1:6379> GEOADD test 116.397128 39.916527 gugong
(integer) 1
127.0.0.1:6379> GEOADD test 116.39798630688475 39.90382054860711 tiananmen
(integer) 1
127.0.0.1:6379> GEOADD test 116.44381989453123 39.915605367786505 ritangongyuan
(integer) 1
127.0.0.1:6379> GEOADD test 116.41077507946775 39.88208903170563 tiantangongyuan
(integer) 1
127.0.0.1:6379> GEOADD test 116.42699707958982 39.90273413640524 beijingzhan
(integer) 1

2 GEOPOS获取坐标

GEOPOS key member [member ...]

返回由key的Sorted set表示的地理空间索引的要给或者多个指定成员的位置(经度、纬度)。

当通过 GEOADD 填充地理空间索引时,坐标被转换为 52 位 geohash,因此返回的坐标可能不完全是用于添加元素的坐标,可能会有小精度错误,也就是说值不一定是插入值。

获取故宫、北京站和日坛公园的地理坐标:

127.0.0.1:6379> GEOPOS test gugong
1) 1) "116.39712899923324585"
   2) "39.91652647362980844"
127.0.0.1:6379> GEOPOS test beijingzhan ritangongyuan
1) 1) "116.4269980788230896"
   2) "39.90273505580184832"
2) 1) "116.44382089376449585"
   2) "39.91560636984896604"

3 GEODIST计算距离

GEODIST key member1 member2 [m|km|ft|mi]  

返回由Sorted set表示的地理空间索引中两个成员之间的距离,这个命令非常有用。

如果缺少一个或两个成员,则该命令返回 NULL。

单位必须是以下之一,默认为m(米),还有km(千米),mi(英里),ft(英尺)。

该命令假设地球是一个完美的球体来计算距离,因此在边缘情况下可能会出现高达 0.5% 的误差。

案例,计算故宫和北京站的距离:

127.0.0.1:6379> GEODIST test gugong beijingzhan
"2974.4056"

结果约2.974公里,差不多(在线经纬度距离计算www.hhlink.com/%E7%BB%8F%E…

在这里插入图片描述

4 GEOHASH获取geohash

返回有效的 Geohash 字符串,表示一个或多个元素在表示地理空间索引的Sortedset中的位置(其中元素是使用 GEOADD 添加的)。

通常 Redis 使用 Geohash 技术的变体来表示元素的位置,其中位置使用 52 位无符号二进制整数进行编码。与标准相比,编码也不同,因为在编码和解码过程中使用的初始最小和最大坐标不同。但是,此命令以 Wikipedia 文章(en.wikipedia.org/wiki/Geohas… Geohash,并与 geohash.org/网站兼容。

也就是说,我们获取到某个坐标的hash值之后,就能直接去geohash.org/${hash}网站定位…

该命令返回 11 个字符的 Geohash 字符串,因此与 Redis 内部 52 位表示相比没有精度损失。返回的 Geohashes 具有以下属性:

  1. 它们可以从右边删除字符以缩短,这会使得定位失去精度,但仍会指向同一区域。
  2. 可以在 geohash.org的URL中使用它们,例如geohash.org/%3Cgeohash-…
  3. 具有相似前缀的字符串在附近,但反之则不然,具有不同前缀的字符串也可能在附近。

我们获取故宫的坐标的hash:

127.0.0.1:6379> GEOHASH test gugong
1) "wx4g0dtf9e0"

去定位一下geohash.org/wx4g0dtf96x… 在这里插入图片描述

有时候下面的小图加载不出来,点击图中的“google”试试: 在这里插入图片描述

确实定位是在故宫。

5 GEORADIUSBYMEMBER成员半径搜索

GEORADIUSBYMEMBER key member radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count [ANY]] [ASC|DESC] [STORE key] [STOREDIST key]

GEORADIUSBYMEMBER以指定名字的成员的位置用作查询的中心,返回距中心的最大距离(半径)指定的区域的边界内的其他成员。

此命令的常见用例是检索指定点附近不超过给定米数(或其他单位)的地理空间项目。例如,这允许向移动用户推荐附近地点的应用程序,比如附近的餐馆、附近的人等。

半径单位必须是以下之一,默认为m(米),还有km(千米),mi(英里),ft(英尺)。

该命令可选地使用以下参数,用于返回更多的附加信息:

  1. WITHDIST: 还要返回距离指定中心的距离,距离以与指定为命令的半径参数的单位相同的单位返回。
  2. WITHCOORD:还返回匹配项目的经度,纬度坐标。
  3. WITHHASH:还以 52 位无符号二进制数的十进制整数的形式返回项目的原始 geohash 编码的sorted set score,这仅对低级黑客或调试有用,否则一般用户几乎没有兴趣。

命令默认是返回未排序的项目,可以使用以下两个参数调用两种不同的排序方法:

  1. ASC: 相对于中心,从最近到最远对返回的项目进行排序.
  2. DESC: 相对于中心,从最远到最近的顺序对返回的项目进行排序。

默认情况下,返回所有匹配的项目。可以使用 COUNT N参数将结果限制为前 N 个匹配项。

当添加ANY 参数时,该命令将在找到足够的匹配项后立即返回,因此结果可能不是最接近要求的结果,但另一方面,将显著降低服务器的工作量,这个命令对于一个很大的范围的搜索来说对于减轻服务器压力非常有用。

默认情况下,该命令将项目返回给客户端,可以使用以下参数之一存储结果:

  1. STORE: 将项目存储在填充了其地理空间信息的Sorted set中.
  2. STOREDIST: 将项目存储在一个Sorted set中,该集合以它们与中心的距离作为浮点数填充,在半径中指定的单位相同。

返回值:

  1. 没有指定任何 WITH 选项,该命令只返回一个线性数组,如 ["New York","Milan","Paris"]
  2. 如果指定了 WITHCOORD、WITHDIST 或 WITHHASH 选项,则该命令返回一个数组的数组,其中每个子数组代表一个项目。
  3. 子数组最多包含四项元素:元素名、与中心的距离的浮点数,单位与参数指定的单位相同、geohash 整数、两个元素的数组(经度和纬度)。

由于 GEORADIUS 和 GEORADIUSBYMEMBER 具有 STORE 和 STOREDIST 选项,因此它们在技术上被标记为 Redis 命令表中的写入命令。因此,Redis 集群的从服务器接到这两命令时,会将它们重定向到主服务器。

从 Redis 3.2.10 和 Redis 4.0.0 开始,Reids分别提供了可在从服务器中使用的只读命令GEORADIUS_RO和GEORADIUSBYMEMBER_RO来代替GEORADIUS和GEORADIUSBYMEMBER,这两个只读命令与原始命令完全相同,但拒绝 STORE 和 STOREDIST 选项,可以安全的在从服务器中使用。

5.1 使用案例

返回距离故宫不超过3km的最近的一个点位(这里的COUNT 后面的数字是2,因为ASC不会排除自身):

127.0.0.1:6379> GEORADIUSBYMEMBER test gugong 3 km COUNT 2 ASC WITHDIST
1) 1) "gugong"
   2) "0.0000"
2) 1) "tiananmen"
   2) "1.4152"

可以看到,3km以内的距离故宫最近的点位是天安门,它们距离约1415m:

再返回距离故宫不超过3km的最远的一个点位:

127.0.0.1:6379> GEORADIUSBYMEMBER test gugong 3 km COUNT 1 DESC WITHDIST
1) 1) "beijingzhan"
   2) "2.9744"

可以看到,3km以内的距离故宫最远的点位是北京站,它们距离约2977m。

返回距离故宫不超过3km的最近的3个点位,展示全部附加内容:

127.0.0.1:6379> GEORADIUSBYMEMBER test gugong 3 km COUNT 4 ASC WITHCOORD WITHDIST WITHHASH
1) 1) "gugong"
   2) "0.0000"
   3) (integer) 4069885548668386
   4) 1) "116.39712899923324585"
      2) "39.91652647362980844"
2) 1) "tiananmen"
   2) "1.4152"
   3) (integer) 4069885361351847
   4) 1) "116.39798730611801147"
      2) "39.9038199164580405"
3) 1) "beijingzhan"
   2) "2.9744"
   3) (integer) 4069885468202423
   4) 1) "116.4269980788230896"
      2) "39.90273505580184832"

返回距离故宫不超过5km的最近的3个点位,展示全部附加内容:

127.0.0.1:6379> GEORADIUSBYMEMBER test gugong 5 km COUNT 4 ASC WITHCOORD WITHDIST WITHHASH
1) 1) "gugong"
   2) "0.0000"
   3) (integer) 4069885548668386
   4) 1) "116.39712899923324585"
      2) "39.91652647362980844"
2) 1) "tiananmen"
   2) "1.4152"
   3) (integer) 4069885361351847
   4) 1) "116.39798730611801147"
      2) "39.9038199164580405"
3) 1) "beijingzhan"
   2) "2.9744"
   3) (integer) 4069885468202423
   4) 1) "116.4269980788230896"
      2) "39.90273505580184832"
4) 1) "ritangongyuan"
   2) "3.9846"
   3) (integer) 4069885683167475
   4) 1) "116.44382089376449585"
      2) "39.91560636984896604"

6 GEORADIUS坐标半径搜索

GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count [ANY]] [ASC|DESC] [STORE key] [STOREDIST key]

该命令与 GEORADIUSBYMEMBER完全相同,唯一不同的是,它不以Sorted Set表示的地理空间索引中已存在的成员的名称作为要查询的区域的中心,而是以指定的经纬度值作为查询区域中心。

在使用GEORADIUS时,只需要将点位的名字改为经纬度值即可。

127.0.0.1:6379> GEOPOS test gugong
1) 1) "116.39712899923324585"
   2) "39.91652647362980844"
127.0.0.1:6379> GEORADIUS test 116.39712899923324585 39.91652647362980844 3 km COUNT 2 ASC WITHDIST
1) 1) "gugong"
   2) "0.0000"
2) 1) "tiananmen"
   2) "1.4152"

在 Redis 6.2.0的版本中,GEORADIUS 命令系列被视为已弃用,在新代码中优先选择 GEOSEARCH 和 GEOSEARCHSTORE。

7 GEOSEARCH指定区域搜索

GEOSEARCH key [FROMMEMBER member] [FROMLONLAT longitude latitude] [BYRADIUS radius m|km|ft|mi] [BYBOX width height m|km|ft|mi] [ASC|DESC] [COUNT count [ANY]] [WITHCOORD] [WITHDIST] [WITHHASH]

Redis 6.2 新增的命令。返回使用 GEOADD填充的地理空间信息的Sorted set的成员,这些成员位于给定形状指定的区域的边界内。

该命令扩展了 GEORADIUS 命令,因此除了在圆形区域内搜索外,它还支持在矩形区域内搜索。应使用此命令代替已弃用的 GEORADIUS 和 GEORADIUSBYMEMBER 命令。

查询的中心点由以下强制性参数之一提供:

  1. FROMMEMBER: 使用给定在的现有Sorted set中的成员的位置。
  2. FROMLONLAT: 使用给定的经度和纬度确定的位置。

这两个参数使得GEOSEARCH完全替代了GEORADIUS 和 GEORADIUSBYMEMBER 命令。

查询的形状由以下强制性参数之一提供:

  1. BYRADIUS: 与 GEORADIUS 类似,根据给定的radius半径在圆形区域内搜索。
  2. BYBOX: 在轴对齐的矩形内搜索,由height长和width确定长和宽。

该命令可选地使用以下参数,用于返回更多的附加信息:WITHDIST、WITHCOORD、WITHHASH。其含义与在GEORADIUS和GEORADIUSBYMEMBER命令中的使用一致。

命令默认是返回未排序的项目。可以使用以下两个参数调用两种不同的排序方法:ASC、DESC。其含义与在GEORADIUS和GEORADIUSBYMEMBER命令中的使用一致。

其他命令参数,如COUNT N、ANY、STORE、STOREDIST等的含义与在GEORADIUS和GEORADIUSBYMEMBER命令中的使用一致。

返回值的格式与在GEORADIUS和GEORADIUSBYMEMBER命令中的使用一致。

相比于GEORADIUS和GEORADIUSBYMEMBER,该命令没有提供STORESTOREDIST选项,因此是一个真正的只读命令,可以在从服务器中直接使用。

我们搜索以故宫为中心,半径为3km的圆形范围内的目标:

127.0.0.1:6379> GEOSEARCH test FROMMEMBER gugong BYRADIUS 3 km ASC COUNT 5 WITHDIST
1) 1) "gugong"
   2) "0.0000"
2) 1) "tiananmen"
   2) "1.4152"
3) 1) "beijingzhan"
   2) "2.9744"

我们搜索以故宫为中心,长宽为3km的矩形范围内的目标:

127.0.0.1:6379> GEOSEARCH test FROMMEMBER gugong BYBOX 3 3 km ASC COUNT 5 WITHDIST
1) 1) "gugong"
   2) "0.0000"
2) 1) "tiananmen"
   2) "1.4152"

很明显,选择不同的形状,将可能返回不同的结果。

8 GEOSEARCHSTORE搜索并存储

GEOSEARCHSTORE destination source [FROMMEMBER member] [FROMLONLAT longitude latitude] [BYRADIUS radius m|km|ft|mi] [BYBOX width height m|km|ft|mi] [ASC|DESC] [COUNT count [ANY]] [STOREDIST]

Redis 6.2 新增的命令。此命令类似于 GEOSEARCH,但只是将结果存储在目标key中,此命令代替现已弃用的 GEORADIUS 和 GEORADIUSBYMEMBER,这是一个写命令。

默认情况下,它将结果与其地理空间信息一起存储在目标Sorted set中,score是geohash的52 位无符号二进制数的十进制形式。使用 STOREDIST 选项时,该命令将项目存储在一个Sorted set集中,该Sorted set中填充了它们与圆或框中心的距离浮点数作为score,单位是指定的半径单位。

如下案例,我们将以故宫为中心,半径为3km的所有坐标存储在一个新的test2的Sorted set集合中,score为geohash的整数形式:

127.0.0.1:6379> GEOSEARCHSTORE test2 test FROMMEMBER gugong BYRADIUS 3 km ASC
(integer) 3
127.0.0.1:6379> ZRANGE test2 0 -1 WITHSCORES
1) "tiananmen"
2) "4069885361351847"
3) "beijingzhan"
4) "4069885468202423"
5) "gugong"
6) "4069885548668386"

如果我们加上STOREDIST选项,那么新的Sorted set中的score就是距离,其单位与GEOSEARCHSTORE中指定的单位相同:

127.0.0.1:6379> GEOSEARCHSTORE test2 test FROMMEMBER gugong BYRADIUS 3 km ASC STOREDIST
(integer) 3
127.0.0.1:6379> ZRANGE test2 0 -1 WITHSCORES
1) "gugong"
2) "0"
3) "tiananmen"
4) "1.4151991392787893"
5) "beijingzhan"
6) "2.9744056471131772"

9 Redis GEO的原理

经纬度是经度与纬度的合称组成一个坐标系统,称为地理坐标系统,它是一种利用三度空间的球面来定义地球上的空间的球面坐标系统,经度范围是东经180到西经180,纬度范围是南纬90到北纬90,我们设定西经为负,南纬为负,所以地球上的经度范围就是[-180, 180],纬度范围就是[-90,90]。

经纬度能够标示地球上除了南北极点的任何一个位置,因为南北极是一个点,所有经线交汇于极点,极点的经度可以表示为0度到180度之间(东经)和0度到-180度之间(西经)的任意值。

9.1 GeoHash算法的简介

GeoHash是一种地址编码方法,他能够把二维的空间经纬度数据编码成一个字符串。Geohash比直接用经纬度的高效很多,而且使用者可以发布地址编码,既能表明自己位于北海公园附近,又不至于暴露自己的精确坐标,有助于隐私保护。

GeoHash算法分为三步,我们以故宫的坐标(116.397128, 39.916527)为例子进行讲解。

第一步:将经度和纬度使用极限逼近算法转换为二进制数,这一步也采用了二分法的思想,以经度116.397128为例子:

  1. 将区间[-180,180]进行二分得到[-180,0),[0,180]两个区间,设左区间的位为0,右边区间的位为1,116.397128属于右区间,那么转换为二进制数的第1位为1。
  2. 将右区间[0,180]进行二分得到[0,90),[90,180]两个区间,同理,116.397128属于右区间,那么转换为二进制数的第2位为1。
  3. 将右区间[90,180]进行二分得到[90,135),[135,180]两个区间,同理,116.397128属于左区间,那么转换为二进制数的第3位为0。
  4. 将左区间[90,135)进行二分得到[90,112.5],[112.5,135)两个区间,同理,116.397128属于右区间,那么转换为二进制数的第4位为1。
  5. 将右区间[112.5,135)进行二分得到[112.5,123.75),[123.75,135)两个区间,同理,116.397128属于左区间,那么转换为二进制数的第5位为0。
  6. 将左区间[112.5,123.75)进行二分得到[112.5,118.125),[118.125,123.75)两个区间,同理,116.397128属于左区间,那么转换为二进制数的第6位为0。

这样不断地进行二分下去,二分的次数越多,所得到的区间也就越逼近于真实的经度或者纬度值,最终得到的GeoHash定位也就越准确,范围也就越小。

在 Redis 中,经纬度使用 52 位的无符号整数进行整体编码。经过26次的二分法,最终得到的经度的26位的二进制数就是:11010010110001010111001101,得到的纬度的26位的二进制数就是10111000110001010010100111。

第二步:将经度和纬度的二进制编码从头开始一位一位的合并,经度占奇数位,纬度占偶数位,最终得到的一个52位的无符号数:1110011101001000111100000011001100101110010010110111。

第三步,对得到的合并二进制编码进行Base32编码(编码表字符集有32个字符,0~9,a~z,去掉 a/i/l/o四个容易混淆的字母,编码时以每五个位进行一次编码,不够的填充0),让它变成一个真正的字符串,最终的结果就是“wx4g0dtf9e3”,这就是我们获取到的故宫的geihash值。可以看到该值与Redis GEO计算的值“wx4g0dtf9e0”有些许区别,因为各种GEO的实现稍有不同,但是最终的定位位置都会是在故宫的。

我们打开geohash.org/wx4g0dtf9e3…

在这里插入图片描述 确实定位在故宫!

我们再换一个日坛公园的地址(116.44381989453123, 39.915605367786505),计算得到的geoHash为wx4g1drv382,和Redis GEO的计算结果“wx4g1drv380”差不多,同样geohash.org/wx4g1drv382…

在这里插入图片描述 经过GeoHash算法,二维的经纬度点位就能够变成一个非常好保存且保密性比较好的字符串的形式,Redis GEO同样采用了上面这种GeoHash算法,并且将最终的数据存入一个Sorted set集合中,集合的每一个元素就是存入的坐标的名字(比如gugong、ritangongyuan),每个元素的score则是经纬度的52位的无符号geoHash编码的10进制整数形式。

9.2 GeoHash算法的Java实现

下面是GeoHash算法的简单Java实现。

/**
 * @author lx
 */
public class GEOHash {
    static final String ONE = "1";
    static final String ZERO = "0";
    static final String TWO = "2";
    static final String LONGITUDE_MAX = "180";
    static final String LONGITUDE_MIN = "-180";
    static final String LATITUDE_MAX = "90";
    static final String LATITUDE_MIN = "-90";
    static final int BIN_NUM = 26;
    static final int MERGE_BIN_NUM = 26 << 1;

    /**
     * Base32编码的字符串池
     */
    private final static char[] BASE_32_CHARS = {'0', '1', '2', '3', '4', '5', '6', '7', '8',
            '9', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'k', 'm', 'n', 'p',
            'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'};

    /**
     * 与十进制值的对应关系
     */
    final static HashMap<Character, Integer> BASE_32_LOOKUP = new HashMap<Character, Integer>();


    static {
        int i = 0;
        for (char c : BASE_32_CHARS) {
            BASE_32_LOOKUP.put(c, i++);
        }
    }

    public static void main(String[] args) {
        String longitude = getBin("116.44381989453123", true);
        System.out.println("longitudebin: " + longitude);
        String latitude = getBin("39.915605367786505", false);
        System.out.println("latitudebin: " + latitude);
        String merge = merge(longitude, latitude);
        System.out.println("mergebin: " + merge);
        String geoHash = base32Encode(merge);
        System.out.println("geohash: " + geoHash);
        double[] doubles = base32Decode(geoHash);
        System.out.println("longitude: " + doubles[0] + ",  latitude: " + doubles[1]);
    }

    /**
     * 经纬度的二进制编码
     */
    private static String getBin(String longitudeOrLatitude, boolean isLongitudeOrLatitude) {
        String min, max;
        if (isLongitudeOrLatitude) {
            max = LONGITUDE_MAX;
            min = LONGITUDE_MIN;
        } else {
            max = LATITUDE_MAX;
            min = LATITUDE_MIN;
        }
        BigDecimal num = new BigDecimal(longitudeOrLatitude);
        BigDecimal two = new BigDecimal(TWO), x, y, z;
        StringBuilder stringBuilder = new StringBuilder();
        if (num.compareTo(new BigDecimal(ZERO)) >= 0) {
            stringBuilder.append(ONE);
            x = new BigDecimal(ZERO);
            y = new BigDecimal(max);
        } else {
            stringBuilder.append(ZERO);
            x = new BigDecimal(min);
            y = new BigDecimal(ZERO);
        }
        z = x.add(y).divide(two);
        for (int i = 1; i < 26; i++) {
            if (num.compareTo(z) >= 0) {
                stringBuilder.append(ONE);
                x = z;
                z = x.add(y).divide(two);
            } else {
                stringBuilder.append(ZERO);
                y = z;
                z = x.add(y).divide(two);
            }
        }
        return stringBuilder.toString();
    }

    /**
     * 合并经纬度的二进制编码
     */
    private static String merge(String longitude, String latitude) {
        StringBuilder stringBuilder = new StringBuilder();
        for (int i = 0; i < BIN_NUM; i++) {
            stringBuilder.append(longitude.charAt(i));
            stringBuilder.append(latitude.charAt(i));
        }
        return stringBuilder.toString();
    }


    /**
     * geohash编码
     */
    public static String base32Encode(String longitudeAndLatitudeBinCode) {
        StringBuilder geohash = new StringBuilder();
        for (int i = 0; i < MERGE_BIN_NUM; ) {
            int j = i;
            String substring;
            if ((i += 5) > MERGE_BIN_NUM) {
                substring = longitudeAndLatitudeBinCode.substring(j, MERGE_BIN_NUM);
            } else {
                substring = longitudeAndLatitudeBinCode.substring(j, i);
            }
            int i1 = Integer.parseInt(substring, 2);
            geohash.append(BASE_32_CHARS[i1]);
        }
        return geohash.toString();
    }

    /**
     * geohash解码
     */
    public static double[] base32Decode(String geohash) {
        StringBuilder buffer = getBin(geohash);
        BitSet lonset = new BitSet();
        BitSet latset = new BitSet();

        //偶数位,经度
        int j = 0;
        for (int i = 0; i < MERGE_BIN_NUM; i += 2) {
            boolean isSet = false;
            if (i < buffer.length()) {
                isSet = buffer.charAt(i) == '1';
            }
            lonset.set(j++, isSet);
        }

        //奇数位,纬度
        j = 0;
        for (int i = 1; i < MERGE_BIN_NUM; i += 2) {
            boolean isSet = false;
            if (i < buffer.length()) {
                isSet = buffer.charAt(i) == '1';
            }
            latset.set(j++, isSet);
        }

        double lon = base32Decode(lonset, new BigDecimal(LONGITUDE_MIN), new BigDecimal(LONGITUDE_MAX));
        double lat = base32Decode(latset, new BigDecimal(LATITUDE_MIN), new BigDecimal(LATITUDE_MAX));

        return new double[]{lon, lat};
    }

    private static StringBuilder getBin(String geohash) {
        StringBuilder buffer = new StringBuilder();
        for (char c : geohash.toCharArray()) {
            int i = BASE_32_LOOKUP.get(c) + 32;
            buffer.append(Integer.toString(i, 2).substring(1));
        }
        return buffer;
    }


    private static double base32Decode(BitSet bs, BigDecimal floor, BigDecimal ceiling) {
        BigDecimal two = new BigDecimal(TWO);
        BigDecimal mid = new BigDecimal(ZERO);
        for (int i = 0; i < bs.length(); i++) {
            mid = (floor.add(ceiling).divide(two, 17, ROUND_HALF_EVEN));
            if (bs.get(i)) {
                floor = mid;
            } else {
                ceiling = mid;
            }
        }
        return mid.doubleValue();
    }
}

9.3 GeoHash算法的原理

为什么对经度和纬度进行二分然后再合并、编码后,就能使用一个一维的字符串表示一个二维的区域(点)呢?这个问题我们使用图解来回答。

首先,经纬度两个数值可以完美的映射到一个封闭的二维的平面直角坐标系中: 在这里插入图片描述

图中地球上的每一个点位的经纬度(极点除外)都能在上面封闭的图形内部找到。

根据我们此前提到的二分法,区间更小的为二进制数为0,更大的二进制数为1,如果分别对经度和纬度进行一次二分,那么经纬度的合并GeoHash二进制编码(经度和纬度的GeoHash编码长度分别为1)如下:

在这里插入图片描述

如上图,整个经纬度平面分为四块,每一块都是用不同的二进制编码,所以说,这个GeoHash实际上是代表着一个区域的编码,而不是某个“点位”,只不过,当这个区域划分得足够的小,那么就会无限趋近于一个点,定位也就越精准。

上图仅仅经历了一次二分,地球平面仅仅被分为四块,可以想象,这是远远达不到我们所需的精度的要求的。那么如果我们对经纬度进行两次二分呢?注意,每一次二分都是使用上一次二分得到的最小区域进行二分的: 在这里插入图片描述

对经纬度分别进行两次二分之后,经纬度的合并GeoHash二进制编码(经度和纬度的GeoHash编码长度分别为1)长度增长了一倍,整个地球平面即被划分为16块。

设经过x次数划分,那么合并GeoHash二进制编码长度y,x和y的关系为y=2*x。设整个地球平面就会被分为m块,那么x和m的关系为:m=4^x,这个函数的图像如下:

在这里插入图片描述

这是一个非常恐怖的指数函数,这类似于细胞分裂,随着x的增加,m的结果增加得越来越快,不需要经过很多次的二分法,即可把地球平面划分为足够小的满足经度需求的多个区域,下面的误差对照表(en.wikipedia.org/wiki/Geohas…

在这里插入图片描述

从图中可大概推算,纬度每隔0.00001度,距离相差约1.1米;经度每隔0.00001度,距离相差约1米。

在我们的GeoHash的Java实现中,对经纬度分别采用了26次二分,经纬度的编码长度都为26位,合起来之后GeoHash的编码的二进制长度为52位,转换为Base32编码的字符串长度为11(每5位进行一次编码,不够的补0),从图中可知此时误差不超过0.0001492km,即0.1492m,由于GepHash的长度没有达到55位,因此精度会低一些,但是不会超过0.5971m(为了性能考虑)。

以上这点误差(不超过1m)对于我们日常使用的:附近的人、附近的商店、加油站、骑手距离等功能来说,完全足够了,Redis GEO的GeoHash的二进制长度也是52位,编码后的字符串长度也是11位。

相关文章:

  1. redis.io

如有需要交流,或者文章有误,请直接留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!

猜你喜欢

转载自juejin.im/post/7113202149533679630