今日も適当ダイアリー

PHP や Javascript や Symfony、BEAR.Sunday などのWeb周りのことを中心に。それ以外のことも気まぐれに投稿します。

「第1回 MongoDB JP & CouchDB JP 合同勉強会 in Tokyo」でMongoDB 地理空間インデックスについてLTしました+

昨日(2010/12/12)開催された「第1回 MongoDB JP & CouchDB JP 合同勉強会 in Tokyo」で、MongoDBの地理空間インデックスの紹介をLTとしてやりました。

初LTで緊張しまくってましたが(?)皆様の暖かいまなざしに救われ、なんとか無事に終われました。

勉強会全体の内容については、主催者でもある@doryokujinさんがまとめられた記事をご覧ください。
「第1回 MongoDB JP & CouchDB JP 合同勉強会 in Tokyo」を開催してきました! - doryokujin's blog

で、地理空間インデックスに関するいくつかの補足と、コピペ用のテキストを書いておきたいと思います。

Geospatial Indexingの速度

昨日、Geospatial Indexingの速度はどうなのか聞かれ、数万件程度のレコードでは(普通のIndexに比べて)重くは感じない、と答えたのですが、気になったのでちゃちゃっと検証してみました。
(簡易的な検証ですので参考程度に)

検証環境
  • CPU: Xeon 2.26 GHz (4コア)
  • Memory: 4G
  • OS: CentOS 5.2
  • PHP: 5.3.3
  • MongoDB: 1.7.3

localhost上での検証。各テストは3回計測し、その平均値を掲載しています)

保存

1,000万件のレコードをインサートした時の結果です。

名前 普通のIndex(ObjectId) GeoIndex
合計時間(秒) 942.7 1,162.2 (+23%)
秒間に処理された件数(/sec) 10,613.4 8,606.7 (-19%)

※カッコ内は、ObjectIdに比べての増減の比率

GeoIndexについては、あらかじめensureIndexを実行してあります。

ObjectIdと比較すると、保存では2割程度の速度低下が見られました。
これは、内部的にGeoHash化するため、その計算処理が多くを占めていると思われます。

検索

上記で保存したデータ(1,000万件)を、ObjectIdでの検索と、GeoIndexの$near, $nearSphereで検索をそれぞれ10万回した時の結果です。
(findOne で実行)

名前 普通のIndex(ObjectId) $near $nearSphere
合計時間(秒) 6.2 8.5 (+37%) 9.3 (+50%)
秒間に処理された件数(/sec) 16,155.4 11,770.8 (-38%) 10,770.8 (-51%)

※カッコ内は、ObjectIdに比べての増減の比率

検索に関しては、ObjectIdと比較し、4〜5割程度の速度低下となりました。
これは、基点のGeoHash処理に加え、GeoHashでの前方一致検索を行った後に、距離計算とソートが行われるためかと思われます。

結論

スライド内容の補足と、コピペ用コードたち

地理空間のインデックス、Geospatial Indexingでは、二次元の地理空間情報をキーに検索を行うことが出来ます。
経度・緯度以外の情報を入れることも出来ますが、普通は経度・緯度情報を使うと思います。

Geospatial Index の作成

Indexを作るには、普通のIndexと同じで、コレクションのensureIndexメソッドを使うのですが、Geospatial Index の場合は、2dという文字列を指定します。
ただし、コレクション毎に、一つのGeospatial Indexしか作れません。

db.shopinfo.ensureIndex({
  // loc フィールドに Geospatial Index を張る
  loc : "2d"
});

もちろん、複合Indexとして普通のIndexと同時に指定する事も出来ます。

db.shopinfo.ensureIndex({
  loc : "2d", // Geospatial Index を張る
  category: 1 // 他のフィールドに Indexを張る
});

ちなみに、デフォルトでは、±180のfloat値を受け付けますが、拡張も可能です。

db.shopinfo.ensureIndex({
  loc : "2d", // Geospatial Index を張る
}, { // オプションで拡張もできる
  min: -1000,
  max: 1000
});
保存
var lon = 139;
var lat = 35;

db.shopinfo.save({
  name: '喫茶もんご',
  category: ['喫茶店'],
  loc: [lon, lat] // 位置情報 (x, y の順がオススメ)
  // loc: [x: lon, y: lat] // 連想配列でも
  // loc: [lon: lon, lat: lat] // lon,latでも
  // loc: [foo: lon, bar:lat] // 適当な名前でもOK
});
クエリー:完全一致
// ほぼ使い道なし。。。
db.shopinfo.find({
  loc: [139, 35]
});
クエリー:$near
db.shopinfo.find({
  loc : {
    $near : [139.4, 35.4] // [139.4, 35.4]から近い順にソート
  }
});
クエリー:$maxDistance
db.shopinfo.find({
  loc : {
    $near : [139.4, 35.4],
    $maxDistance : 2 // 最大距離が ±2 のものを検索
  }
});
クエリー:複合条件
db.shopinfo.find({
  loc : {
    $near : [139.4, 35.4],
    $maxDistance : 2
  },
  category: '喫茶店'
});
クエリー:geoNearコマンド

geoNearコマンドを使うと、詳細情報を取得することが出来ます。
runCommandメソッドで、geoNearでコレクション名を指定します。
指定出来るオプションには、"near", "maxDistance", "query"なんかがあります。

db.runCommand({
  geoNear: "shopinfo",
  near: [139.4, 35.4],
  maxDistance: 2,
  query: {category: '喫茶店'} // 普通のクエリも使える
});

上記を実行すると下記のように返ってきます

{
  "ns" : "test.shopinfo",
  // ↓基点に指定した情報をGeoHash化したビット列
  "near" : "1110100101001011000011000101000010111011111111011110",
  "results" : [{
    "dis" : 0.5656861498877501, // 距離(単位:度)
    "obj" : {
      "_id" : ObjectId("4d01f5616919cb54afce6a77"),
      "name" : "喫茶もんご",
      "category" : ["喫茶店"],
      "loc" : [139, 35]
    }
  }],
  "stats" : {
    "time" : 0,
    "btreelocs" : 0,
    "nscanned" : 1,
    "objectsLoaded" : 1,
    "avgDistance" : 0.5656861498877501,
    "maxDistance" : 0.5656861498877501
  },
  "ok" : 1
}

地理空間インデックスでは、内部でGeoHashを併用することで、高速性を高めています。

領域のクエリー:$within

長方形のモデルの場合、右上と左下(もちろん、右下と左上とかでもいいのだけど…)の点情報を配列で指定します。

db.shopinfo.find({
  loc : {
    $within : {
      $box : [[130, 30], [140, 35]] // (130, 30) - (140, 35)の長方形内
    }
  }
});

円形のモデルの場合、中心点と半径情報を配列で指定します。

db.shopinfo.find({
  loc : {
    $within : {
      $center : [[135, 35], 4] // (135, 35)から半径4の円の範囲
    }
  }
});
地球は球体:Sphere クエリ

(本当は楕円だ、とか、そういうツッコミはとりあえず置いておきます!)

経度1度の距離は赤道から離れるほど、小さくなるので、精度が落ちてしまう、
というか、ソートの順番が正しくない場合があり、使い物になりません。

Ver.1.7.0以降でサポートされたSphereクエリ使いましょう。

使い方は、$near の代わりに $nearSphere

db.shopinfo.find({
  loc : {
    $nearSphere : [139.4, 35.4]
  }
});

$center の代わりに $centerSphere

db.shopinfo.find({
  loc : {
    $within : {
      // (135, 35)から半径 0.1ラジアンの円の範囲
      $centerSphere : [[135, 35], 0.1]
    }
  }
});

を使うだけ(簡単!)

ちなみに、$boxSphereというのは、仕組み上意味をなさないので無いよ!

Sphere系の距離単位:ラジアン

Sphere系では、距離の単位がラジアンになります。

地球では1ラジアンが6371kmになるので、これを基に計算を行えば問題ないと思います。

1ラジアン = (約) 6371 km

runCommandでの球体モデル

runCommandで、球体モデルを使用したい場合は、spherical パラメータを true にします。

db.runCommand({
  geoNear: "shopinfo",
  near: [135, 35],
  query: {category: '喫茶店'},
  spherical: true // 球体モデルを有効に
});

結果はこんな感じ(抜粋)

"results" : [{
  "dis" : 0.05718377899700883, // ラジアン単位で返ります
  "obj" : {
    "loc" : [139, 35]
  }
}],

検索基点から検索結果までの距離がdisというプロパティで返ってくる(ラジアン単位)ので、上記の場合、0.057…ラジアン×6371kmで、大体364 km になるよ。

というわけで、一緒にMongoDBで遊び(?)ましょう!