開発者が知っておくべき、ドキュメント・データベースの基礎:特集:MongoDBで理解する「ドキュメント・データベース」の世界(前編)(2/3 ページ)
これまでドキュメント・データベースを触ったことがない開発者に向け、最もポピュラーな「MongoDB」を例にその本質を解説。
インデックスとリッチな検索処理
さて、ここまでは基本的な使い方を見てきたが、ここからは、ドキュメント・データベースらしい利点に着目して特徴を見ていこう。
ドキュメント・データベースがKey-Valueストア(KVS)のNoSQLであることは前述したが、ほとんどのドキュメント・データベースでは、このKey(上記の「_id」)以外のインデックス(index)を扱うことが可能であり、「アドホック検索に対応できる」という柔軟性も特徴の1つだ。特にMongoDBの場合は、その概念がRDBに慣れたエンジニアにとってなじみやすいものとなっている。ここでは、その辺りの特徴を見てみよう。
●インデックスを使用した検索
まず、RDBを扱ったことがあるエンジニアにとっては当たり前のことだが、インデックス(index)作成の概念と、インデックスを使用した検索について見てみよう。
前述のOrdersコレクションにインデックスの定義/作成を行うには、コマンドライン・ユーティリティ「mongo」で下記のように記述する。この例では、Nameフィールドにインデックスを作成している(下記の「{Name:1}」の「1」は「昇順」を意味している)。
> db.Orders.ensureIndex({Name:1});
OrdersコレクションのNameフィールドにインデックスを作成
このコマンドで既存のコレクションのオブジェクト全てに対してインデックスが作成され、以降、データが更新されるたびにインデックスも更新される。
上記の設定を行った場合、下記のようなNameフィールドを使った検索ではインデックスが使用される。
> db.Orders.find({Name:"test1"}); // SQLの「where Name=="test1"」に相当
Nameフィールドを使った検索
これに対し、Priceフィールドを使って検索すると、登録されているオブジェクト全ての確認が行われ、データ量が増加した場合に両者でパフォーマンスに大きな差が生じる結果となる。
●インデックスを使用したカウントやソート
もちろん、カウント(=countメソッド)やソート(=sortメソッド)においても、インデックスを使って高速に処理できる。
> db.Orders.find().sort({Name:1}); // 昇順でソート
インデックスが使用されるsortメソッド(昇順)のコード例
「1」は「昇順」を意味している。
なお、MongoDBのインデックスはB-Treeインデックスであり、逆向きのスキャンでも同様にインデックスをたどって高速化される。例えば下記のコードは、上記のコードとは逆の降順でソートする例だが、この場合も同じインデックスが使用される。
> db.Orders.find().sort({Name:-1}); // 降順でソート
インデックスが使用されるsortメソッド(降順)のコード例
「-1」は「降順」を意味している。
●複合インデックスにする場合の注意点
それでは下記のコード例のように、Nameフィールドを昇順にして、かつPriceフィールドを降順にする複合インデックスを作成した場合、どのような条件のソートでインデックスが使われるかを考えてみよう。
> db.Orders.ensureIndex({Name:1, Price:-1});
2つのフィールドに対して複合インデックスを作成する場合のコード例
この場合、前述の理由から、「{Name:1, Price:-1}」と、その逆の関係にある「{Name:-1, Price:1}」のソートではインデックスが使用されるが、「{Name:1, Price:1}」や「{Name:-1, Price:-1}」のソートではインデックスは使用できない。
このように、ドキュメント・データベースはパフォーマンスやスケーラビリティに配慮されたデータベースであるが、MongoDBの場合、いわゆる「遅い検索」もできてしまうので注意してほしい。インデックス以外にも、必要なフィールドに絞り込んだり、あらかじめデータを絞ってから処理するなど、RDBのようなSQLチューニングのポイントがいくつか存在するのだ。
●インデックス使用の有無を確認する方法
インデックスが使われているか否かを確認する際、実際にデータを登録してパフォーマンスを見てもよいが、explainメソッドを使えば簡単かつ確実にクエリの実行計画を確認できる。
例えば下記のコードは、explainメソッドの呼び出し例と出力結果を示している。これを見ると、Nameフィールドのインデックスが使用され(indexBoundsフィールドを参照)、全体で1件のオブジェクトのみがスキャンされたこと(nscannedObjectsフィールドを参照)が分かる。
> db.Orders.find({Name:"test1"}).explain();
{
"cursor" : "BtreeCursor Name_1",
"isMultiKey" : false,
"n" : 1,
"nscannedObjects" : 1,
"nscanned" : 1,
"nscannedObjectsAllPlans" : 1,
"nscannedAllPlans" : 1,
"scanAndOrder" : false,
"indexOnly" : false,
"nYields" : 0,
"nChunkSkips" : 0,
"millis" : 0,
"indexBounds" : {
"Name" : [
[
"test1",
"test1"
]
]
},
"server" : "machine01:27017"
}
クエリの実行計画を確認できるexplainメソッドの呼び出し例と出力結果
なお、プログラミング言語などからアクセスしている場合には、プロファイル(Profile)を取得することで(具体的には「db.setProfilingLevel(2);」というコマンドでプロファイルを有効化したうえで、「db.system.profile.find();」コマンドを呼び出して取得することで)、実行されたクエリとスキャンされた件数などを容易に確認できる。
さらに、RDBのように、クエリ実行の際(に使用するインデックス)のヒント(=hintメソッド)を付与することも可能だ。
このようにMongoDBは、RDBを知っているエンジニアにとっては、かなり導入しやすいデータベースである。
集約(Aggregation)とMapReduce
RDBでは、「結合(Join)」や「集約(Aggregation。SQLで言えば例えばGROUP BY句など)」などの複雑な操作に対し、その最適化(例えば、インデックスの使用など)はブラックボックス化(つまり、データベースにより自動化)されている場合が多い。
これに対し、MongoDBなどのドキュメント・データベースの場合は、こうしたパフォーマンスに影響を与えるような複雑な操作を行う際には、より直接的な記述を行うようになっている。これは、一見、不便に思われるかもしれないが、むしろ「スケーラブルで高速な処理を分かりやすく実現するために配慮された設計」と言ってよいだろう。
●基本的なMapReduce処理
MongoDBでは、集約(Aggregation)を実現する手段の1つとして、MapReduce(=膨大なデータを分散並列処理する技術)を使用できる(なお、MongoDBにはgroupメソッドがあり、これを使って同様の処理を実現できるが、後述するシャーディング(Sharding)環境には対応していないため、ここでは説明を省略する)。
例えば、前述のOrdersコレクションでCategoryフィールドごとのアイテム数(Countフィールド)と金額の合計(Amountフィールド)を算出する処理を、MapReduceを使用して実装すると、以下のようなコードになる。
> function mapf() {
emit(this.Category,
{Category:this.Category, Count:1, Amount:this.Price});
};
> function reducef(key, values) {
var result = {Category:key, Count:0, Amount:0};
values.forEach(function(v) {
result.Count += v.Count;
result.Amount += v.Amount;
});
return result;
}
> db.Orders.mapReduce(mapf, reducef, {out: {replace:"testcol"}});
Categoryフィールドごとのアイテム数と金額の合計を算出するMapReduceのコード例
このコードの処理内容について解説しよう。
まず、Map関数(=コレクション内の各オブジェクトを扱いやすい形式の値に細分化していく処理)として「mapf関数」、Reduce関数(=細分化したデータを少数の結果値に減らしていく処理)として「reducef関数」を準備している。それらの関数を引数に指定してOrdersコレクションのmapReduce関数を呼び出すことで、これらの関数による一連の処理を結合している。
上記のmapf関数で呼び出しているemit関数はMongoDB独自の関数で、入力データ(今回の場合、Ordersコレクションの各オブジェクト)から、新しい形式の出力結果へマップしている。例えば、
Key:_id
Value:{Name:"notebook", Price:200, Category:"material"}
Map関数内のemit関数でマップする前の、1つの入力データの例
という入力データは、このMap関数(mapf)によって、
Key:"material"
Value:{Category:"material", Count:1, Amount:200}
先ほどの1つの入力データをMap関数内のemit関数により処理した結果(Key-Value値)
という出力結果にマップされる。
一方、Reduce関数(reducef)は、先ほどのMap関数の結果(上記のKey-Value値)を入力として、各結果に対して一定の処理を行い、最終的な結果のValue(値)を返す。
mapReduceメソッドの3番目の引数である「{out: {replace:"testcol"}}」は、「出力結果(out)を『testcol』という名前のコレクションに挿入すること」を意味している(testcolコレクションに既存のデータがある場合、全て入れ替えられるので注意してほしい)。
例えば、以下の5件のオブジェクトがOrdersコレクションに登録されていた場合、出力結果(=testcolコレクションの内容)は下記のとおりになる。
> db.Orders.save({Name:"notebook", Price:200, Category:"material"});
> db.Orders.save({Name:"pencil", Price:80, Category:"material"});
> db.Orders.save({Name:"salad", Price:220, Category:"food"});
> db.Orders.save({Name:"others", Price:20, Category:"material"});
> db.Orders.save({Name:"bread", Price:100, Category:"food"});
> db.Orders.mapReduce(mapf, reducef, {out: {replace:"testcol"}});
> db.testcol.find();
{
"_id" : "food",
"value" : { "Category" : "food", "Count" : 2, "Amount" : 320 }
}
{
"_id" : "material",
"value" : { "Category" : "material", "Count" : 3, "Amount" : 300 }
}
5件のオブジェクトが登録されたOrdersコレクションに対して前述のMapReduceを実施した場合の処理結果
●MapReduceの分散処理とIncremental MapReduce
MapReduceでは、Map関数とReduce関数の双方が分散処理できる点がポイントだ。
Map関数は、個別の異なるオブジェクトに対する単一の処理であるため、並行に処理できることは明白だが、Reduce関数についても、入力データを任意のグループに分散して計算し、計算結果に対し、再度、同じReduceの処理を分散して実行できる(後は、これを再帰的に繰り返して求める答えに到達するまで計算する)。このため、上記のReduce関数(reducef)では、入力のvaluesと出力のresultが同じ型になっていることが分かる。
例えば、後述するシャーディング(Sharding)によってデータが複数のサーバに分散配置されている場合は、それぞれのサーバ(mongodプロセス)ごとに、このMapReduce処理を並列に実行することができる。
また、データが追加された際には、増分データのみ評価し直すことが可能だ(これは、「Incremental MapReduce」と呼ばれている)。例えば、下記のコード例では、「2012-10-01 09:00:00」の更新日時(=Updatedフィールド)を設定して新規のオブジェクトを1件追加し、このUpdatedフィールドが「2012-10-01 00:00:00」以降のデータのみ、Incremental MapReduceにより処理し直している(下記の「{Updated: {$gt: ISODate("2012-10-01 00:00:00")}」は、「Updatedフィールの値 > ISODate("2012-10-01 00:00:00")」を意味している)。
> db.Orders.save({Name:"butter", Price:210, Category:"food", Updated:ISODate("2012-10-01 09:00:00")});
> db.Orders.mapReduce(
mapf,
reducef,
{query: {Updated: {$gt: ISODate("2012-10-01 00:00:00")}}, out: {reduce:"testcol"}});
> db.testcol.find();
{
"_id" : "food",
"value" : { "Category" : "food", "Count" : 3, "Amount" : 530}
}
{
"_id" : "material",
"value" : { "Category" : "material", "Count" : 3, "Amount" : 300 }
}
Incremental MapReduceの例
●MapReduceに関する、そのほかのポイント
MongoDBにおけるMapReduceでは、平均値を求める際など、最後に処理が必要な場合は、Map関数、Reduce関数以外に、Finalize関数も使用できる。
以上、MongoDBにおけるMapReduceについて簡単に解説したが、ドキュメント・データベースの多くは、高パフォーマンスな操作を実現する手段として、このMapReduceをサポートしている。ただし、データベース(MongoDB、CouchDB、RavenDB)によって、MapReduceの使い方や概念が異なっているので注意してほしい(例えばCouchDBでは、クエリの代わりにビューを使用する。後述するRavenDBの場合は、インデックスの作成で使用する)。
なお、上記のコード(シンタックス)を見てお分かりのとおり、MongoDBのMapReduce操作はJavaScriptのエンジン(SpiderMonkey)を使用しており、単一のmongodのインスタンス(=mongodプロセス)では、シングル・スレッドによるノンブロッキングIOにより並行性を維持するように動作する(一方、group関数は、実行中、全てのJavaScriptスレッドがブロックされてしまう)。このため、例えばスレッド分割による並列性の恩恵を受けたい場合には、シャーディングなどを活用することになるだろう。
MongoDBのバージョン2.2以降では、「Aggregation Framework」と呼ばれる新しい計算エンジンが実装されていて、この機能を使うと、処理のパイプライン(Pipeline)によって直感的に集約操作(Aggregation)を実装できることに加え、このスレッドの問題も解消されている(前述のとおり、MapReduceの場合には、処理を「Map」「Reduce」「Finalize」の各概念に分けて設計し直す必要がある)。
続いて次のページでは、ここまでの記事中に何度か登場している「シャーディング」について説明する。
Copyright© Digital Advantage Corp. All Rights Reserved.