怎樣實現按距離排序、范圍查找


簡單介紹

如今差點兒全部的O2O應用中都會存在“按范圍搜素、離我近期、顯示距離”等等基於位置的交互。那這種功能是怎么實現的呢?本文提供的實現方式,適用於全部數據庫。

實現

為了方便以下說明,先給出一個初始表結構。我使用的是MySQL:

CREATE TABLE `customer` ( `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主鍵', `name` VARCHAR(5) NOT NULL COMMENT '名稱', `lon` DOUBLE(9,6) NOT NULL COMMENT '經度', `lat` DOUBLE(8,6) NOT NULL COMMENT '緯度', PRIMARY KEY (`id`) ) COMMENT='商戶表' CHARSET=utf8mb4 ENGINE=InnoDB ;

實現過程主要分為四步:
1. 搜索
在數據庫中搜索出接近指定范圍內的商戶,如:搜索出1公里范圍內的。
2. 過濾
搜索出來的結果可能會存在超過1公里的。須要再次過濾。

假設對精度沒有嚴格要求,能夠跳過。


3. 排序
距離由近到遠排序。

假設不須要。能夠跳過。
4. 分頁
假設須要2、3步。才須要對分頁特殊處理。

假設不須要。能夠在第1步直接SQL分頁。

第1步數據庫完畢,后3步應用程序完畢。

step1 搜索

搜索能夠用以下兩種方式來實現。

區間查找

customer表中使用兩個字段存儲了經度和緯度,假設提前計算出經緯度的范圍。然后在這兩個字段上加上索引,那搜索性能會非常不錯。
那怎么計算出經緯度的范圍呢?已知條件是移動設備所在的經緯度,還有滿足業務要求的半徑,這非常像初中的一道平面幾何題:給定圓心坐標和半徑,求該圓外切正方形四個頂點的坐標。而我們面對的是一個球體,能夠使用spatial4j來計算。

<dependency>
    <groupId>com.spatial4j</groupId>
    <artifactId>spatial4j</artifactId>
    <version>0.5</version>
</dependency>
// 移動設備經緯度
double lon = 116.312528, lat = 39.983733;
// 千米
int radius = 1;

SpatialContext geo = SpatialContext.GEO;
Rectangle rectangle = geo.getDistCalc().calcBoxByDistFromPt(
        geo.makePoint(lon, lat), radius * DistanceUtils.KM_TO_DEG, geo, null);
System.out.println(rectangle.getMinX() + "-" + rectangle.getMaxX());// 經度范圍
System.out.println(rectangle.getMinY() + "-" + rectangle.getMaxY());// 緯度范圍

計算出經緯度范圍之后,SQL是這樣:

SELECT id, name FROM customer WHERE (lon BETWEEN ? AND ?) AND (lat BETWEEN ?

AND ?

);

須要給lon、lat兩個字段建立聯合索引:

INDEX `idx_lon_lat` (`lon`, `lat`)

geohash

geohash的原理不講了,具體能夠看這篇文章,講的非常具體。geohash算法能把二維的經緯度編碼成一維的字符串。它的特點是越相近的經緯度編碼后越類似,所以能夠通過前綴like的方式去匹配周圍的商戶。
customer表要添加一個字段。來存儲每個商戶的geohash編碼。而且建立索引。

CREATE TABLE `customer` ( `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主鍵', `name` VARCHAR(5) NOT NULL COMMENT '名稱' COLLATE 'latin1_swedish_ci', `lon` DOUBLE(9,6) NOT NULL COMMENT '經度', `lat` DOUBLE(8,6) NOT NULL COMMENT '緯度', `geo_code` CHAR(12) NOT NULL COMMENT 'geohash編碼', PRIMARY KEY (`id`), INDEX `idx_geo_code` (`geo_code`) ) COMMENT='商戶表' CHARSET=utf8mb4 ENGINE=InnoDB ;

在新增或改動一個商戶的時候,維護好geo_code,那geo_code怎么計算呢?spatial4j也提供了一個工具類GeohashUtils.encodeLatLon(lat, lon)。默認精度是12位。這個存儲做好后,就能夠通過geo_code去搜索了。拿到移動設備的經緯度,計算geo_code,這時能夠指定精度計算。那指定多長呢?我們須要一個geo_code長度和距離的對比表:

geohash length width height
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

https://en.wikipedia.org/wiki/Geohash#Cell_Dimensions

假設我們的需求是1公里范圍內的商戶,geo_code的長度設置為5就能夠了,GeohashUtils.encodeLatLon(lat, lon, 5)。計算出移動設備經緯度的geo_code之后,SQL是這樣:

SELECT id, name FROM customer WHERE geo_code LIKE CONCAT(?

, '%');

這樣會比區間查找快非常多,而且得益於geo_code的類似性,能夠對熱點區域做緩存。

但這樣使用geohash還存在一個問題。geohash終於是在地圖上鋪上了一個網格。每個網格代表一個geohash值。當傳入的坐標接近當前網格的邊界時。用上面的搜索方式就會丟失它附近的數據。

比方下圖中,在綠點的位置搜索不到白家大院,綠點和白家大院在划分的時候就分到了兩個格子中。


這里寫圖片描寫敘述
解決問題思路也比較簡單,我們查詢時。除了使用綠點的geohash編碼進行匹配外,還使用周圍8個網格的geohash編碼,這樣能夠避免這個問題。那怎么計算出周圍8個網格的geohash呢。能夠使用geohash-java來解決。

<dependency>
    <groupId>ch.hsr</groupId>
    <artifactId>geohash</artifactId>
    <version>1.3.0</version>
</dependency>
// 移動設備經緯度
double lon = 116.312528, lat = 39.983733;
GeoHash geoHash = GeoHash.withCharacterPrecision(lat, lon, 10);
// 當前
System.out.println(geoHash.toBase32());
// N, NE, E, SE, S, SW, W, NW
GeoHash[] adjacent = geoHash.getAdjacent();
for (GeoHash hash : adjacent) {
    System.out.println(hash.toBase32());
}

終於我們的sql變成了這樣:

SELECT id, name FROM customer WHERE geo_code LIKE CONCAT(?

, '%') OR geo_code LIKE CONCAT(?

, '%') OR geo_code LIKE CONCAT(?, '%') OR geo_code LIKE CONCAT(?, '%') OR geo_code LIKE CONCAT(?

, '%') OR geo_code LIKE CONCAT(?, '%') OR geo_code LIKE CONCAT(?, '%') OR geo_code LIKE CONCAT(?, '%') OR geo_code LIKE CONCAT(?, '%');

原來的1次查詢變成了9次查詢,性能肯定會下降,這里能夠優化下。還用上面的需求場景,搜索1公里范圍內的商戶。從上面的表格知道。geo_code長度為5時,網格寬高是4.9KM,用9個geo_code查詢時,范圍太大了,所以能夠將geo_code長度設置為6。即縮小了查詢范圍,也滿足了需求。還能夠繼續優化,在存儲geo_code時,僅僅計算到6位,這樣就能夠將sql變成這樣:

SELECT id, name FROM customer WHERE geo_code IN (?, ?, ?

, ?

, ?

, ?, ?

, ?, ?);

這樣將前綴匹配換成了直接匹配,速度會提升非常多。

step2 過濾

上面兩種搜索方式,都不是精確搜索,僅僅是盡量縮小搜索范圍。提升響應速度。所以須要在應用程序中做過濾,把距離大於1公里的商戶過濾掉。計算距離相同使用spatial4j

// 移動設備經緯度
double lon1 = 116.3125333347639, lat1 = 39.98355521792821;
// 商戶經緯度
double lon2 = 116.312528, lat2 = 39.983733;

SpatialContext geo = SpatialContext.GEO;
double distance = geo.calcDistance(geo.makePoint(lon1, lat1), geo.makePoint(lon2, lat2)) 
    * DistanceUtils.DEG_TO_KM;
System.out.println(distance);// KM

過濾代碼就不寫了。遍歷一遍搜索結果就可以。

step3 排序

相同。排序也須要在應用程序中處理。排序基於上面的過濾結果做就能夠了Collections.sort(list, comparator)

step4 分頁

假設須要2、3步,僅僅能在內存中分頁,做法也非常easy。能夠參考這篇文章

總結

全文的重點都在於搜索怎樣實現,更好的利用數據庫的索引,兩種搜索方式以百萬數據量為切割線,第一種適用於百萬以下。另外一種適用於百萬以上,未經過嚴格驗證

可能有人會有疑問,過濾和排序都在應用層做。內存占用會不會非常嚴重?這是個潛在問題,但大多數情況下不會。

看我們大部分的應用場景,都是單一種類POI(Point Of Interest)的搜索,如酒店、美食、KTV、電影院等等,這種數據密度是非常小。1公里內的酒店。能有多少家,50家都算多的,所以終於要看具體業務數據密度。本文沒有分析原理,僅僅講了具體實現。有關分析的文章能夠看參考鏈接。


參考

http://www.infoq.com/cn/articles/depth-study-of-Symfony2
http://tech.meituan.com/lucene-distance.html
http://blog.csdn.net/liminlu0314/article/details/8553926
http://janmatuschek.de/LatitudeLongitudeBoundingCoordinates
http://www.cnblogs.com/LBSer/p/3310455.html
http://cevin.net/geohash/

本文來自:高爽|Coder,原文地址:http://blog.csdn.net/ghsau/article/details/50591932,轉載請注明。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM