Java中「附近的人」實現方案討論及代碼實現

前言

在咱們平時使用的許多app中有附近的人這一功能,像微信、qq附近的人,哈羅、街兔附近的車輛。這些功能就在咱們平常生活中出現。html

像相似於附近的人這一類業務,在Java中是如何實現的呢?java

本文就簡單介紹下目前的幾種解決方案,並提供簡單的示例代碼git

注: 本文僅涉及附近的人這一業務場景的解決方案討論,並未涉及到相關的技術細節和方案優化,各位看官能夠放心閱讀。github

<!-- more --> redis

基本套路和方案

目前業內的解決方案大都依據geoHash展開,考慮到不一樣的數據量以及不一樣的業務場景,本文主要討論如下3種方案算法

  • Mysql+外接正方形
  • Mysql+geohash
  • Redis+geohash

Mysql+外接正方形

外接矩形的實現方式是相對較爲簡單的一種方式。spring

假設給定某用戶的位置座標, 求在該用戶指定範圍內的其餘用戶信息sql

此時能夠將位置信息和距離範圍簡化成平面幾何題來求解數據庫

實現思路

以當前用戶爲圓心,以給定距離爲半徑畫圓,那麼在這個圓內的全部用戶信息就是符合結果的信息,直接檢索圓內的用戶座標難以實現,咱們能夠經過獲取這個圓的外接正方形json

經過外接正方形,獲取經度和緯度的最大最小值,根據最大最小值能夠將座標在正方形內的用戶信息搜索出來。

此時在外接正方形中不屬於圓形區域的部分就屬於多餘的部分,這部分用戶信息距離當前用戶(圓心)的距離一定是大於給定半徑的,故能夠將其剔除,最終得到指定範圍內的附近的人

<img src="https://cdn.jsdelivr.net/gh/larscheng/myImg/blogImg/nearbysearch/20191210200831.png" style="zoom:67%;" />

代碼實現

這裏只貼出部分核心代碼,詳細的代碼可見源碼:NearBySearch

在實現附近的人搜索中,須要根據位置經緯度點,進行一些距離和範圍的計算,好比求球面外接正方形的座標點,球面兩座標點的距離等,能夠引入Spatial4j庫。

<dependency>
            <groupId>com.spatial4j</groupId>
            <artifactId>spatial4j</artifactId>
            <version>0.5</version>
        </dependency>
  1. 首先建立一張數據表user
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;
  1. 假設已插入足夠的測試數據,只要咱們獲取到外接正方形的四個關鍵點,就能夠直接直接查詢
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);
    }
    private Rectangle getRectangle(double distance, double userLng, double userLat) {
        return spatialContext.getDistCalc()
            .calcBoxByDistFromPt(spatialContext.makePoint(userLng, userLat), 
                                 distance * DistanceUtils.KM_TO_DEG, spatialContext, null);
    }
  1. 這裏給出查詢的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

前面介紹了經過Mysql存儲用戶的信息和gps座標,經過計算外接正方形的座標點來粗略篩選結果集,最終剔除超過範圍的用戶。

而如今要提到的Mysql+geohash方案,一樣是以Mysql爲基礎,只不過引入了geohash算法,同時在查詢上藉助索引。

geohash被普遍應用於位置搜索類的業務中,本文不對它進行展開說明,有興趣的同窗能夠看一下這篇博客:[GeoHash核心原理解析],這裏簡單對它作一個描述:

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字符串。

  1. 添加新用戶時計算該用戶的geohash字符串,並存儲到用戶表中
  2. 當要查詢某一gps附近指定距離的用戶信息時,經過比對geohash偏差表肯定須要的geohash字符串精度
  3. 計算得到某一精度的當前座標的geohash字符串,經過WHERE geohash Like 'geohashcode%'來查詢數據集
  4. 若是geohash字符串的精度遠大於給定的距離範圍時,查詢出的結果集中必然存在在範圍以外的數據
  5. 計算兩點之間距離,對於超出距離的數據進行剔除。

代碼實現

這裏只貼出部分核心代碼,詳細的代碼可見源碼:NearBySearch

一樣的要涉及到座標點的計算和geohash的計算,開始以前先導入spatial4j

  1. 建立數據表user_geohash,給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;
  1. 添加用戶信息和範圍搜索邏輯
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).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) {
        return spatialContext.calcDistance(spatialContext.makePoint(userLng, userLat),
                spatialContext.makePoint(longitude, latitude)) * DistanceUtils.DEG_TO_KM;
    }

經過上面幾步,就能夠實現這一業務場景,不只提升了查詢效率,而且保護了用戶的隱私,不對外暴露座標位置。而且對於同一位置的頻繁請求,若是是同一個geohash字符串,能夠加上緩存,減緩數據庫的壓力。

邊界問題優化

geohash算法將地圖分爲一個個矩形,對每一個矩形進行編碼,獲得geohash碼,可是當前點與待搜索點距離很近可是剛好在兩個區域,用上面的方法則就不適用了。

解決這一問題的辦法:獲取當前點所在區域附近的8個區域的geohash碼,一併進行篩選。

如何求解附近的8個區域的geohash碼可參考Geohash求當前區域周圍8個區域編碼的一種思路

瞭解了思路,這裏咱們可使用第三方開源庫ch.hsr.geohash來計算,經過maven引入

<dependency>
            <groupId>ch.hsr</groupId>
            <artifactId>geohash</artifactId>
            <version>1.0.10</version>
        </dependency>

對上一章節的nearBySearch方法進行修改以下:

/**
     * 獲取附近指定範圍的人
     *
     * @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);
    }

Redis+GeoHash

基於前兩種方案,咱們能夠發現gps這類數據屬於讀多寫少的狀況,若是使用redis來實現附近的人,想必效率會大大提升。

自Redis 3.2開始,Redis基於geohash有序集合Zset提供了地理位置相關功能

Redis提供6條命令,來幫助咱們我完成大部分業務的需求,關於Redis提供的geohash操做命令介紹可閱讀博客:Redis 究竟是怎麼實現「附近的人」這個功能的呢?

本文主要介紹下,咱們示例代碼中用到的兩個命令:

  • GEOADD key longitude latitude member:將給定的空間元素(緯度、經度、名字)添加到指定的鍵裏面
    • 例如添加小明的經緯度信息:GEOADD location 119.98866180732716 30.27465803229662 小明
  • GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [ASC|DESC] [COUNT count]: 根據給定地理位置座標獲取指定範圍內的地理位置集合(附近的人)
    • 例如查詢某gps附近500m的用戶座標:GEORADIUS location 119.98866180732716 30.27465803229662 500 m WITHCOORD

實現思路

  • 添加用戶座標信息到redis(GEOADD),redis會將經緯度參數值轉換爲52位的geohash碼,
  • Redis以geohash碼爲score,將其餘信息以Zset有序集合存入key中
  • 經過調用GEORADIUS命令,獲取指定座標點某一範圍內的數據
  • 因geohash存在精度偏差,剔除超過指定距離的數據

實現代碼

這裏只貼出部分核心代碼,詳細的代碼可見源碼:NearBySearch

@Autowired
    private RedisTemplate<String, Object> redisTemplate;
	
	//GEO相關命令用到的KEY
    private final static String KEY = "user_info";

    public boolean save(User user) {
        Long flag = redisTemplate.opsForGeo().add(KEY, new RedisGeoCommands.GeoLocation<>(
                user.getName(), 
                new Point(user.getLongitude(), user.getLatitude()))
        );
        return flag != null && flag > 0;
    }

    /**
     * 根據當前位置獲取附近指定範圍內的用戶
     * @param distance 指定範圍 單位km ,可根據{@link org.springframework.data.geo.Metrics} 進行設置
     * @param userLng 用戶經度
     * @param userLat 用戶緯度
     * @return
     */
    public String nearBySearch(double distance, double userLng, double userLat) {
        List<User> users = new ArrayList<>();
        // 1.GEORADIUS獲取附近範圍內的信息
        GeoResults<RedisGeoCommands.GeoLocation<Object>> reslut = 
            redisTemplate.opsForGeo().radius(KEY, 
                        new Circle(new Point(userLng, userLat), new Distance(distance, Metrics.KILOMETERS)),
                        RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()
                                .includeDistance()
                                .includeCoordinates().sortAscending());
        //2.收集信息,存入list
        List<GeoResult<RedisGeoCommands.GeoLocation<Object>>> content = reslut.getContent();
        //3.過濾掉超過距離的數據
        content.forEach(a-> users.add(
                new User().setDistance(a.getDistance().getValue())
                .setLatitude(a.getContent().getPoint().getX())
                .setLongitude(a.getContent().getPoint().getY())));
        return JSON.toJSONString(users);
    }

方案總結

方案 優點 缺點
Mysql外接正方形 邏輯清晰,實現簡單,支持多條件篩選 效率較低,不適合大數據量,不支持按距離排序
Mysql+Geohash 藉助索引有效提升效率,支持多條件篩選 不支持按距離排序,存在數據庫瓶頸
Redis+Geohash 效率高,集成便捷,支持距離排序 不適合複雜對象存儲,不支持多條件查詢

總結以上三種方案,各有優劣,在不一樣的業務場景下,可選擇不一樣的方案來實現。

固然目前附近的人的解決方案並不只僅這三種,以上權當是這一功能的入門引子,但願對你們有所幫助。

本文的三種方案均有源碼提供,源碼地址

參考文章

Redis 究竟是怎麼實現「附近的人」這個功能的呢?

Geohash求當前區域周圍8個區域編碼的一種思路

GeoHash核心原理解析



原文出處:https://www.cnblogs.com/larscheng/p/12063409.html

相關文章
相關標籤/搜索