Redis Geo实战:让你轻松玩转地理位置数据存储与查询

2023年 9月 12日 64.4k 0

什么是Redis Geo?

Redis GEO(Geo Redis)是一个用于存储和操作地理空间数据的 Redis 模块。它提供了一组命令,可以将地理位置数据存储为 Redis 键值,并支持各种地理位置查询和操作。Redis GEO 可以在需要处理地理位置数据的场景中使用,例如近邻查询、地理位置路由、基于地理位置的服务等。使用 Redis GEO 可以高效地处理大量的地理位置数据,并且可以与其他 Redis 数据类型(例如列表、哈希表)结合使用,以提供更复杂的地理位置服务。

Redis Geo可以解决什么问题?

Redis Geo最主要的应用场景就是基于地理位置的服务,例如:
推荐服务中,查询附近的餐厅、酒店、景点等。
社交服务中,查询附近的好友等。
叫车服务中,查询附近的司机等。
针对这些场景Redis Geo提供了一种高效、便捷的解决方案,可以方便地处理各种基于地理位置服务的需求。

认识一下GeoHash

在讲解Redis Geo具体使用之前,我们有必要先来介绍一下GeoHash,它是Redis所采用的编码方式,是一种专门针对地址的编码方法,它将二维的经纬度数据编码成一个字符串,从而实现对地理位置的索引和存储。GeoHash算法能够将一个给定的经纬度坐标转换成一个由数字和字母组成的字符串,这个字符串代表了该坐标在二维地球表面上的位置。通过比较GeoHash字符串,就可以计算出两个坐标之间的距离或者判断某个区域是否包含某个坐标点。

GeoHash编码原理介绍

经纬度等份划分

将经度和纬度分别转换成二进制数。将经度范围[-180, 180]分成等长的2^n份,将纬度范围[-90, 90]分成等长的2^m份。比如如果将[-180, 180]拆分为2份,则表示为:[-180, 0)和[0, 180],此时目标经度如果落在[-180, 0)则用0表示,如果落在[0, 180]则用1表示。

举个例子,假设现在有一个经度值为:109.05122,纬度值为:19.292939的坐标。

按6次编码为例:

经度编码过程
image.png
二进制表示为:110011

纬度编码过程
image.png
二进制表示为:100110

二进制合并

当经纬度各自编码值都计算出来以后,我们需要对其进行合并,合并规则为,从第一位开始,奇数位取经度值,偶数位取纬度值,因此坐标[109.05122,19.292939]合并后最终的结果为:111000011110

按区域划分

前面我们按经纬度等份划分时提到,如果我们要把[-180, 180][-90, 90]分别都拆分为两份,则表示为:[-180, 0)、[0, 180][-90, 0)和[0, 90]

按0,1表示,得到如下映射关系:

[-180, 0) -> 0

[0, 180) -> 1

[-90, 0) -> 0

[0, 90) -> 1

然后我们将这4个坐标分别对应4个方格,则表示为下图所示:
image.png

因此得到如下编码值:

[-180, 0),[-90, 0)  -> 00

[0, 180),[-90, 0)  -> 10

[-180, 0),[0, 90)  -> 01

[0, 180),[0, 90)  -> 11

同理,对于坐标[109.05122,19.292939]编码后得到的结果111000011110,也一定会落在某个方格内。

计算

当每一个坐标都能按照统一的编码得到一个二进制数,并每一个二进制数也都能在落在某一个方格内,那接下来我们只需要将整个位置区域按方格划分好即可,那么落在同一个方格内的坐标肯定是最近的,相邻方格也是相对较近的,因此方格精度越高,误差也就越小。

GeoHash存在的问题

同一个方格内的坐标肯定是最近的?

考虑一下如下情况,下图中红圈和蓝圈虽然在同一个方格内,但实际上蓝圈并不是距离红圈最近的,很明显距离红圈最近的应该是绿圈。
image.png

相邻方格也是相对较近的?

这个说法实际上也并不是绝对的,如下图所示,0111与1000虽然在二进制编码上是相邻的,但实际上在地理位置上却相差较远。
image.png

因此,考虑上面两种情况的存在,所以,一般情况下并不会只在同一个方格内搜索,也并不会只查询二进制编码相邻的坐标,通常在方格搜索时会同时多查询附近几个方格,二进制编码也一样,然后作为距离远近的参考,最后还是会通过坐标计算得到最终结果。

Redis如何应用?

现在我们按照GeoHash编码,可以将经纬度坐标转换成一个二进制数,并且可以推论出与其相邻的二进制数,其在地理位置上也是相邻的,那Redis要如何利用这些信息来快速完成相邻结果的计算呢?

Sorted Set

在Sorted Set中,每个元素都会关联一个叫做score的浮点数值。这个score用于确定元素在集合中的位置,score越小,元素排名越靠前。因此,Sorted Set可以按照score来对元素进行排序。

所以,Redis Geo的底层数据结构就是Sorted Set,只需要用score来存储二进制数即可利用它完成排序以及范围查询。

GeoHash的使用方式

Redis GEOADD 命令

将指定的地理空间位置(纬度、经度、名称)添加到指定的key中

语法

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

示例

将指定经、纬度添加到指定的类型的集合中,比如:geoadd user:locations 109.05122 19.292939 1 103.05212 23.292939 2,就表示把id为1的用户且经纬度值:109.05122 19.292939和id为2的用户且经纬度值:103.05212 23.292939存放到user:locations集合中。

Redis GEORADIUS 命令

以给定的经纬度为中心, 找出某一半径内的元素

语法

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

半径单位:

M 表示单位为米。
KM 表示单位为千米。
FT 表示单位为英尺。
MI 表示单位为英里。

可选性参数:

WITHDIST: 在返回位置元素的同时, 将位置元素与中心之间的距离也一并返回。 距离的单位和用户给定的范围单位保持一致。
WITHCOORD: 将位置元素的经度和维度也一并返回。
WITHHASH: 以 52 位有符号整数的形式, 返回位置元素经过原始 geohash 编码的有序集合分值。 这个选项主要用于底层应用或者调试, 实际中的作用并不大。
命令默认返回未排序的位置元素。 通过以下两个参数, 用户可以指定被返回位置元素的排序方式:

ASC: 根据中心的位置, 按照从近到远的方式返回位置元素。
DESC: 根据中心的位置, 按照从远到近的方式返回位置元素。
在默认情况下, GEORADIUS 命令会返回所有匹配的位置元素。 虽然用户可以使用 COUNT count 选项去获取前 N 个匹配元素, 但是因为命令在内部可能会需要对所有被匹配的元素进行处理, 所以在对一个非常大的区域进行搜索时, 即使只使用 COUNT 选项去获取少量元素, 命令的执行速度也可能会非常慢。 但是从另一方面来说, 使用 COUNT 选项去减少需要返回的元素数量, 对于减少带宽来说仍然是非常有用的。

示例

假设,我们现在需要根据给定经纬度,由近到远显示附近前10条1000KM范围内的信息,那么,可以这样写:georadius user:locations 110.05152 20.293939 1000 km withcoord withdist count 10 asc

Redis GEORADIUSBYMEMBER 命令

找出位于指定范围内的元素,中心点是由给定的位置元素决定

语法

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

此命令与GEORADIUS完全相同,唯一的区别是 将经度和纬度值作为要查询的区域的中心,它采用排序集表示的地理空间索引中已存在的成员的名称。

Redis GEODIST 命令

返回两个给定位置之间的距离

语法

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

如果用户没有显式地指定单位参数, 那么 GEODIST 默认使用米作为单位。
GEODIST 命令在计算距离时会假设地球为完美的球形, 在极限情况下, 这一假设最大会造成 0.5% 的误差。

代码演示

import org.springframework.data.geo.*;
import org.springframework.data.redis.connection.RedisGeoCommands;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

@RestController
@RequestMapping(value = "/geo")
public class Test {

    @Resource
    private RedisTemplate redisTemplate;

    private static final String GEO_KEY = "TEST:LOCATION";

    @RequestMapping("/init")
    public void init() {
        redisTemplate.opsForGeo().add(GEO_KEY, new Point(118.79581, 32.02636), "夫子庙风光带");
        redisTemplate.opsForGeo().add(GEO_KEY, new Point(118.79400, 32.01800), "老门东");
        redisTemplate.opsForGeo().add(GEO_KEY, new Point(118.78787, 32.00351), "雨花台");
        redisTemplate.opsForGeo().add(GEO_KEY, new Point(118.84109, 32.05200), "明孝陵");
        redisTemplate.opsForGeo().add(GEO_KEY, new Point(118.85913, 32.06904), "中山陵");
        redisTemplate.opsForGeo().add(GEO_KEY, new Point(119.01724, 32.08044), "江苏园博园");
    }

    @RequestMapping("/m")
    public void 添加位置信息(@RequestBody GeoQuery geoQuery) {
        redisTemplate.opsForGeo().add(GEO_KEY, new Point(geoQuery.getX(), geoQuery.getY()), geoQuery.member);
        System.out.println("成功添加:" + geoQuery.member + ",坐标:" + geoQuery.getX() + "," + geoQuery.getY());
    }

    @RequestMapping("/m1")
    public void 查询指定范围内的景点(@RequestBody GeoQuery geoQuery) {
        Circle circle = new Circle(new Point(geoQuery.getX(), geoQuery.getY()), new Distance(geoQuery.getValue(), RedisGeoCommands.DistanceUnit.METERS));

        // 按近距离排序,查询前10条
        RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()
                .includeDistance().includeCoordinates().sortAscending().limit(10);

        GeoResults results = redisTemplate.opsForGeo().radius(GEO_KEY, circle, args);

        System.out.println("相距坐标:" + geoQuery.getX() + "," + geoQuery.getY() + "," + geoQuery.getValue() + "米范围内景点如下:");

        for (GeoResult geoResult : results) {
            System.out.println("景点名称:" + geoResult.getContent().getName() + ",相距:" + geoResult.getDistance().getValue() + "米");
        }
    }

    @RequestMapping("/m2")
    public void 查询两个位置相隔的距离(@RequestBody GeoQuery geoQuery) {
        Distance distance = redisTemplate.opsForGeo().distance(GEO_KEY, geoQuery.getMember1(), geoQuery.getMember2(), RedisGeoCommands.DistanceUnit.METERS);
        System.out.println(geoQuery.getMember1() + "," + geoQuery.getMember2() + ",相距:" + distance.getValue() + "米");
    }

    @RequestMapping("/m3")
    public void 根据member查询指定范围内的景点(@RequestBody GeoQuery geoQuery) {
        // 按近距离排序,查询前10条
        RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()
                .includeDistance().includeCoordinates().sortAscending().limit(10);
        GeoResults results =
                redisTemplate.opsForGeo().radius(GEO_KEY, geoQuery.member, new Distance(geoQuery.getValue(), RedisGeoCommands.DistanceUnit.METERS), args);

        System.out.println("相距景点:" + geoQuery.member + "," + geoQuery.getValue() + "米范围内景点如下:");

        for (GeoResult geoResult : results) {
            System.out.println("景点名称:" + geoResult.getContent().getName() + ",相距:" + geoResult.getDistance().getValue() + "米");
        }
    }

}
请求【添加位置信息】方法    
    成功添加:鸡鸣寺,坐标:118.80164,32.06684
请求【添加位置信息】方法        
    成功添加:玄武湖,坐标:118.79913,32.08073
    
请求【查询指定范围内的景点】方法        
    相距坐标:118.79596,32.02629,5000.0米范围内景点如下:
    景点名称:夫子庙风光带,相距:15.9604米
    景点名称:老门东,相距:940.3401米
    景点名称:雨花台,相距:2646.2043米
    景点名称:鸡鸣寺,相距:4541.8586米
    
请求【查询两个位置相隔的距离】方法    
    老门东,雨花台,相距:1712.383米
    
请求【根据member查询指定范围内的景点】方法        
    相距景点:夫子庙风光带,10000.0米范围内景点如下:
    景点名称:夫子庙风光带,相距:0.0米
    景点名称:老门东,相距:945.3886米
    景点名称:雨花台,相距:2649.7197米
    景点名称:鸡鸣寺,相距:4535.7611米
    景点名称:明孝陵,相距:5133.7084米
    景点名称:玄武湖,相距:6055.4184米
    景点名称:中山陵,相距:7626.6901米

Redis Geo逻辑封装

测试代码


import org.springframework.data.redis.connection.RedisGeoCommands;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.util.List;

@RestController
@RequestMapping(value = "/geo2")
public class Test2 {

    @Resource
    private RedisService redisService;

    private static final String GEO_KEY = "TEST:LOCATION:NEW";

    @RequestMapping("/init")
    public void init() {
        redisService.geoAdd(GEO_KEY, 118.79581, 32.02636, "夫子庙风光带");
        redisService.geoAdd(GEO_KEY, 118.79400, 32.01800, "老门东");
        redisService.geoAdd(GEO_KEY, 118.78787, 32.00351, "雨花台");
        redisService.geoAdd(GEO_KEY, 118.84109, 32.05200, "明孝陵");
        redisService.geoAdd(GEO_KEY, 118.85913, 32.06904, "中山陵");
        redisService.geoAdd(GEO_KEY, 119.01724, 32.08044, "江苏园博园");
    }

    @RequestMapping("/m")
    public void 添加位置信息(@RequestBody GeoQuery geoQuery) {
        long result = redisService.geoAdd(GEO_KEY, geoQuery.getX(), geoQuery.getY(), geoQuery.getMember());
        if (result == -1) {
            System.out.println("处理异常情况。。。");
            return;
        }

        if (result == 0) {
            System.out.println("成功更新:" + geoQuery.getMember() + ",坐标:" + geoQuery.getX() + "," + geoQuery.getY());
            return;
        }

        System.out.println("成功添加:" + geoQuery.getMember() + ",坐标:" + geoQuery.getX() + "," + geoQuery.getY());
    }

    @RequestMapping("/m1")
    public void 查询指定范围内的景点(@RequestBody GeoRadiusQuery geoRadiusQuery) {
        geoRadiusQuery.setKey(GEO_KEY);
        geoRadiusQuery.setLimit(geoRadiusQuery.getLimit());
        geoRadiusQuery.setSort(geoRadiusQuery.getSort());
        geoRadiusQuery.setMetric(RedisGeoCommands.DistanceUnit.METERS);

        List geoRadiusInfoList = redisService.radius(geoRadiusQuery);

        System.out.println("相距坐标:" + geoRadiusQuery.getLongitude() + "," + geoRadiusQuery.getLatitude() + "," + geoRadiusQuery.getValue() + "米范围内景点如下:");

        for (GeoRadiusInfo geoResult : geoRadiusInfoList) {
            System.out.println("景点名称:" + geoResult.getName() + "," +
                    "经纬度坐标:【" + geoResult.getLongitude() + "," + geoResult.getLatitude() + "】," +
                    "相距:" + geoResult.getValue() + "米");
        }
    }

    @RequestMapping("/m2")
    public void 查询两个位置相隔的距离(@RequestBody GeoQuery geoQuery) {
        double value = redisService.distance(GEO_KEY, geoQuery.getMember1(), geoQuery.getMember2(), RedisGeoCommands.DistanceUnit.METERS);
        System.out.println(geoQuery.getMember1() + "," + geoQuery.getMember2() + ",相距:" + value + "米");
    }

    @RequestMapping("/m3")
    public void 根据member查询指定范围内的景点(@RequestBody GeoRadiusQuery geoRadiusQuery) {

        geoRadiusQuery.setKey(GEO_KEY);
        geoRadiusQuery.setLimit(geoRadiusQuery.getLimit());
        geoRadiusQuery.setSort(geoRadiusQuery.getSort());
        geoRadiusQuery.setMetric(RedisGeoCommands.DistanceUnit.METERS);
        geoRadiusQuery.setMember(geoRadiusQuery.getMember());

        List geoRadiusInfoList = redisService.radiusByMember(geoRadiusQuery);

        System.out.println("相距景点:" + geoRadiusQuery.getMember() + "," + geoRadiusQuery.getValue() + "米范围内景点如下:");

        for (GeoRadiusInfo geoResult : geoRadiusInfoList) {
            System.out.println("景点名称:" + geoResult.getName() + "," +
                    "经纬度坐标:【" + geoResult.getLongitude() + "," + geoResult.getLatitude() + "】," +
                    "相距:" + geoResult.getValue() + "米");
        }
    }

}

Geo逻辑封装到RedisService中


import org.springframework.data.geo.*;
import org.springframework.data.redis.connection.RedisGeoCommands;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List;

@Component
public class RedisService {

    @Resource
    private RedisTemplate redisTemplate;

    public long geoAdd(String key, double longitude, double latitude, String member) {
        Long num = redisTemplate.opsForGeo().add(key, new Point(longitude, latitude), member);
        if (num == null) {
            return -1;
        }
        return num;
    }

    public List radius(GeoRadiusQuery geoRadiusQuery) {
        Circle circle = new Circle(
                new Point(geoRadiusQuery.getLongitude(), geoRadiusQuery.getLatitude()),
                new Distance(geoRadiusQuery.getValue(), geoRadiusQuery.getMetric()));

        RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()
                .includeDistance().includeCoordinates();

        if (geoRadiusQuery.getSort() == 1) {
            args.sortDescending();
        } else {
            args.sortAscending();
        }

        args.limit(geoRadiusQuery.getLimit() > 0 ? geoRadiusQuery.getLimit() : 10);

        GeoResults results = redisTemplate.opsForGeo().radius(geoRadiusQuery.getKey(), circle, args);

        List result = new ArrayList();

        if (results == null) {
            return result;
        }

        for (GeoResult geoResult : results) {
            GeoRadiusInfo geoRadiusInfo = new GeoRadiusInfo();
            geoRadiusInfo.setName(String.valueOf(geoResult.getContent().getName()));
            geoRadiusInfo.setLongitude(geoResult.getContent().getPoint().getX());
            geoRadiusInfo.setLatitude(geoResult.getContent().getPoint().getY());
            geoRadiusInfo.setValue(geoResult.getDistance().getValue());
            result.add(geoRadiusInfo);
        }

        return result;
    }

    public double distance(String key, String member1, String member2, Metric metric) {
        Distance distance = redisTemplate.opsForGeo().distance(key, member1, member2, metric);
        return distance == null ? -1 : distance.getValue();
    }


    public List radiusByMember(GeoRadiusQuery geoRadiusQuery) {

        RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()
                .includeDistance().includeCoordinates();

        if (geoRadiusQuery.getSort() == 1) {
            args.sortDescending();
        } else {
            args.sortAscending();
        }

        args.limit(geoRadiusQuery.getLimit() > 0 ? geoRadiusQuery.getLimit() : 10);


        GeoResults results =
                redisTemplate.opsForGeo().radius(geoRadiusQuery.getKey(), geoRadiusQuery.getMember(),
                        new Distance(geoRadiusQuery.getValue(), geoRadiusQuery.getMetric()), args);

        List result = new ArrayList();

        if (results == null) {
            return result;
        }

        for (GeoResult geoResult : results) {
            GeoRadiusInfo geoRadiusInfo = new GeoRadiusInfo();
            geoRadiusInfo.setName(String.valueOf(geoResult.getContent().getName()));
            geoRadiusInfo.setLongitude(geoResult.getContent().getPoint().getX());
            geoRadiusInfo.setLatitude(geoResult.getContent().getPoint().getY());
            geoRadiusInfo.setValue(geoResult.getDistance().getValue());
            result.add(geoRadiusInfo);
        }

        return result;
    }

}

相关对象:GeoRadiusQuery

import lombok.Data;
import org.springframework.data.geo.Metric;

@Data
public class GeoRadiusQuery {

    private String key;
    private double longitude;
    private double latitude;
    private double value;
    private int limit;
    private int sort;
    private String member;
    private Metric metric;

}

相关对象:GeoRadiusInfo

import lombok.Data;

@Data
public class GeoRadiusInfo {

    private String name;
    
    private double longitude;

    private double latitude;

    private double value;


}

相关对象:GeoQuery

@Data
public class GeoQuery {

    // 经度
    private double x;
    // 纬度
    private double y;
    // 范围大小
    private double value;
    
    private String member;

    private String member1;
    private String member2;
}

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论