1. 程式人生 > >結合MongoDB開發LBS應用

結合MongoDB開發LBS應用

簡介

隨著近幾年各類移動終端的迅速普及,基於地理位置的服務(LBS)和相關應用也越來越多,而支撐這些應用的最基礎技術之一,就是基於地理位置資訊的處理。我所在的專案也正從事相關係統的開發,我們使用的是Symfony2+Doctrine2 ODM+MongoDB的組合。

我們將這些技術要點整理成文,希望能夠通過本文的介紹和案例,詳細解釋如何使用MongoDB進行地理位置資訊的查詢和處理。在文章的開頭,我們也會先介紹一下業界通常用來處理地理位置資訊的一些方案並進行比較,讓讀者逐步瞭解使用MongoDB查詢及處理地理位置資訊的優勢。

本文使用了Symfony2和Doctrine2作為Web應用的開發框架,對於想了解Symfony2的資料庫操作的讀者來說閱讀本文也可以瞭解和掌握相關的技術和使用方法。

 

1. LBS類應用特點

不管是什麼LBS應用,一個共同的特點就是:他們的資料都或多或少包含了地理位置資訊。而如何對這些資訊進行查詢、處理、分析,也就成為了支撐LBS應用的最基礎也是最關鍵的技術問題。

而由於地理位置資訊的特殊性,在開發中經常會有比較難以處理的問題出現,比如:由於使用者所在位置的不固定性,使用者可能會在很小範圍內移動,而此時經緯度值也會隨之變化;甚至在同一個位置,通過GPS裝置獲取到的位置資訊也可能不一樣。所以如果通過經緯度去獲取周邊資訊時,就很難像傳統資料庫那樣做查詢並進行快取。

對於這個問題,有讀者可能會說有別的處理方案,沒錯,比如只按經緯度固定的幾位小數點做索引,比如按矩陣將使用者劃分到某固定小範圍的區域(可以參考後文將會提到的geohash)等方式,雖然可以繞個彎子解決,但或多或少操作起來比較麻煩,也會犧牲一些精度,甚至無法做到效能的最優化,所以不能算作是最佳的解決辦法。

而最近幾年,直接支援地理位置操作的資料庫層出不窮,其操作友好、效能高的特性也開始被我們慢慢重視起來,其中的佼佼者當屬MongoDB。

MongoDB在地理位置資訊的處理上有什麼優勢?下面我們通過一個簡單的案例來對比一下各種技術方案之間進行進行地理位置資訊處理的差異。

2. 幾個地理位置資訊處理方案的對比和分析

1. 確定功能需求

對於任何LBS應用來說,讓使用者尋找周圍的好友可能都是一個必不可少的功能,我們就以這個功能為例,來看看各種處理方案之間的差異和區別。

我們假設有如下功能需求:

  • 顯示我附近的人
  • 由近到遠排序
  • 顯示距離

2. 可能的技術方案

排除一些不通用和難以實現的技術,我們羅列出以下幾種方案:

  1. 基於MySQL資料庫
  2. 採用GeoHash索引,基於MySQL
  3. MySQL空間儲存(MySQL Spatial Extensions)
  4. 使用MongoDB儲存地理位置資訊

我們一個個來分析這幾種方案。

方案1:基於MySQL資料庫

MySQL的使用非常簡單。對於大部分已經使用MySQL的網站來說,使用這種方案沒有任何遷移和部署成本。

而在MySQL中查詢“最近的人”也僅需一條SQL即可,

SELECT id, ( 6371 * acos( cos( radians(37) ) * cos( radians( lat ) ) * cos( radians
( lng ) - radians(-122) ) + sin( radians(37) ) * sin( radians( lat ) ) ) ) AS distance
 FROM places HAVING distance < 25 ORDER BY distance LIMIT 0 , 100;

注:這條SQL查詢的是在lat,lng這個座標附近的目標,並且按距離正序排列,SQL中的distance單位為公里。

但使用SQL語句進行查詢的缺點也顯而易見,每條SQL的計算量都會非常大,效能將會是嚴重的問題。

先別放棄,我們嘗試對這條SQL做一些優化。

可以將圓形區域抽象為正方形,如下圖

根據維基百科上的球面計算公式,可以根據圓心座標計算出正方形四個點的座標。

然後,查詢這個正方形內的目標點。

SQL語句可以簡化如下:

SELECT * FROM places WHERE ((lat BETWEEN ? AND ?) AND (lng BETWEEN ? AND ?))

這樣優化後,雖然資料不完全精確,但效能提升很明顯,並且可以通過給lat lng欄位做索引的方式進一步加快這條SQL的查詢速度。對精度有要求的應用也可以在這個結果上再進行計算,排除那些在方塊範圍內但不在原型範圍內的資料,已達到對精度的要求。

可是這樣查詢出來的結果,是沒有排序的,除非再進行一些SQL計算。但那又會在查詢的過程中產生臨時表排序,可能會造成效能問題。

方案2:GeoHash索引,基於MySQL

GeoHash是一種地址編碼,通過切分地圖區域為小方塊(切分次數越多,精度越高),它能把二維的經緯度編碼成一維的字串。也就是說,理論上geohash字串表示的並不是一個點,而是一個矩形區域,只要矩形區域足夠小,達到所需精度即可。(其實MongoDB的索引也是基於geohash

如:wtw3ued9m就是目前我所在的位置,降低一些精度,就會是wtw3ued,再降低一些精度,就會是wtw3u。(點選連結檢視座標編碼對應Google地圖的位置)

所以這樣一來,我們就可以在MySQL中用LIKE ‘wtw3u%’來限定區域範圍查詢目標點,並且可以對結果集做快取。更不會因為微小的經緯度變化而無法用上資料庫的Query Cache。

這種方案的優點顯而易見,僅用一個字串儲存經緯度資訊,並且精度由字串從頭到尾的長度決定,可以方便索引。

但這種方案的缺點是:從geohash的編碼演算法中可以看出,靠近每個方塊邊界兩側的點雖然十分接近,但所屬的編碼會完全不同。實際應用中,雖然可以通過去搜索環繞當前方塊周圍的8個方塊來解決該問題,但一下子將原來只需要1次SQL查詢變成了需要查詢9次,這樣不僅增大了查詢量,也將原本簡單的方案複雜化了。

除此之外,這個方案也無法直接得到距離,需要程式協助進行後續的排序計算。

方案3:MySQL空間儲存

MySQL的空間擴充套件(MySQL Spatial Extensions),它允許在MySQL中直接處理、儲存和分析地理位置相關的資訊,看起來這是使用MySQL處理地理位置資訊的“官方解決方案”。但恰恰很可惜的是:它卻不支援某些最基本的地理位置操作,比如查詢在半徑範圍內的所有資料。它甚至連兩座標點之間的距離計算方法都沒有(MySQL Spatial的distance方法在5.*版本中不支援)

官方指南的做法是這樣的:

GLength(LineStringFromWKB(LineString(point1, point2)))

這條語句的處理邏輯是先通過兩個點產生一個LineString的型別的資料,然後呼叫GLength得到這個LineString的實際長度。

這麼做雖然有些複雜,貌似也解決了距離計算的問題,但讀者需要注意的是:這種方法計算的是歐式空間的距離,簡單來說,它給出的結果是兩個點在三維空間中的直線距離,不是飛機在地球上飛的那條軌跡,而是筆直穿過地球的那條直線。

所以如果你的地理位置資訊是用經緯度進行儲存的,你就無法簡單的直接使用這種方式進行距離計算。

方案4:使用MongoDB儲存地理位置資訊

MongoDB原生支援地理位置索引,可以直接用於位置距離計算和查詢。

另外,它也是如今最流行的NoSQL資料庫之一,除了能夠很好地支援地理位置計算之外,還擁有諸如面向集合儲存、模式自由、高效能、支援複雜查詢、支援完全索引等等特性。

對於我們的需求,在MongoDB只需一個命令即可得到所需要的結果:

db.runCommand( { geoNear: "places", near: [ 121.4905, 31.2646 ], num:100 })

查詢結果預設將會由近到遠排序,而且查詢結果也包含目標點物件、距離目標點的距離等資訊。

由於geoNear是MongoDB原生支援的查詢函式,所以效能上也做到了高度的優化,完全可以應付生產環境的壓力。

方案總結

基於MongoDB做附近查詢是很方便的一件事情。

MongoDB在地理位置資訊方面的表現遠遠不限於此,它還支援更多更加方便的功能,如範圍查詢、距離自動計算等。

接下來,我們結合Symfony2來詳細地演示一些使用MongoDB進行地理位置資訊處理的例子。

3. 結合Symfony2演示

執行環境

參考環境:Nginx1.2 + PHP5.4 + MongoDB2.4.3 + Symfony2.1

建立coordinate和places兩個document檔案,前者是作為places內的一個embed欄位. 為方便演示效果,這裡同時設定了兩個索引 2d 和 2dsphere

    Document/Coordinate.php

    /**
     * @MongoDB\EmbeddedDocument
     */
    class Coordinate {
        /**
        * @MongoDB\Field(type="float")
        */
        public $longitude;

        /**
        * @MongoDB\Field(type="float")
        */
        public $latitude;

        ...
    }

    Document/Place.php

    /**
    * @MongoDB\Document(collection="places")
    * @MongoDB\ChangeTrackingPolicy("DEFERRED_EXPLICIT")
    * @MongoDB\Indexes({
    *   @MongoDB\Index(keys={"coordinate"="2d"}),
    *   @MongoDB\Index(keys={"coordinate"="2dsphere"})
    * })
    */
    class Place
    {
        /**
        *
        * @MongoDB\Id(strategy="INCREMENT")
        */
        protected $id;

        /**
        * @MongoDB\Field(type="string")
        */
        protected $title;

        /**
        * @MongoDB\Field(type="string")
        */
        protected $address;

        /**
        * @MongoDB\EmbedOne(targetDocument="HenterGEO\GEOBundle\Document\Coordinate")
        */
        protected $coordinate;

        /**
        * @MongoDB\Distance
        */
        public $distance;

        ...
    }

座標儲存以longitude, latitude這個順序(沒有明確的限制和區別,但我們在此遵循官方的推薦)。

另外,為直觀顯示查詢效果,預設使用百度地圖示記查詢資料。

程式說明

我們用到的程式碼包是doctrine/mongodb-odm-bundle(下文稱ODM),這個程式碼包提供了在Symfony2環境下的MongoDB資料庫支援,使用這個程式碼包,可以讓我們更加方便的在Symfony2環境下操作MongoDB資料庫。。

ODM封裝了MongoDB中常用的一些地理位置函式,如周邊搜尋和範圍搜尋。

ODM中的操作預設距離單位是度,只有geoSphere支援弧度單位(必須在引數中指定spherical(true))

4. MongoDB的地理位置查詢

注意事項

  1. 下文大多數直接對MongoDB的資料庫操作將使用Mongo Shell進行演示。在演示網站頁面和功能時,將結合Symfony2、Doctrine-MongoDB進行演示。
  2. 本文演示所用的MongoDB版本為2.4.3,版本號比較新,所以某些查詢方式在低版本里面並不支援。
  3. 以places這個collection為例,大部分例子都需要類似下面格式的測試資料支援: { "_id" : 2, "coordinate" : { "longitude" : 121.3449, "latitude" : 31.17528 }, "title" : "僅售75元,市場價210元的頂呱呱田雞火鍋3-4人套餐,無餐具費,冬日暖鍋,歡迎品嚐", "address" : "閔行區航新路634號" }

地理位置索引:

MongoDB地理位置索引常用的有兩種。

  • 2d 平面座標索引,適用於基於平面的座標計算。也支援球面距離計算,不過官方推薦使用2dsphere索引。
  • 2dsphere 幾何球體索引,適用於球面幾何運算

關於兩個座標之間的距離,官方推薦2dsphere:

MongoDB supports rudimentary spherical queries on flat 2d indexes for legacy reasons. In general, spherical calculations should use a 2dsphere index, as described in 2dsphere Indexes.

不過,只要座標跨度不太大(比如幾百幾千公里),這兩個索引計算出的距離相差幾乎可以忽略不計。

建立索引:

> db.places.ensureIndex({'coordinate':'2d'})
> db.places.ensureIndex({'coordinate':'2dsphere'})

查詢方式:

查詢方式分三種情況:

  1. Inclusion。範圍查詢,如百度地圖“視野內搜尋”。
  2. Inetersection。交集查詢。不常用。
  3. Proximity。周邊查詢,如“附近500內的餐廳”。

而查詢座標引數則分兩種:

  1. 座標對(經緯度)根據查詢命令的不同,$maxDistance距離單位可能是 弧度 和 平面單位(經緯度的“度”):

      db.<collection>.find( { <location field> :
                       { $nearSphere: [ <x> , <y> ] ,
                         $maxDistance: <distance in radians>
                    } } )
  2. GeoJson $maxDistance距離單位預設為米:

      db.<collection>.find( { <location field> :
                       { $nearSphere :
                         { $geometry :
                            { type : "Point" ,
                              coordinates : [ <longitude> , <latitude> ] } ,
                           $maxDistance : <distance in meters>
                 } } } )

案例A:附近的人

查詢當前座標附近的目標,由近到遠排列。

可以通過$near或$nearSphere,這兩個方法類似,但預設情況下所用到的索引和距離單位不同。

查詢方式:

> db.places.find({'coordinate':{$near: [121.4905, 31.2646]}})
> db.places.find({'coordinate':{$nearSphere: [121.4905, 31.2646]}})

查詢結果:

{ 
    "_id" : 115, 
    "coordinate" : { 
        "longitude" : 121.4915, 
        "latitude" : 31.25933 
    }, 
    "title" : "僅售148元,市場價298元的星程上服假日酒店全日房一間入住一天,
節假日通用,精緻生活,品質享受", 
    "address" : "虹口區天水路90號" 
}

…(100條)

上述查詢座標[121.4905, 31.2646]附近的100個點,從最近到最遠排序。

預設返回100條資料,也可以用limit()指定結果數量,如

> db.places.find({'coordinate':{$near: [121.4905, 31.2646]}}).limit(2)

指定最大距離 $maxDistance

> db.places.find({'coordinate':{$near: [121.4905, 31.2646], $maxDistance:2}})

結合Symfony2進行演示:

這裡用near,預設以度為單位,公里數除以111(關於該距離單位後文有詳細解釋)。

/**
 * @Route("/near", name="near")
 * @Template()
 */
public function nearAction(){

    $longitude = (float)$this->getRequest()->get('lon',121.4905);
    $latitude = (float)$this->getRequest()->get('lat',31.2646);
    //2km
    $max = (float)$this->getRequest()->get('max', 2);

    $places = $this->getPlaceRepository()->createQueryBuilder()
        ->field('coordinate')->near($longitude, $latitude)
        ->maxDistance($max/111)
        ->getQuery()->toarray();

    return compact('places','max','longitude','latitude');
}

通過 domain.dev/near 訪問,效果如下:

longitude: xxx, latitude: xxx為當前位置,我們在地圖上顯示了周邊100條目標記錄

案例B:區域內搜尋

MongoDB中的範圍搜尋(Inclusion)主要用$geoWithin這個命令,它又細分為3種不同型別,如下:

  1. $box 矩形
  2. $center 圓(平面),$centerSphere圓(球面)
  3. $polygon 多邊形

$center和$centerSphere在小範圍內的應用幾乎沒差別(除非這個圓半徑幾百上千公里)。

下面我們介紹一下這三種查詢的案例。

矩形區域

這個比較常用,比如百度地圖的視野內搜尋(矩形)、或搜狗地圖的“拉框搜尋”

定義一個矩形範圍,我們需要指定兩個座標,在MongoDB的查詢方式如下:

> db.places.find( 
    { 
        coordinate : { 
            $geoWithin : { 
                $box :[ [ 121.44, 31.25 ] , [ 121.5005, 31.2846 ] ] 
            } 
        } 
    } 
)

查詢結果:

{ 
    "_id" : 90472, 
    "title" : "【魯迅公園】僅售99元!酒店門市價288元的上海虹元商務賓館客房一間入住一天(需持本人有效
身份證件辦理登記):大床房/標準房(2選1)!不含早餐!不涉外!2012年9月29日-10月6日
不可使用拉手券!可延遲退房至14:00!", 
    "address" : "上海市虹口區柳營路8號", 
    "coordinate" : { 
        "longitude" : 121.47, 
        "latitude" : 31.27145 
    } 
}
...
...

Symfony2演示程式碼:

指定兩個座標點

/**
 * @Route("/box", name="box")
 * @Template()
 */
public function boxAction(){
    $request = $this->getRequest();

    $longitude = (float)$request->get('lon',121.462035);
    $latitude = (float)$request->get('lat',31.237641);

    $longitude2 = (float)$request->get('lon2',121.522098);
    $latitude2 = (float)$request->get('lat2',31.215284);

    $places = $this->getPlaceRepository()->createQueryBuilder()
        ->field('coordinate')->withinBox($longitude, $latitude, 
$longitude2, $latitude2)
        ->getQuery()->toarray();

    return compact('places','longitude','latitude', 'longitude2', 'latitude2');
}

通過 domain.dev/box 訪問,效果如下:

圓形區域

應用場景有:地圖搜尋租房資訊

查詢以某座標為圓心,指定半徑的圓內的資料。

前面已提到,圓形區域搜尋分為$center和$centerSphere這兩種型別,它們的區別主要在於支援的索引和預設距離單位不同。

2d索引能同時支援$center和$centerSphere,2dsphere索引支援$centerSphere。關於距離單位,$center預設是度,$centerSphere預設距離是弧度。

查詢方式如下:

> db.places.find({'coordinate':{$geoWithin:{$centerSphere:[ [121.4905, 31.2646] ,
0.6/111] }}})
或
> db.places.find({'coordinate':{$geoWithin:{$centerSphere:[ [121.4905, 31.2646] ,
0.6/6371] }}})
查詢結果
{ 
    "_id" : 115, 
    "coordinate" : { 
        "longitude" : 121.4915, 
        "latitude" : 31.25933 
    }, 
    "title" : "僅售148元,市場價298元的星程上服假日酒店全日房一間入住一天,節假日通用,
精緻生活,品質享受", 
    "address" : "虹口區天水路90號" 
}
...

Symfony2演示程式碼:

指定圓心座標和半徑

/**
 * @Route("/center", name="center")
 * @Template()
 */
public function centerAction(){
    $request = $this->getRequest();

    $longitude = (float)$request->get('lon',121.4905);
    $latitude = (float)$request->get('lat',31.2646);
    //10km
    $max = (float)$request->get('max', 10);

    $places = $this->getPlaceRepository()->createQueryBuilder()
        ->field('coordinate')->withinCenter($longitude, $latitude, $max/111)
        ->getQuery()->toarray();

    return compact('places','max','longitude','latitude');
}

通過 domain.dev/center 訪問,效果如下:

以longitude: xxx,latitude: xxx為中心點,半徑10km的圓內

多邊形

複雜區域內的查詢,這個應用場景比較少見。指定至少3個座標點,查詢方式如下(五邊形):

> db.places.find( { coordinate : { $geoWithin : { $polygon : [ 
    [121.45183 , 31.243816] ,
    [121.533181, 31.24344] ,
    [121.535049, 31.208983] ,
    [121.448955, 31.214913] ,
    [121.440619, 31.228748]
] } } } )

查詢結果

{ 
    "_id" : 90078, 
    "title" : "僅售9.9元,市場價38元的燕太太燕窩單人甜品餐,用耐心守候一盅燉品,用愛滋補一生情誼", 
    "address" : "河南南路489號香港名都購物廣場1F125燕太太燕窩", 
    "coordinate" : { 
        "longitude" : 121.48912, 
        "latitude" : 31.22355 
    } 
}
...

Symfony2演示程式碼(這裡為方便,直接寫死了5個座標點):

/**
 * @Route("/polygon", name="polygon")
 * @Template()
 */
public function polygonAction(){
    $points = [];
    $points[] = [121.45183,31.243816];
    $points[] = [121.533181,31.24344];
    $points[] = [121.535049,31.208983];
    $points[] = [121.448955,31.214913];
    $points[] = [121.440619,31.228748];

    $sumlon = $sumlat = 0;
    foreach($points as $p){
        $sumlon += $p[0];
        $sumlat += $p[1];
    }
    $center = [$sumlon/count($points), $sumlat/count($points)];

    $places = $this->getPlaceRepository()->createQueryBuilder()
        ->field('coordinate')->withinPolygon($points[0], $points[1], $points[2], 
$points[3], $points[4])
        ->getQuery()->toarray();

    return compact('places','points', 'center');
}

通過 domain.dev/polygon 訪問,效果如下:

案例C:附近的餐廳

我們假設需要以當前座標為原點,查詢附近指定範圍內的餐廳,並直接顯示距離。

這個需求用前面提到的$near是可以實現的,但是距離需要二次計算。這裡我們用$geoNear這個命令查詢。

$geoNear與$near功能類似,但提供更多功能和返回更多資訊,官方文件是這麼解釋的:

The geoNear command provides an alternative to the $near operator. In addition to the functionality of $near, geoNear returns additional diagnostic information.

查詢方式如下(關於下面的示例用到了distanceMultipler函式,後文會詳細解釋):

> db.runCommand( { geoNear: "places", near: [ 121.4905, 31.2646 ], spherical: true,
 maxDistance:1/6371, num:2 })
{
    "ns" : "mongo_test.places",
    "near" : "1110001100111100001011010110010111001000110011111101",
    "results" : [
        {
            "dis" : 0.00009318095248858048,
            "obj" : {
                "_id" : 115,
                "coordinate" : {
                    "longitude" : 121.4915,
                    "latitude" : 31.25933
                },
                "title" : "僅售148元,市場價298元的星程上服假日酒店全日房一間入住一天,
節假日通用,精緻生活,品質享受",
                "address" : "虹口區天水路90號"
            }
        },
        {
            "dis" : 0.00010610660597329082,
            "obj" : {
                "_id" : 465,
                "coordinate" : {
                    "longitude" : 121.48406,
                    "latitude" : 31.26202
                },
                "title" : "【四川北路】熱烈慶祝康駿會館成立8週年!僅售169元!市場價399元的
康駿會館四川北路一店(僅限3星級技師)全身精油按摩一人次!全程約90分鐘!
男女不限!僅限四川北路一店使用,非本市所有門店通用!拉手券消費僅限每日19:00前!
健康有道,駿越萬里!",
                "address" : "虹口區四川北路1896號-1904號201室"
            }
        }
    ],
    "stats" : {
        "time" : 0,
        "btreelocs" : 0,
        "nscanned" : 18,
        "objectsLoaded" : 12,
        "avgDistance" : 0.00009964377923093564,
        "maxDistance" : 0.0001064199324957278
    },
    "ok" : 1
}

可以看到返回了很多詳細資訊,如查詢時間、返回數量、最大距離、平均距離等。

另外,results裡面直接返回了距離目標點的距離dis。

Symfony2演示程式碼:

/**
 * @Route("/distance", name="distance")
 * @Template()
 */
public function distanceAction(){

    $longitude = (float)$this->getRequest()->get('lon',121.4905);
    $latitude = (float)$this->getRequest()->get('lat',31.2646);
    //2km
    $max = (float)$this->getRequest()->get('max', 2);

    $places = $this->getPlaceRepository()->createQueryBuilder()
        ->field('coordinate')
        ->geoNear($longitude, $latitude)
        ->spherical(true)
        ->distanceMultiplier(6371)
        ->maxDistance($max/6371)
        ->limit(100)
        ->getQuery()
        ->execute()
        ->toArray();

    return compact('places','longitude', 'latitude', 'max');
}

通過 domian.dev/distance 訪問,效果如下:

距離xxx米

小結

前面演示的查詢程式碼中,座標都是按照 longitude, latitude這個順序的。

這個是官方建議的座標順序,但是網上很多文件是相反的順序,經測試發現,只要查詢時指定的座標順序與資料庫內的座標順序一致,出來的結果就是正確的,沒有特定的先後順序之分。

但鑑於官方文件的推薦,我在此還是建議大家按照官方推薦的順序。

案例A的$near和案例B的$center從需求上看差不多,但是$center或$centerSphere是屬於$geoWithin的型別,$near方法查詢後會對結果集對距離進行排序,而$geoWithin是無序的。

常用的查詢方式已經介紹完了,不常用的比如geoIntersect查詢,這裡不做介紹,但是已經包含在開源的演示程式裡了,有興趣的讀者可以自行測試研究。

下面介紹前文提到的距離單位等問題。

5. 需要注意的問題

索引

$near命令必須要求有索引。

$geoWithin可以無需索引,但是建議還是建立索引以提升效能。

距離單位

MongoDB查詢地理位置預設有3種距離單位:

  • 米(meters)
  • 平面單位(flat units,可以理解為經緯度的“一度”)
  • 弧度(radians)。

通過GeoJSON格式查詢,單位預設是米,通過其它方式則比較混亂,下面詳細解釋一下。

下面的查詢語句指定距離內的目標:

> db.places.find({'coordinate':{$near: [121.4905, 31.2646], $maxDistance:2}})

現在$maxDistance引數是2,但是如果我要查詢如“附近500米內的餐廳”這樣的需求,這個引數應該是多少?

關於距離計算,MongoDB的官方文件僅僅提到了弧度計算,未說明水平單位(度)計算。

關於弧度計算,官方文件的說明是:

To convert: distance to radians: divide the distance by the radius of the sphere (e.g. the Earth) in the same units as the distance measurement. radians to distance: multiply the radian measure by the radius of the sphere (e.g. the Earth) in the units system that you want to convert the distance to. 

The radius of the Earth is approximately 3,959 miles or 6,371 kilometers.

所以如果用弧度查詢,則以公里數除以6371,如“附近500米的餐廳”:

> db.runCommand( { geoNear: "places", near: [ 121.4905, 31.2646 ], spherical: true,
 $maxDistance: 0.5/6371 })

那如果不用弧度,以水平單位(度)查詢時,距離單位如何處理?

答案是以公里數除以111(推薦值),原因如下:

經緯度的一度,分為經度一度和緯度一度。

地球不同緯度之間的距離是一樣的,地球子午線(南極到北極的連線)長度39940.67公里,緯度一度大約110.9公里

但是不同緯度的經度一度對應的長度是不一樣的:

在地球赤道,一圈大約為40075KM,除以360度,每一個經度大概是:40075/360=111.32KM

上海,大概在北緯31度,對應一個經度的長度是:40075*sin(90-31)/360=95.41KM

北京在北緯40度,對應的是85KM

前面提到的引數111,這個值只是估算,並不完全準確,任意兩點之間的距離,平均緯度越大,這個引數則誤差越大。詳細原因可以參考wiki上的解釋:http://en.wikipedia.org/wiki/Latitude

但是,即便如此,“度”這個單位只用於平面,由於地球是圓的,在大範圍使用時會有誤差。

官方建議使用sphere查詢方式,也就是說距離單位用弧度。

The current implementation assumes an idealized model of a flat earth, meaning that an arcdegree of latitude (y) and longitude (x) represent the same distance everywhere. This is only true at the equator where they are both about equal to 69 miles or 111km. However, at the 10gen offices at { x : -74 , y : 40.74 } one arcdegree of longitude is about 52 miles or 83 km (latitude is unchanged). This means that something 1 mile to the north would seem closer than something 1 mile to the east.

$geoNear返回結果集中的dis,如果指定了spherical為true, dis的值為弧度,不指定則為度。

指定 spherical為true,結果中的dis需要乘以6371換算為km:

> db.runCommand( { geoNear: "places", near: [ 121.4905, 31.2646 ], 
spherical: true, num:1 })
{
    "ns" : "mongo_test.places",
    "near" : "1110001100111100001011010110010111001000110011111101",
    "results" : [
        {
            "dis" : 0.00009318095248858048,
            "obj" : {
                "_id" : 115,
                "coordinate" : {
                    "longitude" : 121.4915,
                    "latitude" : 31.25933
                },
                "title" : "僅售148元,市場價298元的星程上服假日酒店全日房一間入住一天,節假日通用,
精緻生活,品質享受",
                "address" : "虹口區天水路90號"
            }
        }

    ],
    "stats" : {
        "time" : 0,
        "btreelocs" : 0,
        "nscanned" : 18,
        "objectsLoaded" : 12,
        "avgDistance" : 0.00009964377923093564,
        "maxDistance" : 0.0001064199324957278
    },
    "ok" : 1
}

不指定sphericial,結果中的dis需要乘以111換算為km:

> db.runCommand( { geoNear: "places", near: [ 121.4905, 31.2646 ], num:1 })
{
    "ns" : "mongo_test.places",
    "near" : "1110001100111100001011010110010111001000110011111101",
    "results" : [
        {
            "dis" : 0.005364037658335473,
            "obj" : {
                "_id" : 115,
                "coordinate" : {
                    "longitude" : 121.4915,
                    "latitude" : 31.25933
                },
                "title" : "僅售148元,市場價298元的星程上服假日酒店全日房一間入住一天,節假日通用,
精緻生活,品質享受",
                "address" : "虹口區天水路90號"
            }
        }   

    ],
    "stats" : {
        "time" : 0,
        "btreelocs" : 0,
        "nscanned" : 18,
        "objectsLoaded" : 12,
        "avgDistance" : 0.006150808243357531,
        "maxDistance" : 0.00695541352612983
    },
    "ok" : 1
}

說到這裡讀者是不是已經有點迷糊了?沒關係,在開發中其實你並不需要去知道各種距離單位的歷史和使用它的原因,我在此為你總結了一張表,大部分常用的函式和所使用的距離單位都已經被我整理了出來,你只需要參考表上所列的距離單位直接使用即可。

查詢命令 距離單位 說明
$near 官方文件上關於這一點是錯的
$nearSphere 弧度  
$center  
$centerSphere 弧度  
$polygon  
$geoNear 度或弧度 指定引數spherical為true則為弧度,否則為度

如果座標以GeoJSON格式,則單位都為米。

當然如果你的操作比較複雜,或者希望知道更加詳細的對照關係,也可以參考官方的這個更詳細的對比表格:http://docs.mongodb.org/manual/reference/operator/query-geospatial/

單位自動換算

如上面兩個geoNear示例,結果中的dis,前文已經提過這是與目標點的距離,但是這個距離單位是跟查詢單位一致的,需要二次計算,不太方便。

而其實可以直接在查詢時指定 distanceMultiplier ,它會將這個引數乘以距離返回,如指定為6371,返回的就是公里數。

> db.runCommand({ geoNear : "places", near : [121.4905, 31.2646], spherical : true,
 maxDistance : 1/6371, distanceMultiplier: 6371})
{
    "ns" : "mongo_test.places",
    "near" : "1110001100111100001011010110010111001000110011111101",
    "results" : [
        {
            "dis" : 0.5936558483047463,
            "obj" : {
                "_id" : 115,
                "coordinate" : {
                    "longitude" : 121.4915,
                    "latitude" : 31.25933
                },
                "title" : "僅售148元,市場價298元的星程上服假日酒店全日房一間入住一天,節假日通用,
精緻生活,品質享受",
                "address" : "虹口區天水路90號"
            }
        },

        …
        …

    ],
    "stats" : {
        "time" : 0,
        "btreelocs" : 0,
        "nscanned" : 15,
        "objectsLoaded" : 9,
        "avgDistance" : 0.6348305174802911,
        "maxDistance" : 0.0001064199324957278
    },
    "ok" : 1
}

注意上面的結果中dis的值,已經是km單位的了。

結語

通過前面的案例演示,相信大家對MongoDB的地理位置特性已經比較瞭解。

MongoDB還有很多很酷的功能,地址位置支援僅是其中一項。希望以後能有機會為各位讀者介紹如何結合Symfony2使用MongoDB進行應用開發的更多案例。

文中的演示程式已經發布在了Github上,地址是https://github.com/henter/HenterGEO,讀者可以直接使用。

參考:

http://docs.mongodb.org/manual/

https://wiki.10gen.com/pages/viewpage.action?pageId=21268367&navigatingVersions=true

http://en.wikipedia.org/wiki/Radian

http://www.scribd.com/doc/2569355/Geo-Distance-Search-with-MySQL

http://www.phpchina.com/resource/manual/mysql/spatial-extensions-in-mysql.html

http://derickrethans.nl/spatial-indexes-mysql.html

http://dev.mysql.com/doc/refman/5.6/en/spatial-extensions.html

http://dev.mysql.com/doc/refman/4.1/en/functions-that-test-spatial-relationships-between-geometries.html#function_distance

http://blog.nosqlfan.com/html/1811.html

http://en.wikipedia.org/wiki/Geohash

關於作者

周攀,多年網際網路從業經驗,精通Symfony2框架,微博閱後即焚應用SnapWeibo的作者,斯諾克愛好者,養貓達人。讀者可以通過他的微博 @周攀Henter或郵件(henter at henter dot me)與他取得聯絡。