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>
- 首先建立一張資料表
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;
- 假設已插入足夠的測試資料,只要我們獲取到外接正方形的四個關鍵點,就可以直接直接查詢
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);
}
- 這裡給出查詢的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字串。
- 新增新使用者時計算該使用者的geohash字串,並存儲到使用者表中
- 當要查詢某一gps附近指定距離的使用者資訊時,通過比對geohash誤差表確定需要的geohash字串精度
- 計算獲得某一精度的當前座標的geohash字串,通過
WHERE geohash Like 'geohashcode%'
來查詢資料集 - 如果geohash字串的精度遠大於給定的距離範圍時,查詢出的結果集中必然存在在範圍之外的資料
- 計算兩點之間距離,對於超出距離的資料進行剔除。
程式碼實現
這裡只貼出部分核心程式碼,詳細的程式碼可見原始碼:NearBySearch
同樣的要涉及到座標點的計算和geohash的計算,開始之前先匯入spatial4j
- 建立資料表
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;
- 新增使用者資訊和範圍搜尋邏輯
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核心原理解