一口气说出 4种 “附近的人” 实现方式,面试官笑了
引言
昨天一位公眾號粉絲和我討論了一道面試題,個人覺得比較有意義,這里整理了一下分享給大家,愿小伙伴們面試路上少踩坑。面試題目比較簡單:“讓你實現一個附近的人功能,你有什么方案?”,這道題其實主要還是考察大家對于技術的廣度,本文介紹幾種方案,給大家一點思路,避免在面試過程中語塞而影響面試結果,如有不嚴謹之處,還望親人們溫柔指正!
“附近的人”?功能生活中是比較常用的,像外賣app附近的餐廳,共享單車app里附近的車輛。既然常用面試被問的概率就很大,所以下邊依次來分析基于mysql數據庫、Redis、?MongoDB實現的 “附近的人” 功能。
?
科普:世界上標識一個位置,通用的做法就使用經、緯度。經度的范圍在 (-180, 180],緯度的范圍 在(-90, 90],緯度正負以赤道為界,北正南負,經度正負以本初子午線 (英國格林尼治天文臺) 為界,東正西負。比如:望京摩托羅拉大廈的經、緯度(116.49141,40.01229)全是正數,就是因為我國位于東北半球。
一、“附近的人”原理
“附近的人”?也就是常說的?LBS?(Location Based Services,基于位置服務),它圍繞用戶當前地理位置數據而展開的服務,為用戶提供精準的增值服務。
“附近的人” 核心思想如下:
以 “我” 為中心,搜索附近的用戶
以 “我” 當前的地理位置為準,計算出別人和 “我” 之間的距離
按 “我” 與別人距離的遠近排序,篩選出離我最近的用戶或者商店等
二、什么是GeoHash算法?
在說?“附近的人”?功能的具體實現之前,先來認識一下GeoHash?算法,因為后邊會一直和它打交道。定位一個位置最好的辦法就是用經、緯度標識,但經、緯度它是二維的,在進行位置計算的時候還是很麻煩,如果能通過某種方法將二維的經、緯度數據轉換成一維的數據,那么比較起來就要容易的多,因此GeoHash算法應運而生。
GeoHash算法將二維的經、緯度轉換成一個字符串,例如:下圖中9個GeoHash字符串代表了9個區域,每一個字符串代表了一矩形區域。而這個矩形區域內其他的點(經、緯度)都用同一個GeoHash字符串表示。
比如:WX4ER區域內的用戶搜索附近的餐廳數據,由于這區域內用戶的GeoHash字符串都是WX4ER,故可以把WX4ER當作key,餐廳信息作為value進行緩存;而如果不使用GeoHash算法,區域內的用戶請求餐廳數據,用戶傳來的經、緯度都是不同的,這樣緩存不僅麻煩且數據量巨大。
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 |
而且字符串越相似表示距離越相近,字符串前綴匹配越多的距離越近。比如:下邊的經、緯度就代表了三家距離相近的餐廳。
| 串串香 | 116.402843,39.999375 | wx4er9v |
| 火鍋 | 116.3967,39.99932 | wx4ertk |
| 烤肉 | 116.40382,39.918118 | wx4erfe |
讓大家簡單了解什么是GeoHash算法,方便后邊內容展開,GeoHash算法內容比較高深,感興趣的小伙伴自行深耕一下,這里不占用過多篇幅(其實是我懂得太膚淺,哭唧唧~)。
三、基于Mysql
此種方式是純基于mysql實現的,未使用GeoHash算法。
1、設計思路
以用戶為中心,假設給定一個500米的距離作為半徑畫一個圓,這個圓型區域內的所有用戶就是符合用戶要求的 “附近的人”。但有一個問題是圓形有弧度啊,直接搜索圓形區域難度太大,根本無法用經、緯度直接搜索。
但如果在圓形外套上一個正方形,通過獲取用戶經、緯度的最大最小值(經、緯度 + 距離),再根據最大最小值作為篩選條件,就很容易將正方形內的用戶信息搜索出來。
那么問題又來了,多出來一些面積腫么辦?
我們來分析一下,多出來的這部分區域內的用戶,到圓點的距離一定比圓的半徑要大,那么我們就計算用戶中心點與正方形內所有用戶的距離,篩選出所有距離小于等于半徑的用戶,圓形區域內的所用戶即符合要求的“附近的人”。
2、利弊分析
純基于?mysql?實現?“附近的人”,優點顯而易見就是簡單,只要建一張表存下用戶的經、緯度信息即可。缺點也很明顯,需要大量的計算兩個點之間的距離,非常影響性能。
3、實現
創建一個簡單的表用來存放用戶的經、緯度屬性。
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;計算兩個點之間的距離,用了一個三方的類庫,畢竟自己造的輪子不是特別圓,還有可能是方的,啊哈哈哈~
<dependency><groupId>com.spatial4j</groupId><artifactId>spatial4j</artifactId><version>0.5</version> </dependency>獲取到外接正方形后,以正方形的最大最小經、緯度值搜索正方形區域內的用戶,再剔除超過指定距離的用戶,就是最終的附近的人。
????private?SpatialContext?spatialContext?=?SpatialContext.GEO;????/***?獲取附近?x?米的人**?@param?distance?搜索距離范圍?單位km*?@param?userLng??當前用戶的經度*?@param?userLat??當前用戶的緯度*/@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);}由于用戶間距離的排序是在業務代碼中實現的,可以看到SQL語句也非常的簡單。
????<select?id="selectUser"?resultMap="BaseResultMap">SELECT?*?FROM?userWHERE?1=1and?(longitude?BETWEEN?${minlng}?AND?${maxlng})and?(latitude?BETWEEN?${minlat}?AND?${maxlat})</select>四、Mysql + GeoHash
1、設計思路
這種方式的設計思路更簡單,在存用戶位置信息時,根據用戶經、緯度屬性計算出相應的geohash字符串。注意:在計算geohash字符串時,需要指定geohash字符串的精度,也就是geohash字符串的長度,參考上邊的geohash精度表。
當需要獲取附近的人,只需用當前用戶geohash字符串,數據庫通過WHERE geohash Like 'geocode%' 來查詢geohash字符串相似的用戶,然后計算當前用戶與搜索出的用戶距離,篩選出所有距離小于等于指定距離(附近500米)的,即附近的人。
2、利弊分析
利用GeoHash算法實現“附近的人”有一個問題,由于geohash算法將地圖分為一個個矩形,對每個矩形進行編碼,得到geohash字符串。可我當前的點與鄰近的點很近,但恰好我們分別在兩個區域,明明就在眼前的點偏偏搜不到,實實在在的燈下黑。
如何解決這一問題?
為了避免類似鄰近兩點在不同區域內,我們就需要同時獲取當前點(WX4G0)所在區域附近?8個區域的geohash碼,一并進行篩選比較。
3、實現
同樣要設計一張表存用戶的經、緯度信息,但區別是要多一個geo_code字段,存放geohash字符串,此字段通過用戶經、緯度屬性計算出。使用頻繁的字段建議加上索引。
CREATE?TABLE?`nearby_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;首先根據用戶經、緯度信息,在指定精度后計算用戶坐標的geoHash碼,再獲取到用戶周邊8個方位的geoHash碼在數據庫中搜索用戶,最后過濾掉超出給定距離(500米內)的用戶。
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碼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);}/****?球面中,兩點間的距離*?@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;}五、Redis + GeoHash
Redis 3.2版本以后,基于geohash和數據結構Zset提供了地理位置相關功能。通過上邊兩種mysql的實現方式發現,附近的人功能是明顯的讀多寫少場景,所以用redis性能更會有很大的提升。
1、設計思路
redis?實現附近的人功能主要通過Geo模塊的六個命令。
-
GEOADD:將給定的位置對象(緯度、經度、名字)添加到指定的key;
-
GEOPOS:從key里面返回所有給定位置對象的位置(經度和緯度);
-
GEODIST:返回兩個給定位置之間的距離;
-
GEOHASH:返回一個或多個位置對象的Geohash表示;
-
GEORADIUS:以給定的經緯度為中心,返回目標集合中與中心的距離不超過給定最大距離的所有位置對象;
-
GEORADIUSBYMEMBER:以給定的位置對象為中心,返回與其距離不超過給定最大距離的所有位置對象。
以GEOADD?命令和GEORADIUS?命令簡單舉例:
GEOADD?key?longitude?latitude?member?[longitude?latitude?member?...]其中,key為集合名稱,member為該經緯度所對應的對象。
GEOADD?添加多個商戶“火鍋店”位置信息:
GEOADD?hotel?119.98866180732716????30.27465803229662?火鍋店GEORADIUS?根據給定的經緯度為中心,獲取目標集合中與中心的距離不超過給定最大距離(500米內)的所有位置對象,也就是“附近的人”。
GEORADIUS?key?longitude?latitude?radius?m|km|ft|mi?[WITHCOORD]?[WITHDIST]?[WITHHASH]?[ASC|DESC]?[COUNT?count]?[STORE?key]?[STORedisT?key]范圍單位:m?|?km?|?ft?|?mi?--> 米 | 千米 | 英尺 | 英里。
-
WITHDIST:在返回位置對象的同時,將位置對象與中心之間的距離也一并返回。距離的單位和用戶給定的范圍單位保持一致。
-
WITHCOORD:將位置對象的經度和維度也一并返回。
-
WITHHASH:以 52 位有符號整數的形式,返回位置對象經過原始 geohash 編碼的有序集合分值。這個選項主要用于底層應用或者調試,實際中的作用并不大。
-
ASC | DESC:從近到遠返回位置對象元素 | 從遠到近返回位置對象元素。
-
COUNT count:選取前N個匹配位置對象元素。(不設置則返回所有元素)
-
STORE key:將返回結果的地理位置信息保存到指定key。
-
STORedisT key:將返回結果離中心點的距離保存到指定key。
例如下邊命令:獲取當前位置周邊500米內的所有飯店。
GEORADIUS?hotel?119.98866180732716????30.27465803229662?500?m?WITHCOORDRedis內部使用有序集合(zset)保存用戶的位置信息,zset中每個元素都是一個帶位置的對象,元素的score值為通過經、緯度計算出的52位geohash值。
2、利弊分析
redis實現附近的人效率比較高,集成也比較簡單,而且還支持對距離排序。不過,結果存在一定的誤差,要想讓結果更加精確,還需要手動將用戶中心位置與其他用戶位置計算距離后,再一次進行篩選。
3、實現
以下就是Java?redis實現版本,代碼非常的簡潔。
@Autowiredprivate?RedisTemplate<String,?Object>?redisTemplate;//GEO相關命令用到的KEYprivate?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.收集信息,存入listList<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);}六、MongoDB + 2d索引
1、設計思路
MongoDB實現附近的人,主要是通過它的兩種地理空間索引?2dsphere?和?2d。兩種索引的底層依然是基于Geohash來進行構建的。但與國際通用的Geohash還有一些不同,具體參考官方文檔。
2dsphere?索引僅支持球形表面的幾何形狀查詢。
2d?索引支持平面幾何形狀和一些球形查詢。雖然2d?索引支持某些球形查詢,但?2d?索引對這些球形查詢時,可能會出錯。所以球形查詢盡量選擇?2dsphere索引。
盡管兩種索引的方式不同,但只要坐標跨度不太大,這兩個索引計算出的距離相差幾乎可以忽略不計。
2、實現
首先插入一批位置數據到MongoDB,?collection為起名?hotel,相當于MySQL的表名。兩個字段name名稱,location?為經、緯度數據對。
db.hotel.insertMany([{'name':'hotel1',??location:[115.993121,28.676436]},{'name':'hotel2',??location:[116.000093,28.679402]},{'name':'hotel3',??location:[115.999967,28.679743]},{'name':'hotel4',??location:[115.995593,28.681632]},{'name':'hotel5',??location:[115.975543,28.679509]},{'name':'hotel6',??location:[115.968428,28.669368]},{'name':'hotel7',??location:[116.035262,28.677037]},{'name':'hotel8',??location:[116.024770,28.68667]},{'name':'hotel9',??location:[116.002384,28.683865]},{'name':'hotel10',?location:[116.000821,28.68129]}, ])接下來我們給?location?字段創建一個2d索引,索引的精度通過bits來指定,bits越大,索引的精度就越高。
db.coll.createIndex({'location':"2d"},?{"bits":11111})用geoNear命令測試一下,?near?當前坐標(經、緯度),spherical?是否計算球面距離,distanceMultiplier地球半徑,單位是米,默認6378137,?maxDistance?過濾條件(指定距離內的用戶),開啟弧度需除distanceMultiplier,distanceField?計算出的兩點間距離,字段別名(隨意取名)。
db.hotel.aggregate({$geoNear:{near:?[115.999567,28.681813],?//?當前坐標spherical:?true,?//?計算球面距離distanceMultiplier:?6378137,?//?地球半徑,單位是米,那么的除的記錄也是米maxDistance:?2000/6378137,?//?過濾條件2000米內,需要弧度distanceField:?"distance"?//?距離字段別名} })看到結果中有符合條件的數據,還多出一個字段distance?剛才設置的別名,代表兩點間的距離。
{?"_id"?:?ObjectId("5e96a5c91b8d4ce765381e58"),?"name"?:?"hotel10",?"location"?:?[?116.000821,?28.68129?],?"distance"?:?135.60095397487655?} {?"_id"?:?ObjectId("5e96a5c91b8d4ce765381e51"),?"name"?:?"hotel3",?"location"?:?[?115.999967,?28.679743?],?"distance"?:?233.71915803517447?} {?"_id"?:?ObjectId("5e96a5c91b8d4ce765381e50"),?"name"?:?"hotel2",?"location"?:?[?116.00009,?28.679402?],?"distance"?:?273.26317035334176?} {?"_id"?:?ObjectId("5e96a5c91b8d4ce765381e57"),?"name"?:?"hotel9",?"location"?:?[?116.00238,?28.683865?],?"distance"?:?357.5791936927476?} {?"_id"?:?ObjectId("5e96a5c91b8d4ce765381e52"),?"name"?:?"hotel4",?"location"?:?[?115.995593,?28.681632?],?"distance"?:?388.62555058249967?} {?"_id"?:?ObjectId("5e96a5c91b8d4ce765381e4f"),?"name"?:?"hotel1",?"location"?:?[?115.993121,?28.676436?],?"distance"?:?868.6740526419927?}總結
本文重點并不是在具體實現,旨在給大家提供一些設計思路,面試中可能你對某一項技術了解的并不深入,但如果你的知識面寬,可以從多方面說出多種設計的思路,能夠侃侃而談,那么會給面試官極大的好感度,拿到offer的概率就會高很多。而且“附近的人”?功能使用的場景比較多,尤其是像電商平臺應用更為廣泛,所以想要進大廠的同學,這類的知識點還是應該有所了解的。
總結
以上是生活随笔為你收集整理的一口气说出 4种 “附近的人” 实现方式,面试官笑了的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 搬砖到年薪百万,是怎样的一种体验?
- 下一篇: 我要彻底给你讲清楚,Java就是值传递,