1. 程式人生 > >Java中“附近的人”實現方案討論及程式碼實現

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核心原理解