Bootstrap

【Java、Redis】通过中心经纬度与半径获取范围内的结果集(类似附近的人)

需求

通过百度地图的覆盖物功能,用户在页面上画圈选定某个区域,前端传输中心点经纬度与半径给后端,后端需要返回位置在圈内的设备

在这里插入图片描述

解决方案

经过网上查阅资料,最终决定使用Redis + GeoHash来做,效率也很高

什么是Redis + GeoHash

redis 实现附近的人功能主要通过Geo模块的六个命令

关键字解释
GEOADD将给定的位置对象(纬度、经度、名字)添加到指定的key
GEOPOS从key里面返回所有给定位置对象的位置(经度和纬度)
GEODIST返回两个给定位置之间的距离
GEOHASH返回一个或多个位置对象的Geohash表示
GEORADIUS以给定的经纬度为中心,返回目标集合中与中心的距离不超过给定最大距离的所有位置对象
GEORADIUSBYMEMBER以给定的位置对象为中心,返回与其距离不超过给定最大距离的所有位置对象

以GEOADD 命令和GEORADIUS 命令简单举例:

GEOADD key longitude latitude member [longitude latitude member …]

其中,key为集合名称,member为该经纬度所对应的对象。
GEOADD 添加多个商户“火锅店”位置信息:

GEOADD hotel 119.98866180732716 30.27465803229662 火锅店

GEORADIUS 根据给定的经纬度为中心,获取目标集合中与中心的距离不超过给定最大距离(500米内)的所有位置对象,也就是“附近的人”。

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

范围单位:m | km | ft | mi --> 米 | 千米 | 英尺 | 英里

关键字解释
WITHDIST在返回位置对象的同时,将位置对象与中心之间的距离也一并返回。距离的单位和用户给定的范围单位保持一致
WITHCOORD将位置对象的经度和维度也一并返回
WITHHASH以 52 位有符号整数的形式,返回位置对象经过原始 geohash 编码的有序集合分值。这个选项主要用于底层应用或者调试,实际中的作用并不大
ASC DESC从近到远返回位置对象元素 、从远到近返回位置对象元素
COUNT count选取前N个匹配位置对象元素。(不设置则返回所有元素)
STORE key将返回结果的地理位置信息保存到指定key
STORedisT key将返回结果离中心点的距离保存到指定key

例如下边命令:获取当前位置周边500米内的所有饭店

GEORADIUS hotel 119.98866180732716 30.27465803229662 500 m WITHCOORD

Redis内部使用有序集合(zset)保存用户的位置信息,zset中每个元素都是一个带位置的对象,元素的score值为通过经、纬度计算出的52位geohash值

1、Java + Redis实现

建立一个任务,当项目启动时,把大量设备的经纬度信息存入redis中

注意:

有效的经度从-180度到180度。有效的纬度从-85.05112878度到85.05112878度。 当坐标位置超出上述指定范围时,该命令将会返回一个错误。

引用的pom依赖

<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-redis</artifactId>
    <version>2.3.9.RELEASE</version>
</dependency>
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>3.3.0</version>
</dependency>

InitEquLongLatTask.java

/**
 * 项目启动初始化设备的经纬度到redis中(以圆形中心经纬度 获取在中心半径以内的设备数据)
 *
 * @author xiegege
 * @date 2023/1/12/012 14:21
 */
@Component
public class InitEquLongLatTask implements ApplicationListener<ContextRefreshedEvent> {

    private static final Logger logger = LoggerFactory.getLogger(InitEquLongLatTask.class);

    @Value("${server.initCache:true}")
    boolean initCache = true;
    public final static String REDIS_KEY = "equLongLat-task";

    @Autowired
    private RedisTemplate redisTemplate;
    @Autowired
    private IbmsEqdBasicDao eqdBasicDao;


    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        ApplicationContext context = event.getApplicationContext().getParent();
        if (context == null && initCache) {
            logger.info("++++++++ 开始:初始化设备的经纬度到redis中 ++++++++");
            int size = 0;
            try {
                // 删除该key的所有值
                redisTemplate.delete(REDIS_KEY);
                List<IbmsEqdBasic> eqdBasicList = eqdBasicDao.getAllEquLongLat();
                // List<IbmsEqdBasic> eqdBasicList = new ArrayList<>();
                eqdBasicList.forEach(item -> {
                    // 将用户地理位置信息存入 Redis
                    RedisGeoCommands.GeoLocation geoLocation =
                            new RedisGeoCommands.GeoLocation(
                                    item.getEqudatasysid(),
                                    // Point(经度, 纬度)
                                    new Point(Double.parseDouble(item.getGislong()), Double.parseDouble(item.getGislat()))
                            );
                    redisTemplate.opsForGeo().add(REDIS_KEY, geoLocation);
                });
                size = eqdBasicList.size();
            } catch (Exception e) {
                logger.error("++++++++ 失败:初始化设备的经纬度到redis中 ++++++++", e);
            }
            logger.info("++++++++ 结束:初始化设备的经纬度到redis中【共{}台设备】 ++++++++", size);
        }
    }
}

Controller

@Controller
@RequestMapping(value = "${apiPath}/hp/heavyProtect")
public class HeavyProtectController extends BaseController {

    @Autowired
    private HeavyProtectService heavyProtectService;

	/**
     * 获取命中的设备经纬度(redis方式)
     *
     * @param longitude 圆心经度
     * @param latitude  圆心纬度
     * @param radius    半径距离/米
     * @return 命中集合
     */
    @RequestMapping("getHitEquList")
    @ResponseBody
    public Map<String, Object> getHitEquList(String longitude, String latitude, String radius) {
        if (StringUtils.isBlank(longitude)) {
            return error("请传入经度");
        }
        if (StringUtils.isBlank(latitude)) {
            return error("请传入纬度");
        }
        if (radius == null) {
            return error("请传入半径距离/米");
        }
        double longitudeD = Double.parseDouble(longitude);
        double latitudeD = Double.parseDouble(latitude);
        double radiusD = Double.parseDouble(radius);
        logger.info("getHitEquList===经度:{}===纬度:{}===半径:{}m", longitudeD, latitudeD, radiusD);
        if (!(longitudeD >= -180 && longitudeD <= 180)) {
            return error("经度不合法");
        }
        if (!(latitudeD >= -85.05112878 && latitudeD <= 85.05112878)) {
            return error("纬度不合法");
        }
        List<IbmsEqdBasic> hitEquList = heavyProtectService.getHitEquList(longitudeD, latitudeD, radiusD);
        return success("获取数据成功", hitEquList);
    }
    
}

Service

@Service
@Transactional(readOnly = true)
public class HeavyProtectService {

    private static final Logger logger = LoggerFactory.getLogger(HeavyProtectService.class);

    @Autowired
    private RedisTemplate redisTemplate;

    public List<IbmsEqdBasic> getHitEquList(Double longitude, Double latitude, Double radius) {
        List<IbmsEqdBasic> hitEquList = new ArrayList<>();
        // 初始化 Geo 命令参数对象
        RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs();
        // 限制50台设备,包含距离、包含坐标、按由近到远排序
        // args.limit(50).includeDistance().includeCoordinates().sortAscending();
        args.includeDistance().includeCoordinates().sortAscending();
        // 经纬度为圆心,范围前端传来的半径/米
        Point point = new Point(longitude, latitude);
        // 初始化距离对象,单位/米
        Distance distance = new Distance(radius, RedisGeoCommands.DistanceUnit.METERS);
        Circle circle = new Circle(point, distance);
        // 获取附近的设备 GeoLocation 信息
        GeoResults<RedisGeoCommands.GeoLocation> geoResults = redisTemplate.opsForGeo().radius(InitEquLongLatTask.REDIS_KEY, circle, args);
        List<GeoResult<RedisGeoCommands.GeoLocation>> contentList = geoResults.getContent();
        if (CollectionUtils.isNotEmpty(contentList)) {
            // 封装数据
            contentList.forEach(content -> {
                RedisGeoCommands.GeoLocation<String> geoLocation = content.getContent();
                IbmsEqdBasic eqdBasic = new IbmsEqdBasic();
                eqdBasic.setEqudatasysid(geoLocation.getName());
                eqdBasic.setGislong(String.valueOf(geoLocation.getPoint().getX()));
                eqdBasic.setGislat(String.valueOf(geoLocation.getPoint().getY()));
                // 获取距离
                // double dist = content.getDistance().getValue();
                // 四舍五入精确到小数点后 1 位,方便客户端显示
                // String distanceStr = NumberUtil.round(dist, 1).toString() + "m";
                // eqdBasic.setDistance(distanceStr);
                hitEquList.add(eqdBasic);
            });
        }
        logger.info("getHitEquList===命中设备{}台", hitEquList.size());
        return hitEquList;
    }
}

dao

List<IbmsEqdBasic> getAllEquLongLat();

xml sql语句

<select id="getAllEquLongLat" resultType="IbmsEqdBasic">
        select
            a.equdatasysid,
            a.gislong,
            a.gislat
        from ibms_brm_eqm_eqd_basic a
        where a.is_del='0'
        and a.gislong is not null
        and a.gislat is not null
        <![CDATA[
            and (a.gislong >= -180 and a.gislong <= 180)
            and (a.gislat >= -85.05112878 and a.gislat <= 85.05112878)
        ]]>
<!--        -180 <= a.gislong <= 180-->
<!--        -85.05112878 <= a.gislat <= 85.05112878-->
</select>

2、Java + 数据库实现

此种方式是纯基于数据库实现的,未使用GeoHash算法

设计思路
以用户为中心,假设给定一个500米的距离作为半径画一个圆,这个圆型区域内的所有用户就是符合用户要求的 “附近的人”。但有一个问题是圆形有弧度啊,直接搜索圆形区域难度太大,根本无法用经、纬度直接搜索。

但如果在圆形外套上一个正方形,通过获取用户经、纬度的最大最小值(经、纬度 + 距离),再根据最大最小值作为筛选条件,就很容易将正方形内的用户信息搜索出来。

那么问题又来了,多出来一些面积肿么办?

我们来分析一下,多出来的这部分区域内的用户,到圆点的距离一定比圆的半径要大,那么我们就计算用户中心点与正方形内所有用户的距离,筛选出所有距离小于等于半径的用户,圆形区域内的所用户即符合要求的“附近的人”。

在这里插入图片描述

引用的pom依赖

<dependency>
   <groupId>com.spatial4j</groupId>
   <artifactId>spatial4j</artifactId>
   <version>0.5</version>
</dependency>

Controller

@Controller
@RequestMapping(value = "${apiPath}/hp/heavyProtect")
public class HeavyProtectController extends BaseController {

    @Autowired
    private HeavyProtectService heavyProtectService;

    /**
     * 获取命中的设备经纬度(redis方式)
     *
     * @param longitude 圆心经度
     * @param latitude  圆心纬度
     * @param radius    半径距离/米
     * @return 命中集合
     */
    @RequestMapping("getHitEquList")
    @ResponseBody
    public Map<String, Object> getHitEquList(String longitude, String latitude, String radius) {
        if (StringUtils.isBlank(longitude)) {
            return error("请传入经度");
        }
        if (StringUtils.isBlank(latitude)) {
            return error("请传入纬度");
        }
        if (radius == null) {
            return error("请传入半径距离/米");
        }
        double longitudeD = Double.parseDouble(longitude);
        double latitudeD = Double.parseDouble(latitude);
        double radiusD = Double.parseDouble(radius);
        logger.info("getHitEquList===经度:{}===纬度:{}===半径:{}m", longitudeD, latitudeD, radiusD);
        if (!(longitudeD >= -180 && longitudeD <= 180)) {
            return error("经度不合法");
        }
        if (!(latitudeD >= -85.05112878 && latitudeD <= 85.05112878)) {
            return error("纬度不合法");
        }
        List<IbmsEqdBasic> hitEquList = heavyProtectService.getHitEquList(longitudeD, latitudeD, radiusD);
        return success("获取数据成功", hitEquList);
    }

    /**
     * 获取命中的设备经纬度(数据库查询方式:先获取位置在正方形内的所有设备,再剔除半径超过指定距离的多余设备)
     *
     * @param longitude 圆心经度
     * @param latitude  圆心纬度
     * @param radius    半径/米
     * @return 命中集合
     */
    @RequestMapping("getHitEquListByDB")
    @ResponseBody
    public Map<String, Object> getHitEquListByDB(String longitude, String latitude, String radius) {
        if (StringUtils.isBlank(longitude)) {
            return error("请传入经度");
        }
        if (StringUtils.isBlank(latitude)) {
            return error("请传入纬度");
        }
        if (radius == null) {
            return error("请传入半径");
        }
        double longitudeD = Double.parseDouble(longitude);
        double latitudeD = Double.parseDouble(latitude);
        double radiusD = Double.parseDouble(radius);
        logger.info("getHitEquListByDB===经度:{}===纬度:{}===半径:{}m", longitudeD, latitudeD, radiusD);
        List<IbmsEqdBasic> hitEquList = heavyProtectService.getHitEquListByDB(longitudeD, latitudeD, radiusD);
        return success("获取数据成功", hitEquList);
    }

}

Service

@Service
@Transactional(readOnly = true)
public class HeavyProtectService {

    private static final Logger logger = LoggerFactory.getLogger(HeavyProtectService.class);

    private final SpatialContext spatialContext = SpatialContext.GEO;

    @Autowired
    private RedisTemplate redisTemplate;
    @Autowired
    private IbmsEqdBasicDao eqdBasicDao;


    public List<IbmsEqdBasic> getHitEquList(Double longitude, Double latitude, Double radius) {
        List<IbmsEqdBasic> hitEquList = new ArrayList<>();
        // 初始化 Geo 命令参数对象
        RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs();
        // 限制50台设备,包含距离、包含坐标、按由近到远排序
        // args.limit(50).includeDistance().includeCoordinates().sortAscending();
        args.includeDistance().includeCoordinates().sortAscending();
        // 经纬度为圆心,范围前端传来的半径/米
        Point point = new Point(longitude, latitude);
        // 初始化距离对象,单位/米
        Distance distance = new Distance(radius, RedisGeoCommands.DistanceUnit.METERS);
        Circle circle = new Circle(point, distance);
        // 获取附近的设备 GeoLocation 信息
        GeoResults<RedisGeoCommands.GeoLocation> geoResults = redisTemplate.opsForGeo().radius(InitEquLongLatTask.REDIS_KEY, circle, args);
        List<GeoResult<RedisGeoCommands.GeoLocation>> contentList = geoResults.getContent();
        if (CollectionUtils.isNotEmpty(contentList)) {
            // 封装数据
            contentList.forEach(content -> {
                RedisGeoCommands.GeoLocation<String> geoLocation = content.getContent();
                IbmsEqdBasic eqdBasic = new IbmsEqdBasic();
                eqdBasic.setEqudatasysid(geoLocation.getName());
                eqdBasic.setGislong(String.valueOf(geoLocation.getPoint().getX()));
                eqdBasic.setGislat(String.valueOf(geoLocation.getPoint().getY()));
                // 获取距离
                // double dist = content.getDistance().getValue();
                // 四舍五入精确到小数点后 1 位,方便客户端显示
                // String distanceStr = NumberUtil.round(dist, 1).toString() + "m";
                // eqdBasic.setDistance(distanceStr);
                hitEquList.add(eqdBasic);
            });
        }
        logger.info("getHitEquList===命中设备{}台", hitEquList.size());
        return hitEquList;
    }


    public List<IbmsEqdBasic> getHitEquListByDB(Double longitude, Double latitude, Double radius) {
        // KM_TO_DEG  DEG_TO_KM是km计算的,所以要把米转换成千米  /1000
        double divisor = 1000.0;
        double radiusKm = radius / divisor;
        // 1.获取外接正方形
        Rectangle rectangle = getRectangle(radiusKm, longitude, latitude);
        // 2.获取位置在正方形内的所有设备
        List<IbmsEqdBasic> hitEquList = eqdBasicDao.getHitEquListByDB(rectangle.getMinX(), rectangle.getMaxX(), rectangle.getMinY(), rectangle.getMaxY());
        // 3.剔除半径超过指定距离的多余设备
        hitEquList = hitEquList.stream()
                .filter(equ ->
                        getDistance(
                                Double.parseDouble(equ.getGislong()),
                                Double.parseDouble(equ.getGislat()),
                                longitude,
                                latitude) <= radiusKm).collect(Collectors.toList());
        logger.info("getHitEquListByDB===命中设备{}台", hitEquList.size());
        return hitEquList;
    }

    /**
     * 获取外接正方形的最大最小经纬度
     *
     * @param radius    半径/距离
     * @param longitude 圆心经度
     * @param latitude  圆心纬度
     */
    private Rectangle getRectangle(double radius, double longitude, double latitude) {
        return spatialContext.getDistCalc()
                .calcBoxByDistFromPt(spatialContext.makePoint(longitude, latitude),
                        radius * DistanceUtils.KM_TO_DEG, spatialContext, null);
    }

    /***
     * 球面中,两点间的距离
     *
     * @param equGislong    设备经度
     * @param equGislat     设备纬度
     * @param longitude     圆心经度
     * @param latitude      圆心纬度
     * @return 返回距离,单位km
     */
    private double getDistance(Double equGislong, Double equGislat, double longitude, double latitude) {
        return spatialContext.calcDistance(spatialContext.makePoint(longitude, latitude),
                spatialContext.makePoint(equGislong, equGislat)) * DistanceUtils.DEG_TO_KM;
    }
}

dao

List<IbmsEqdBasic> getHitEquListByDB(@Param("minLongitude") double minLongitude, @Param("maxLongitude") double maxLongitude, @Param("minLatitude") double minLatitude, @Param("maxLatitude") double maxLatitude);

xml sql语句

<select id="getHitEquListByDB" resultType="IbmsEqdBasic">
    select
        a.equdatasysid,
        a.gislong,
        a.gislat
    from ibms_brm_eqm_eqd_basic a
    where a.is_del='0'
    and a.gislong is not null
    and a.gislat is not null
    and (a.gislong between ${minLongitude} and ${maxLongitude})
    and (a.gislat between ${minLatitude} and ${maxLatitude})
</select>

总结:数据量比较大推荐使用redis效率比较高。使用Java + 数据库实现会频繁调用持久层,需要大量的计算两个点之间的距离,非常影响性能。数据量比较少可以使用Java + 数据库实现,代码比较简单,速度也还不错。

;