MySQL附近功能

MySQL 实现附近人功能纯属学习,并不好用,推荐使用 Search,方案对比:

方案

优势

缺点

Mysql外接正方形

逻辑清晰,实现简单,支持多条件筛选

效率较低,不适合大数据量,不支持按距离排序

Mysql+Geohash

借助索引有效提高效率,支持多条件筛选

不支持按距离排序,存在数据库瓶颈

Redis+Geohash

效率高,集成便捷,支持距离排序

不适合复杂对象存储,不支持多条件查询

ElasticSearch

集成了 Redis 和 MySQL 的有点既可以条件筛选,距离排序,也可以复杂对象的存储

 

 

MySQL依然可以实现附近的人的功能,两种方案,

方案一:Mysql+外接正方形

实现思路:

以当前用户为圆心,以给定距离为半径画圆,那么在这个圆内的所有用户信息就是符合结果的信息,直接检索圆内的用户坐标难以实现,

我们可以通过获取这个圆的外接正方形。

通过外接正方形,获取经度和纬度的最大最小值,根据最大最小值可以将坐标在正方形内的用户信息搜索出来。

此时在外接正方形中不属于圆形区域的部分就属于多余的部分,这部分用户信息距离当前用户(圆心)的距离必定是大于给定半径的,

故可以将其剔除,最终获得指定范围内的附近的人

 

存储经纬度坐标用的数类型:数据类型为double(10,6)

 

将地理位置的经度、纬度 分开存储在数据库中:

MySQL附近功能

 

 

 

根据定位中心和半径范围,得出经度的最大值最小值,纬度的最大值最小值,即一个正方形范围,得到范围内的对象集合,

再遍历对象集合,分别计算每个对象的距离是否满足距离小于等于半径,过滤掉不满足条件的对象,即可实现附近的人

 MySQL附近功能MySQL附近功能

 

求距离 S 半径内的对象时有一部分对象时在圆之外的四个三角区域,若换个思路,求S1 半径范围内的对象

MySQL附近功能

这样得到的对象就都是范围内的对象,若 S1 范围内的对象不够再查找 S 范围的对象进行过滤

 MySQL附近功能

 

 

 

 计算给定中心坐标,半径范围内的经纬度范围(单位需要统一,用米/或者KM都可以)

 

public class GeoUtils {
   //地球半径(米)
   private static final double EARTH_RADIUS = 6371000d;
   //弧度
   public static final double RADIANS_TO_DEGREES = 180 / Math.PI;
   public static final double DEGREES_TO_RADIANS = Math.PI / 180;
 
 
   /**
    * 根据中心点经纬度,计算半径范围内的经纬度范围
    *
    * @param centerLon 中心点经度
    * @param centerLat 中心点纬度
    * @param dist      半径范围(米)
    * @return
    */
   public static Map<String, Double> findPosition(double centerLon, double centerLat, double dist) {
 
      //先计算查询点的经纬度范围
      double lonRad = 2 * Math.asin(Math.sin(dist / (2 * EARTH_RADIUS)) / Math.cos(centerLat * Math.PI / 180));
      //角度转为弧度
      lonRad = lonRad * RADIANS_TO_DEGREES;
      double latRad = dist / EARTH_RADIUS;
      latRad = latRad * 180 / Math.PI;
      //经度最小值
      double minLon = centerLon - lonRad;
      //经度最大值
      double maxLon = centerLon + lonRad;
      //纬度最小值
      double minLat = centerLat - latRad;
      //纬度最大值
      double maxLat = centerLat + latRad;
 
      HashMap<String, Double> geoMap = new HashMap<>();
      geoMap.put("minLat", minLat);
      geoMap.put("maxLat", maxLat);
      geoMap.put("minLon", minLon);
      geoMap.put("maxLon", maxLon);
      return geoMap;
   }
   
   /**
    * 求地球表面两点间的距离
    *
    * @param centerLon 中心点经度
    * @param centerLat 中心点纬度
    * @param targetLon 目标经度
    * @param targetLat 目标纬度
    * @return
    */
   public static double getDistance(double centerLon, double centerLat, double targetLon, double targetLat) {
 
      //计算中心点经纬度弧度
      double centerLonRad = centerLon * DEGREES_TO_RADIANS;
      double centerLatRad = centerLat * DEGREES_TO_RADIANS;
      //计算目标点经纬度弧度
      double targetLonRad = targetLon * DEGREES_TO_RADIANS;
      double targetLatRad = targetLat * DEGREES_TO_RADIANS;
      //计算经纬度的差
      double diffX = Math.abs(centerLonRad - targetLonRad);
      double diffY = Math.abs(centerLatRad - targetLatRad);
      //计算正弦
      double hsinX = Math.sin(diffX / 2);
      double hsinY = Math.sin(diffY / 2);
      //计算余弦
      double centerLatRad_cos = Math.cos(centerLatRad);
      double targetLatRad_cos = Math.cos(targetLatRad);
 
      double h = power(hsinY, 2) + (centerLatRad_cos * targetLatRad_cos * power(hsinX, 2));
      //两点的距离
      double dist = EARTH_RADIUS * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
      return dist;
   }
 
   /**
    * 经纬度剔除小数点(直接剔除不舍入),保留6位
    *
    * @param number 经度/纬度
    * @return
    */
   public static Double geoRound6Pla(Double number) {
      BigDecimal numDigd = new BigDecimal(number);
      number = numDigd.setScale(6, BigDecimal.ROUND_DOWN).doubleValue();
      return number;
   }
 
   public static void main(String[] args) {
      //中心点经纬度
      double x1 = 102.604646;
      double y1 = 24.239485;
 
      //目标点经纬度
      double x2 = 103.634895;
      double y2 = 24.778173;
 
      Map<String, Double> position = GeoUtils.findPosition(x1, y1, 130000 );
      double distance = GeoUtils.getDistance(x1, y1, x2, y2);
      System.out.println(position);
      System.out.println(distance);
   }
}

打印:

{
  minLon=101.32248517333221, 
  maxLat=25.40860308769435, 
  minLat=23.070366912305648, 
  maxLon=103.8868068266678
}
 
120220.59030376052

 


使用示例

Long distance = NOTICE_DISTANCE;
   publishNotice.setGeoLat(null);
   publishNotice.setGeoLon(null);
   Map<String, Double> geoMap = GeoUtils.findPosition(X, Y, distance);
   Double minLat = geoMap.get("minLat");
   Double maxLat = geoMap.get("maxLat");
   Double minLon = geoMap.get("minLon");
   Double maxLon = geoMap.get("maxLon");
   //保留6位小数点(尽量取多的小数点,保证计算精度)
   minLat = GeoUtils.geoRound6Pla(minLat);
   maxLat = GeoUtils.geoRound6Pla(maxLat);
   minLon = GeoUtils.geoRound6Pla(minLon);
   maxLon = GeoUtils.geoRound6Pla(maxLon);
   //组装SQL语句
   query.between("geo_lon", minLon, maxLon)
         .between("geo_lat", minLat, maxLat);
   iPage = page(page, query);
   noticeList = iPage.getRecords();
   noticeList = this.distFilter(noticeList, X, Y, distance);
}

过滤距离超过指定距离的

/**
 * 计算距离,过滤超出距离范围的元素
 *
 * @param noticeList 通告集合
 * @param centerLon  中心位置经度
 * @param centerLat  中心位置纬度
 * @param distance   距离半径
 */
private List<PublishNotice> distFilter(List<PublishNotice> noticeList, Double centerLon, Double centerLat, Long distance) {
   noticeList = noticeList.parallelStream()
         .map(notice -> {
            Double geoLon = notice.getGeoLon();
            Double geoLat = notice.getGeoLat();
            double dist = GeoUtils.getDistance(centerLon, centerLat, geoLon, geoLat);
            notice.setDistance(Math.round(dist));
            return notice;
         }).filter(notice -> notice.getDistance() <= distance)
         .sorted(Comparator.comparing(PublishNotice::getDistance))
         .collect(Collectors.toList());
   return noticeList;
}

 


使用框架依赖不用自己写计算工具类:

在实现附近的人搜索中,需要根据位置经纬度点,进行一些距离和范围的计算,比如求球面外接正方形的坐标点,球面两坐标点的距离等,可以引入Spatial4j 库。

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

建表举例:

CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL COMMENT '名称',
`longitude` double DEFAULT NULL COMMENT '经度',
`latitude` double DEFAULT NULL COMMENT '纬度',
`create_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
public class User {
    private int id;
    private String name;
    private double longitude;//经度,
    private double latitude;//纬度,
    private LocalDateTime createTime;//创建时间,
}

Controller方法

public class GeoTestController {
 
    private SpatialContext spatialContext = SpatialContext.GEO;
 
    /**
     * 获取附近x米的人
     *
     * @param distance 距离范围 单位km
     * @param userLng  当前经度
     * @param userLat  当前纬度
     * @return json
     */
    @GetMapping("/nearby")
    public String nearBySearch(@RequestParam("distance") double distance,
                               @RequestParam("userLng") double userLng,
                               @RequestParam("userLat") double userLat) {
        //1.获取外接正方形
        Rectangle rectangle = getRectangle(distance, userLng, userLat);
        //2.获取位置在正方形内的所有用户
        List<User> users = userMapper.selectUser(rectangle.getMinX(), rectangle.getMaxX(), rectangle.getMinY(), rectangle.getMaxY());
        //3.剔除半径超过指定距离的多余用户
        users = users.stream()
                .filter(a -> getDistance(a.getLongitude(), a.getLatitude(), userLng, userLat) <= distance)
                .collect(Collectors.toList());
        return JSON.toJSONString(users);
    }
 
    /**
     * 中心坐标和半径距离,获取方形经纬度范围
     *
     * @param distance 距离半径
     * @param lon      经度
     * @param lat      纬度
     * @return
     */
    private Rectangle getRectangle(double distance, double lon, double lat) {
        Rectangle rectangle = spatialContext.getDistCalc()
                .calcBoxByDistFromPt(spatialContext.makePoint(lon, lat),
                        distance * DistanceUtils.KM_TO_DEG, spatialContext, null);
        return rectangle;
    }
}

MySQL附近功能

 

 

 Mapper文件的SQL语句:

<select id="selectUser" resultMap="BaseResultMap">
    SELECT * FROM user
    WHERE 1=1
    and (longitude BETWEEN ${minlng} AND ${maxlng})
    and (latitude BETWEEN ${minlat} AND ${maxlat})
</select>

 


方案二:Mysql+geohash

引入了geohash算法,同时在查询上借助索引

geohash被广泛应用于位置搜索类的业务中,本文不对它进行展开说明,有兴趣的同学可以看一下这篇博客:  https://www.cnblogs.com/LBSer/p/3310455.html

GeoHash算法将经纬度坐标点编码成一个字符串,距离越近的坐标,转换后的geohash字符串越相似,例如下表数据:

用户

经纬度

Geohash字符串

小明

116.402843,39.999375

wx4g8c9v

小华

116.3967,39.99932

wx4g89tk

小张

116.40382,39.918118

wx4g0ffe

其中根据经纬度计算得到的geohash字符串,不同精度(字符串长度)代表了不同的距离误差。具体的不同精度的距离误差可参考下表:

geohash码长度

宽度

高度

1

5,009.4km

4,992.6km

2

1,252.3km

624.1km

3

156.5km

156km

4

39.1km

19.5km

5

4.9km

4.9km

6

1.2km

609.4m

7

152.9m

152.4m

8

38.2m

19m

9

4.8m

4.8m

10

1.2m

59.5cm

11

14.9cm

14.9cm

12

3.7cm

1.9cm

实现思路:

 

  • 使用Mysql存储用户信息,其中包括用户的经纬度信息和geohash字符串。
  • 添加新用户时计算该用户的geohash字符串,并存储到用户表中
  • 当要查询某一gps附近指定距离的用户信息时,通过比对geohash误差表确定需要的geohash字符串精度
  • 计算获得某一精度的当前坐标的geohash字符串,通过WHERE geohash Like 'geohashcode%'来查询数据集
  • 如果geohash字符串的精度远大于给定的距离范围时,查询出的结果集中必然存在在范围之外的数据

计算两点之间距离,对于超出距离的数据进行剔除。

建表结构:

CREATE TABLE `user_geohash` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) DEFAULT NULL COMMENT '名称',
  `longitude` double DEFAULT NULL COMMENT '经度',
  `latitude` double DEFAULT NULL COMMENT '纬度',
  `geo_code` varchar(64) DEFAULT NULL COMMENT '经纬度所计算的geohash码',
  `create_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`),
  KEY `index_geo_hash` (`geo_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Java Bean

public class UserGeohash {
 
    private int id;
    private String name;
    private double longitude;//经度,
    private double latitude;//纬度,
    private String geoCode;
    private LocalDateTime createTime;//创建时间,
 }

控制层

public class GeoHashController {
 
    private SpatialContext spatialContext = SpatialContext.GEO;
 
    /***
     * 添加用户
     * @return
     */
    @PostMapping("/addUser")
    public boolean add(@RequestBody UserGeohash user) {
        //默认精度12位
        String geoHashCode = GeohashUtils.encodeLatLon(user.getLatitude(), user.getLongitude());
        return userGeohashService.save(user.setGeoCode(geoHashCode), user.setCreateTime(LocalDateTime.now()));
    }
 
 
    /**
     * 获取附近指定范围的人
     *
     * @param distance 距离范围 单位km
     * @param len      geoHash的精度
     * @param userLng  当前经度
     * @param userLat  当前纬度
     * @return json
     */
    @GetMapping("/nearby")
    public String nearBySearch(@RequestParam("distance") double distance,
                               @RequestParam("len") int len,
                               @RequestParam("userLng") double userLng,
                               @RequestParam("userLat") double userLat) {
        //1.根据要求的范围,确定geoHash码的精度,获取到当前用户坐标的geoHash码
        String geoHashCode = GeohashUtils.encodeLatLon(userLat, userLng, len);
        QueryWrapper<UserGeohash> queryWrapper = new QueryWrapper<UserGeohash>()
                .likeRight("geo_code", geoHashCode);
        //2.匹配指定精度的geoHash码
        List<UserGeohash> users = userGeohashService.list(queryWrapper);
        //3.过滤超出距离的
        users = users.stream()
                .filter(a -> getDistance(a.getLongitude(), a.getLatitude(), userLng, userLat) <= distance)
                .collect(Collectors.toList());
        return JSON.toJSONString(users);
    }
 
    /***
     * 球面中,两点间的距离
     * @param longitude 经度1
     * @param latitude  纬度1
     * @param userLng   经度2
     * @param userLat   纬度2
     * @return 返回距离,单位km
     */
    private double getDistance(Double longitude, Double latitude, double userLng, double userLat) {
        double distance = spatialContext.calcDistance(spatialContext.makePoint(userLng, userLat),
                spatialContext.makePoint(longitude, latitude)) * DistanceUtils.DEG_TO_KM;
        return distance;
    }
}

通过上面几步,就可以实现这一业务场景,不仅提高了查询效率,并且保护了用户的隐私,不对外暴露坐标位置。并且对于同一位置的频繁请求,如果是同一个geohash字符串,可以加上缓存,减缓数据库的压力。

 

边界优化问题:

geohash算法将地图分为一个个矩形,对每个矩形进行编码,得到geohash码,但是当前点与待搜索点距离很近但是恰好在两个区域,用上面的方法则就不适用了。解决这一问题的办法:获取当前点所在区域附近的8个区域的geohash码,一并进行筛选。

 

如何求解附近的8个区域的geohash码可参考 : https://blog.csdn.net/dokd229933/article/details/47021515  了解了思路,这里我们可以使用第三方开源库ch.hsr.geohash来计算,通过maven引入

<dependency>
    <groupId>ch.hsr</groupId>
    <artifactId>geohash</artifactId>
    <version>1.0.10</version>
</dependency>
/**
 * 获取附近指定范围的人
 *
 * @param distance 距离范围 单位km
 * @param len      geoHash的精度
 * @param userLng  当前经度
 * @param userLat  当前纬度
 * @return json
 */
@GetMapping("/nearby")
public String nearBySearch(@RequestParam("distance") double distance,
                           @RequestParam("len") int len,
                           @RequestParam("userLng") double userLng,
                           @RequestParam("userLat") double userLat) {
 
    //1.根据要求的范围,确定geoHash码的精度,获取到当前用户坐标的geoHash码
    GeoHash geoHash = GeoHash.withCharacterPrecision(userLat, userLng, len);
    //2.获取到用户周边8个方位的geoHash码
    GeoHash[] adjacent = geoHash.getAdjacent();
 
    QueryWrapper<UserGeohash> queryWrapper = new QueryWrapper<UserGeohash>()
            .likeRight("geo_code",geoHash.toBase32());
    Stream.of(adjacent).forEach(a -> queryWrapper.or().likeRight("geo_code",a.toBase32()));
 
    //3.匹配指定精度的geoHash码
    List<UserGeohash> users = userGeohashService.list(queryWrapper);
    //4.过滤超出距离的
    users = users.stream()
            .filter(a ->getDistance(a.getLongitude(),a.getLatitude(),userLng,userLat)<= distance)
            .collect(Collectors.toList());
    return JSON.toJSONString(users);
}

 

上一篇:容器化K8S入门-搭建集群环境


下一篇:geohash:多维空间点的编码方法和索引算法