天天看點

原來用Redis實作查找附近的人這麼容易

原來用Redis實作查找附近的人這麼容易

1. 前言

老闆突然要上線一個需求,擷取目前位置方圓一公裡的業務代理點。明天上線!當接到這個需求的時候我差點吐血,這時間也太緊張了。趕緊去查相關的技術選型。經過一番折騰,終于在晚上十點完成了這個需求。現在把大緻實作的思路總結一下。

原來用Redis實作查找附近的人這麼容易

圖1

2. MySQL 不合适

遇到需求,首先要想到現有的東西能不能滿足,成本如何。

MySQL是我首先能夠想到的,畢竟大部分資料要持久化到MySQL。但是使用MySQL需要自行計算Geohash。需要使用大量數學幾何計算,并且需要學習地理相關知識,門檻較高,短時間内不可能完成需求,而且長期來看這也不是MySQL擅長的領域,是以沒有考慮它。

Geohash 參考 https://www.cnblogs.com/LBSer/p/3310455.html

2. Redis 中的 GEO

Redis是我們最為熟悉的K-V資料庫,它常被拿來作為高性能的緩存資料庫來使用,大部分項目都會用到它。從3.2版本開始它開始提供了GEO能力,用來實作諸如附近位置、計算距離等這類依賴于地理位置資訊的功能。GEO相關的指令如下:

Redis 指令 描述
GEOHASH 傳回一個或多個位置元素的 Geohash 表示
GEOPOS 從 key 裡傳回所有給定位置元素的位置(經度和緯度)
GEODIST 傳回兩個給定位置之間的距離
GEORADIUS 以給定的經緯度為中心, 找出某一半徑内的元素
GEOADD 将指定的地理空間位置(緯度、經度、名稱)添加到指定的 key 中
GEORADIUSBYMEMBER 找出位于指定範圍内的元素,中心點是由給定的位置元素決定
Redis 會假設地球為完美的球形, 是以可能有一些位置計算偏差,據說<=0.5%,對于有嚴格地理位置要求的需求來說要經過一些場景測試來檢驗是否能夠滿足需求。

2.1 寫入地理資訊

那麼如何實作目标機關半徑内的所有元素呢?我們可以将所有的位置的經緯度通過上表中的

GEOADD

将這些地理資訊轉換為 52 位的Geohash寫入Redis。

該指令格式:

geoadd key longitude latitude member [longitude latitude member ...]
           

對應例子:

redis> geoadd cities:locs 117.12 39.08 tianjin 114.29 38.02  shijiazhuang
(integer) 2
           

意思是将經度為

117.12

緯度為

39.08

的地點

tianjin

和經度為

114.29

緯度為

38.02

的地點

shijiazhuang

加入key為

cities:locs

的 sorted set集合中。可以添加一到多個位置。然後我們就可以借助于其他指令來進行地理位置的計算了。

有效的經度從-180 度到 180 度。有效的緯度從-85.05112878 度到 85.05112878 度。當坐标位置超出上述指定範圍時,該指令将會傳回一個錯誤。

2.2 統計機關半徑内的地區

我們可以借助于

GEORADIUS

來找出以給定經緯度,某一半徑内的所有元素。

該指令格式:

georadius key longtitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC]
           

這個指令比

GEOADD

要複雜一些:

  • radius 半徑長度,必選項。後面的

    m

    km

    ft

    mi

    、是長度機關選項,四選一。
  • WITHCOORD 将位置元素的經度和次元也一并傳回,非必選。
  • WITHDIST 在傳回位置元素的同時, 将位置元素與中心點的距離也一并傳回。距離的機關和查詢機關一緻,非必選。
  • WITHHASH 傳回位置的 52 位精度的Geohash值,非必選。這個我反正很少用,可能其它一些偏向底層的LBS應用服務需要這個。
  • COUNT 傳回符合條件的位置元素的數量,非必選。比如傳回前 10 個,以避免出現符合的結果太多而出現性能問題。
  • ASC|DESC 排序方式,非必選。預設情況下傳回未排序,但是大多數我們需要進行排序。參照中心位置,從近到遠使用ASC ,從遠到近使用DESC。

例如,我們在

cities:locs

中查找以(115.03,38.44)為中心,方圓

200km

的城市,結果包含城市名稱、對應的坐标和距離中心點的距離(km),并按照從近到遠排列。指令如下:

redis> georadius cities:locs 115.03 38.44 200 km WITHCOORD WITHDIST ASC
1) 1) "shijiazhuang"
   2) "79.7653"
   3) 1) "114.29000169038772583"
      2) "38.01999994251037407"
2) 1) "tianjin"
   2) "186.6937"
   3) 1) "117.02000230550765991"
      2) "39.0800000535766543"
           
你可以加上

COUNT 1

來查找最近的一個位置。

3. 基于 Redis GEO 實戰

大緻的原理思路說完了,接下來就是實操了。結合Spring Boot應用我們應該如何做?

3.1 開發環境

需要具有GEO特性的Redis版本,這裡我使用的是Redis 4 。另外我們用戶端使用

spring-boot-starter-data-redis

。這裡我們會使用到

RedisTemplate

對象。

3.2 批量添加位置資訊

第一步,我們需要将位置資料初始化到Redis中。在Spring Data Redis中一個位置坐标

(lng,lat)

可以封裝到

org.springframework.data.geo.Point

對象中。然後指定一個名稱,就組成了一個位置Geo資訊。

RedisTemplate

提供了批量添加位置資訊的方法。我們可以将章節 2.1中的添加指令轉換為下面的代碼:

   Map<String, Point> points = new HashMap<>();
   points.put("tianjin", new Point(117.12, 39.08));
   points.put("shijiazhuang", new Point(114.29, 38.02));
   // RedisTemplate 批量添加 Geo
   redisTemplate.boundGeoOps("cities:locs").add(points);
           

可以結合Spring Boot 提供的 ApplicationRunner 接口來實作初始化。

@Bean
public ApplicationRunner cacheActiveAppRunner(RedisTemplate<String, String> redisTemplate) {

    return args -> {
        final String GEO_KEY = "cities:locs";

        // 清理緩存
        redisTemplate.delete(GEO_KEY);

        Map<String, Point> points = new HashMap<>();
        points.put("tianjin", new Point(117.12, 39.08));
        points.put("shijiazhuang", new Point(114.29, 38.02));
        // RedisTemplate 批量添加 GeoLocation
        BoundGeoOperations<String, String> geoOps = redisTemplate.boundGeoOps(GEO_KEY);
        geoOps.add(points);
    };
}
           

3.3 查詢附近的特定位置

RedisTemplate

針對

GEORADIUS

指令也有封裝:

GeoResults<GeoLocation<M>> radius(K key, Circle within, GeoRadiusCommandArgs args)
           

Circle

對象是封裝覆寫的面積(圖 1),需要的要素為中心點坐标

Point

對象、半徑(radius)、計量機關(metric), 例如:

Point point = new Point(115.03, 38.44);

Metric metric = RedisGeoCommands.DistanceUnit.KILOMETERS;
Distance distance = new Distance(200, metric);

Circle circle = new Circle(point, distance);
           

GeoRadiusCommandArgs

用來封裝

GEORADIUS

的一些可選指令參數,參見章節 2.2中的

WITHCOORD

COUNT

ASC

等,例如我們需要在傳回結果中包含坐标、中心距離、由近到遠排序的前 5 條資料:

RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands
        .GeoRadiusCommandArgs
        .newGeoRadiusArgs()
        .includeDistance()
        .includeCoordinates()
        .sortAscending()
        .limit(limit);
           

然後執行

radius

方法就會拿到

GeoResults<RedisGeoCommands.GeoLocation<String>>

封裝的結果,我們對這個可疊代對象進行解析就可以拿到我們想要的資料:

GeoResults<RedisGeoCommands.GeoLocation<String>> radius = redisTemplate.opsForGeo()
        .radius(GEO_STAGE, circle, args);

if (radius != null) {
    List<StageDTO> stageDTOS = new ArrayList<>();
    radius.forEach(geoLocationGeoResult -> {
        RedisGeoCommands.GeoLocation<String> content = geoLocationGeoResult.getContent();
        //member 名稱  如  tianjin
        String name = content.getName();
        // 對應的經緯度坐标
        Point pos = content.getPoint();
        // 距離中心點的距離
        Distance dis = geoLocationGeoResult.getDistance();
    });
}
           

3.4 删除元素

有時候我們可能需要删除某個位置元素,但是Redis的Geo并沒有删除成員的指令。不過由于它的底層是

zset

,我們可以借助

zrem

指令進行删除,對應的Java代碼為:

redisTemplate.boundZSetOps(GEO_STAGE).remove("tianjin");
           

4. 總結

今天我們使用Redis的Geo特性實作了常見的附近的地理資訊查詢需求,簡單易上手。其實使用另一個Nosql資料庫MongoDB也可以實作。在資料量比較小的情況下Redis已經能很好的滿足需要。如果資料量大可使用MongoDB來實作。文中涉及的DEMO可關注:碼農小胖哥 ,公衆号回複 redisgeo擷取。

往期推薦:

Spring Security 實戰幹貨:如何實作不同的接口不同的安全政策

原來用Redis實作查找附近的人這麼容易
原來用Redis實作查找附近的人這麼容易

繼續閱讀