Redis 實戰篇:GEO助我邂逅附近女神

碼哥位元組發表於2021-07-08

碼老溼,閱讀了你的巧用資料型別實現億級資料統計之後,我學會了如何遊刃有餘的使用不同的資料型別(String、Hash、List、Set、Sorted Set、HyperLogLogBitmap)去解決不同場景的統計問題。

產品經理說他有一個 idea,為廣大少男少女提供一個連線彼此的機會

讓處於這最美的年齡的少男少女能在每一個十二時辰裡能邂逅到那個 Ta

所以就想開發一款 App,使用者登陸後能發現附近的那個 Ta,連線彼此。

我該如何實現發現附近的人?我也希望通過這個 App 邂逅女神……

記憶中,一個下班的夜晚,她從人群中輕盈的移動著,那高挑苗條的身材像漂浮在空間中的一個飄逸的音符。她的眼睛充滿清澈的陽光和活力,她的雙眸中印著銀河系的星光。

開篇寄語

多鍛鍊自己的表達能力,特別是在工作中。很多人說「幹活的不如那些做 PPT 的」,實際上老闆都不傻,為何他們會更認可那些做 PPT 的?

因為他們從老闆的角度考慮問題,對他而言,需要的是一個「解決方案」。多從一個創造者的視角去考慮問題,而不是侷限在用程式設計師的視角考慮問題;

多想一下這個東西到底給人提供什麼價值,而不是「我要怎麼實現它」。當然,怎麼實現是必須的,但通常不是最重要的。

什麼是面向 LBS 應用

經緯度是經度與緯度的合稱組成一個座標系統。又稱為地理座標系統,它是一種利用三度空間的球面來定義地球上的空間的球面座標系統,能夠標示地球上的任何一個位置(小數點後7位,精度可以到1釐米)。

經度的範圍在 (-180, 180],緯度的範圍 在(-90, 90],緯度正負以赤道為界,北正南負,經度正負以本初子午線 (英國格林尼治天文臺) 為界,東正西負。

附近的人 也就是常說的 LBS (Location Based Services,基於位置服務),它圍繞使用者當前地理位置資料而展開的服務,為使用者提供精準的邂逅服務。

附近的人核心思想如下:

  1. 以 “我” 為中心,搜尋附近的 Ta;
  2. 以 “我” 當前的地理位置為準,計算出別人和 “我” 之間的距離;
  3. 按 “我” 與別人距離的遠近排序,篩選出離我最近的使用者。

MySQL 實現

計算「附近的人」,通過一個座標計算這個座標附近的其他資料,按照距離排序,如何下手呢?

以使用者為中心,給定一個 1000 米作為半徑畫圓,那麼圓形區域內的使用者就是我們想要邂逅的「附近的人」。

將經緯度儲存到 MySQL

CREATE TABLE `nearby_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;

可是總不能遍歷所有的「女神」經緯度與自己的經緯度資料計算在根據距離排序,這個計算量也太大了。

我們可以通過區域來過濾出有限「女神」座標資料,再對矩形區域內的資料進行全量距離計算再排序,這樣計算量明顯降低。

如何劃分矩形區域呢?

在圓形外套上一個正方形,根據使用者經、緯度的最大最小值(經、緯度 + 距離),作為篩選條件過濾資料,就很容易將正方形內的「女神」資訊搜尋出來。

多出來的一些區域咋辦?

多出來的這部分割槽域內的使用者,到圓點的距離一定比圓的半徑要大,那麼我們就計算使用者中心點與正方形內所有使用者的距離,篩選出所有距離小於等於半徑的使用者,圓形區域內的所使用者即符合要求的附近的人

為了滿足高效能的矩形區域演算法,資料表需要在經緯度座標加上覆合索引 (longitude, latitude),這樣可以最大優化查詢效能。

實戰

根據經緯度和距離獲取外接矩形最大、最小經緯度以及根據經緯度計算距離使用了一個第三方類庫:

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

獲取到外接矩形後,以矩形的最大最小經、緯度值搜尋正方形區域內的使用者,再剔除超過指定距離的使用者,就是最終的附近的人

/**
 * 獲取附近 x 米的人
 *
 * @param distance 搜尋距離範圍 單位km
 * @param userLng  當前使用者的經度
 * @param userLat  當前使用者的緯度
 */
public String nearBySearch(double distance, double userLng, 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);
}

     /***
     * 球面中,兩點間的距離
     * @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;
    }

由於使用者間距離的排序是在業務程式碼中實現的,可以看到SQL語句也非常的簡單。

SELECT * FROM nearby_user
WHERE 1=1
AND (longitude BETWEEN #{minlng} AND #{maxlng})
AND (latitude BETWEEN #{minlat} AND #{maxlat})

但是資料庫查詢效能畢竟有限,如果「附近的人」查詢請求非常多,在高併發場合,這可能並不是一個很好的方案。

嘗試 Redis Hash 未果

我們一起分析下 LBS 資料的特點:

  1. 每個「女神」都有一個 ID 編號,每個ID 對應著經緯度資訊。
  2. 「宅男」登陸 app 獲取「心動女生」的時候,app 根據「宅男」的經緯度查詢附近的「女神」。
  3. 獲取到位置符合的「女神」ID 列表後,再從資料庫獲取 ID 對應的「女神」資訊返回使用者。

資料特點就是一個女神(使用者)對應著一組經緯度,讓我想到了 Redis 的 Hash 結構。也就是一個 key(女神 ID) 對應著 一個 value(經緯度)。

Hash 看起來好像可以實現,但是 LBS 應用除了記錄經緯度以外,還需要對 Hash 集合中的資料進行範圍查詢,根據經緯度換算成距離排序。

而 Hash 集合的資料是無序的,顯然不可取

Sorted Set 初見端倪

Sorted Set 型別是是否合適呢?因為它可以排序。

Sorted Set 型別也是一個 key 對應一個 valuekey元素內容,而 value `就是該元素的權重分數。

Sorted Set 可以根據元素的權重分數對元素排序,這樣看起來就滿足我們的需求了。

比如,Sorted Set 的元素是「女神ID」,元素對應的權重 score 是經緯度資訊。

問題來了,Sorted Set 元素的權重值是一個浮點數,經緯度是經度、緯度兩個值,咋辦呢?能不能將經緯度轉換成一個浮點數呢?

思路對了,為了實現對經緯度比較,Redis 採用業界廣泛使用的 GeoHash 編碼,分別對經度和緯度編碼,最後再把經緯度各自的編碼組合成一個最終編碼。

這樣就實現了將經緯度轉換成一個值,而 Redis 的 GEO 型別的底層資料結構用的就是 Sorted Set來實現

我們來看下 GeoHash 如何將經緯度編碼的。

GEOHash 編碼

關於 GeoHash 可參考 :https://en.wikipedia.org/wiki/Geohash

GeoHash 演算法將二維的經緯度資料對映到一維的整數,這樣所有的元素都將在掛載到一條線上,距離靠近的二維座標對映到一維後的點之間距離也會很接近。

當我們想要計算「附近的人時」,首先將目標位置對映到這條線上,然後在這個一維的線上獲取附近的點就行了。

GeoHash 編碼會把一個經度值編碼成一個 N 位的二進位制值,我們來對經度範圍[-180,180]做 N 次的二分割槽操作,其中 N 可以自定義。

在進行第一次二分割槽時,經度範圍[-180,180]會被分成兩個子區間:[-180,0) 和[0,180](我稱之為左、右分割槽)。

此時,我們可以檢視一下要編碼的經度值落在了左分割槽還是右分割槽。如果是落在左分割槽,我們就用 0 表示;如果落在右分割槽,就用 1 表示

這樣一來,每做完一次二分割槽,我們就可以得到 1 位編碼值(不是0 就是 1)。

再對經度值所屬的分割槽再做一次二分割槽,同時再次檢視經度值落在了二分割槽後的左分割槽還是右分割槽,按照剛才的規則再做 1 位編碼。當做完 N 次的二分割槽後,經度值就可以用一個 N bit 的數來表示了。

所有的地圖元素座標都將放置於唯一的方格中。方格越小,座標越精確。然後對這些方格進行整數編碼,越是靠近的方格編碼越是接近。

編碼之後,每個地圖元素的座標都將變成一個整數,通過這個整數可以還原出元素的座標,整數越長,還原出來的座標值的損失程度就越小。對於「附近的人」這個功能而言,損失的一點精確度可以忽略不計。

比如對經度值等於 169.99 進行 4 位編碼(N = 4,做 4 次分割槽),把經度區間[-180,180]分成了左分割槽[-180,0) 和右分割槽[0,180]。

  1. 169.99 屬於右分割槽,使用 1 表示第一次分割槽編碼;
  2. 再將 169.99 經過第一次劃分所屬的 [0, 180] 區間繼續分成 [0, 90) 和 [90, 180],169.99 依然在右區間,編碼 ‘1’。
  3. 將[90, 180] 分為[90, 135) 和 [135, 180],這次落在左分割槽,編碼 ‘0’。

如此,最後我們就得到一個 4 位的編碼。

而緯度的編碼思路跟經度也是一樣的,不再贅述。

合併經緯度編碼

假如計算的經緯度編碼分別是 11011 和 00101`,目標編碼第 0 位則從經度第 0 位的值 1 作為目標值,目標編碼的第 1 位則從緯度第 0 位值 0 作為目標值,以此類推:

就這樣,經緯度(35.679,114.020)就可以使用 1010011011 表示,而這個值就可以作為 SortedSet 的權重值實現排序。

Redis GEO 實現

GEO 型別是將經緯度的經過 GeoHash 編碼的合併值作為 Sorted Set 元素的 score 權重,Redis 的 GEO 有哪些指令呢?

我們需要把登陸 app 的女生 ID 和對應的經緯度存到 Sorted Set 裡面。

更多 GEO 型別指令可參考:https://redis.io/commands#geo

GEOADD

Redis 提供了 GEOADD key longitude latitude member 命令,將一組經緯度資訊和對應的「女神 ID」記錄到 GEO 型別的集合中,如下:一次記錄多個使用者(蒼井空、波多野結衣)的經緯度資訊。

GEOADD girl:localtion 13.361389 38.115556 "蒼井空" 15.087269 37.502669 "波多野結衣"

GEORADIUS

我登陸了 app,獲取自己的經緯度資訊,如何查詢以這個經緯度為中心的一定範圍內的其他用使用者呢?

Redis GEO 型別提供了 GEORADIUS 指令:會根據輸入的經緯度位置,查詢以這個經緯度為中心的一定範圍內的其他元素。

假設自己的經緯度是(15.087269 37.502669),需要獲取附近 10 km 的「女神」並返回給 LBS 應用:

GEORADIUS girl:locations 15.087269 37.502669 km ASC COUNT 10

ASC 可以實現讓「女神」資訊按照這個距離自己的經緯度由近到遠排序。

COUNT 選項表示指定返回的「女神」數量,防止附近太多「女神」,節省頻寬資源。

如果覺得自己需要更多女神,那麼可以無限制,但是需要注意身體,多吃雞蛋補一補。

使用者下線後,如刪除下線的「女神」經緯度呢?

這個問題問得好,GEO 型別是基於 Sorted Set 實現的,所以可以借用 ZREM 命令實現對地理位置資訊的刪除。

比如刪除「蒼井空」的位置資訊:

ZREM girl:localtion "蒼井空"

小結

GEO 本身並沒有設計新的底層資料結構,而是直接使用了 Sorted Set 集合型別。

GEO 型別使用 GeoHash 編碼方法實現了經緯度到 Sorted Set 中元素權重分數的轉換,這其中的兩個關鍵機制就是對二維地圖做區間劃分,以及對區間進行編碼。

一組經緯度落在某個區間後,就用區間的編碼值來表示,並把編碼值作為 Sorted Set 元素的權重分數。

在一個地圖應用中,車的資料、餐館的資料、人的資料可能會有百萬千萬條,如果使用 Redis 的 Geo 資料結構,它們將全部放在一個 zset 集合中。

在 Redis 的叢集環境中,集合可能會從一個節點遷移到另一個節點,如果單個 key 的資料過大,會對叢集的遷移工作造成較大的影響,在叢集環境中單個 key 對應的資料量不宜超過 1M,否則會導致叢集遷移出現卡頓現象,影響線上服務的正常執行。

所以,這裡建議 Geo 的資料使用單獨的 Redis 叢集例項部署。

如果資料量過億甚至更大,就需要對 Geo 資料進行拆分,按國家拆分、按省拆分,按市拆分,在人口特大城市甚至可以按區拆分。

這樣就可以顯著降低單個 zset 集合的大小。

巨人肩膀

  1. https://segmentfault.com/a/1190000022734787
  2. https://juejin.cn/book/6844733724618129422/section/6844733724710404103
  3. https://cloud.tencent.com/developer/article/1393498
  4. Redis 核心技術與實戰

相關文章