Java中“附近的人”實現方案討論及代碼實現


前言

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

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

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

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

基本套路和方案

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

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

Mysql+外接正方形

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

假設給定某用戶的位置坐標, 求在該用戶指定范圍內的其他用戶信息

此時可以將位置信息和距離范圍簡化成平面幾何題來求解

實現思路

以當前用戶為圓心,以給定距離為半徑畫圓,那么在這個圓內的所有用戶信息就是符合結果的信息,直接檢索圓內的用戶坐標難以實現,我們可以通過獲取這個圓的外接正方形

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

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

代碼實現

這里只貼出部分核心代碼,詳細的代碼可見源碼: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核心原理解析




免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM