MongoDB aggregation の検証
だいぶ空いてしまったが、久々の更新!
Aggregate周りを色々検証したので載せておく。
基本的なTAG構造。
TAGを扱う上でオーソドックスなクエリーと性能を調査。
性能は、people 2000万件、hobies 3200万件、完全ランダムデータで計測。
月数千円で手に入るコンピューティングリソースを使ってます。
Model
Mike
:性別:男
:趣味: 映画、テレビ
Cate
:性別: 女
:趣味: テニス
Bob
:性別: 男
:趣味: 映画、テレビ、テニス
Data
RDBMSではこんな感じの正規化をする。
people
_id | name | sex |
---|---|---|
1 | Mike | male |
2 | Cate | female |
hobbies
pid | name |
---|---|
1 | TV |
1 | cinema |
2 | tenis |
3 | TV |
3 | tenis |
BSON
一方MongoDBではこんな感じで持つだろう
{ _id: 1, "name": "Mike", "sex": "male", "hobbies": [ "cinema", "TV" ] } { _id: 2, "name": "Cate", "sex": "female", "hobbies": [ "tenis" ] } { _id: 3, "name": "Bob", "sex": "male", "hobbies": [ "TV", "tenis", "cinema" ] }
問題1
趣味TVの男性の数を抽出
得たい結果
count(people._id) | |
---|---|
2 |
sql
説明不要・・・
SELECT COUNT(people._id) FROM people INNER JOIN hobbies ON people._id = hobbies.pid WHERE people.sex = ‘male' AND hobbies.name = 'TV';
mongodb
なんの変哲も無いクエリーだが・・・
db.people.find( { sex: ‘male’, hobbies: ‘TV’ } ).count();
性能
Mongo爆速!!
countを舐める処理が賢いのが要因。
mysql5.6 | MongoDB 2.6.3 |
---|---|
94sec | 1sec |
問題2
趣味毎の男性人数を集計
得たい結果
hobbies.name | count(people._id) |
---|---|
tenis | 1 |
cinema | 2 |
TV | 2 |
sql
普通のgroup by
SELECT hobbies.name, COUNT(people._id) FROM people INNER JOIN hobbies ON people._id = hobbies.pid WHERE people.sex = 'male' GROUP BY hobbies.name ORDER BY COUNT(people._id);
mongodb
hobbiesを一旦$unwindでバラす。
ちょっと独特なので下で解説
db.people.aggregate([ { $match: { sex: 'male' }}, { $project: { hobbies: 1, count:{ $literal: 1 }}}, { $unwind: "$hobbies" }, { $group: {_id: "$hobbies", count: { $sum: "$count" }}}, { $sort: { count: 1 } }, ]).
解説:パイプラインの各段階でのデータ
FIRST
{ _id: 1, "name": "Mike", "sex": "male", "hobbies": [ "cinema", "TV" ] } { _id: 2, "name": "Cate", "sex": "female","hobbies": [ "tenis" ] } { _id: 3, "name": "Bob", "sex": "male", "hobbies": [ "TV", "tenis", "cinema" ] }
$match: { sex: 'male' }
最初に絞る。
{ _id: 1, "name": "Mike", "sex": "male", "hobbies": [ "cinema", "TV" ] } { _id: 3, "name": "Bob", "sex": "male", "hobbies": [ "TV", "tenis", "cinema" ] }
$project: { hobbies: 1, count:{ $literal: 1 }}
フィールドも絞る。$literalは2.6系から。SQLとは逆の発想。
{ "hobbies" : [ "cinema", "TV" ], "count" : 1 } { "hobbies" : [ "TV", "tenis", "cinema" ], "count" : 1 }
$unwind: "$hobbies"
$unwindで配列を展開してフラットなドキュメントリストにする
{ "hobbies" : "cinema", "count" : 1 } { "hobbies" : "TV", "count" : 1 } { "hobbies" : "TV", "count" : 1 } { "hobbies" : "tenis", "count" : 1 } { "hobbies" : "cinema", "count" : 1 }
$group: {_id: "$hobbies", count: { $sum: "$count" }}
あとは普通に$groupする
{ "_id" : "cinema", "count" : 2 } { "_id" : "TV", "count" : 2 } { "_id" : "tenis", "count" : 1 }
性能
やはりMongo(Aggregate)速い!
一見非効率なpipelineだが速度は早い。
mysql5.6 | MongoDB 2.6.3 |
---|---|
145sec | 30sec |
問題3
男性が同時に嗜んでいる趣味(趣味の関係性)
得たい結果
h1.name | h2.name | count |
---|---|---|
TV | cinema | 2 |
TV | tenis | 1 |
cinema | TV | 2 |
cinema | tenis | 1 |
tenis | TV | 1 |
tenis | cinema | 1 |
sql
Self Join が常套手段
SELECT h1.name, h2.name, COUNT(people._id) FROM people INNER JOIN hobbies h1 ON people._id = h1.pid INNER JOIN hobbies h2 ON people._id = h2.pid WHERE people.sex = 'male' AND h1.name != h2.name GROUP BY h1.name, h2.name ORDER BY COUNT(people._id) DESC;
mongo
hobbies配列を2個持たせて2重に$unwindする。
db.people.aggregate([ { $match: { sex: 'male' }}, { $project: { h1: "$hobbies", h2: "$hobbies", count:{ $literal: 1 }}}, { $unwind: "$h1" }, { $unwind: "$h2" }, { $redact: { $cond: { if: { $eq: ["$h1", "$h2"]}, then: "$$PRUNE", else: "$$KEEP"} } }, { $group: { _id: { h1: "$h1", h2: "$h2"}, count: { $sum: "$count" }}}, { $sort: { count: -1 } }, ])
性能
Mongo(Aggregate)早すぎる!
$unwindを2重にして一時的にドキュメント数が配列数の2乗倍の数になっているのだが性能にはあまり影響しないようだ。
パイプラインの段数も多くなってるが同じく性能にあまり影響しない。
カジュアルに繋げて大丈夫!!
mysql5.6 | MongoDB 2.6.3 |
---|---|
195sec | 36sec |
実際、業務レベルでも4回$unwindする様な事をしても、充分実用に耐えているな。
問題4
男性の趣味数の分布
得たい結果
hobby_count | count(pid) |
---|---|
2 | 1 |
3 | 1 |
sql
サブクエリーの結果を更にgroup by。辛い感じのSQL
SELECT hobby_count, COUNT(pid) FROM ( SELECT people._id AS pid, COUNT(name) AS hobby_count FROM people INNER JOIN hobbies ON people._id = hobbies.pid WHERE people.sex = 'male' GROUP BY hobbies.pid ) hobby_count_per_person GROUP BY hobby_count ORDER BY COUNT(pid);
mongo
パイプラインだと素直。
db.people.aggregate([ { $match: { sex: 'male' }}, { $project: { count: {$literal: 1}, numhobbies: { $size: "$hobbies" } } }, { $group: { _id: "$numhobbies", count: { $sum: "$count" } } }, { $sort: { count: 1 } }, ])
性能
やはりMongoが早いが、$unwindでひたすらコンテキストを増やすタイプのクエリーより遅い。
これは全然わからないな。
mysql5.6 | MongoDB 2.6.3 |
---|---|
186sec | 53sec |
総括
集計に関しては明らかにMongoDBのAggregationFWが優秀だ。
MongoDB
正規化する代わりにembedするので更新が有った時に全更新が辛い。(不可能な時もある)
この辺に目を瞑れるならMongoDBは充分有用だ。
特にAggregationFW はまさに集計用途に設計されており非常に早い。
またパイプライン処理はプログラマに近いかな。(イメージはシェルスクリプトと一緒)
しかしAggregateFWのPIPELINEの処理は中が全く想像できず
何をやれば性能がどうなるのか?想像しにくい面が有る。
AggregationFW特徴など色々
- 複雑なPipelineを多段組んでも大丈夫そう。
- Sharding環境でも効率的に動く。
- $matchにshard keyが含まれていれば
- 無関係なShardには処理を投げない。
- バラバラに処理できる部分(map)は
- 各Shardで処理を進め、集計が必要な部分(reduce)はPrimary shardが行う。
Proposal about notablescan option
I issue this ticket from strong sense of impending crisis and wanted to know how others thinks about this.
https://jira.mongodb.org/browse/SERVER-15561
This proposal is about the feature of the notablescan option.
This is not for the programers but for the mongo-operators who operates more than hundreds GB of collections regularly.
WISH
- I want to use notablescan on the production DB.
- I want to apply notablescan to per DB or per COLLECTION.
REASON
We can kill our mongod easily by sending query with no indexed field to the more than hundreds GB of collection.
To make matters worse, we'll get same results by specifying non-existent field cause by simple typo.
The feature of notablescan option can prevent these catastrophic incidents.
Especially, on the production DB.
ADDITIONAL
But currently, likely to add this sentence to mongo-docs.
+ Don't run production :program:`mongod` instances with + :parameter:`notablescan` because preventing table scans can potentially + affect queries in all databases, including administrative queries.
I think, this is the wrong policy to keep our mongo system safety.
On the contrary, I want to come to be that the notablescan option is applicable per DB or per COLLECTION.
Please vote this ticket (around right-top) if you agree with me.
https://jira.mongodb.org/browse/SERVER-15561
Mongoクエリー・ベース・レプリケーション
レプリカセット間レプリケーション
MongoDBではレプリカセットを跨いでデータを同期する手段が無い。
そもそもレプリカセット自体が冗長構成を目的としているので設計に組み込まれていないのだろう。
しかし現実は Staging環境や、PV系/集計系の分離など、用途はある。
今は、レプリカセットのslaveを1台切り離してそこで何かするしかない。
フレッシュデータじゃないし運用も面倒。
monmo-repl
https://github.com/monmo/monmo-repl
なければ作れば良いので作った。
oplogベースの同期を行う。-s で指定したレプリカセットのPRIMARYのoplogを読み、-d で指定したレプリカセットに反映していく。
PRIMARYダウン時の挙動など、まだ未テストの部分は多いが
『とりあえず動く』
程度の完成度。
例
ReplicaSet1 =sync=> ReplicaSet2 [ 127.0.0.1 ] [ 127.0.0.1 ] - 27017 - 28017 - 27018 - 28018 - 27019 - 28019 ./bin/monmo-repl.sh -s 127.0.0.1:27017,127.0.0.1:27018,127.0.0.1:27019 -d 127.0.0.1:28017,127.0.0.1:28018,127.0.0.1:28019 -i
仕様
- 例によって bash , mongo シェルしか要らない
- 同期元のレプリカセットのmasterのoplogを読む(将来、選択可能になる可能性あり)
- インデックスの同期はオプショナル(解析用には専用のindex持ちたいだろうし)
- 起動時に完全同期オプション(-f) は遅い。。。
- 同期対象DB を指定できる(デフォルト全て)
Index intersection を試してみた。(失敗談)
MongoDB 2.6 からIndex intersectionという機能が追加された。
1つのクエリーで2つのインデックスを使う(かもしれない)機能で、より効率的にクエリーを処理できる。
(どう効率的なのか?はこのへんが詳しい)
さて、じゃあ実際に見てみようというのが今回の趣旨。
元データ
国土地理院が公開している住所情報が手元にあったのでこれを使うことにした。
大体1000万件/4GB弱のデータ。
> db.block_master.stats().count 11700898 > db.block_master.stats().size 3713899792 > db.block_master.findOne() { "_id" : ObjectId("52b8378dab3de2fd4abb193f"), "full" : "北海道 札幌市中央区 南七条西十一丁目 1281", "pref" : ObjectId("52b8378dab3de2fd4abb193c"), "pref_name" : "北海道", "city" : ObjectId("52b8378dab3de2fd4abb193d"), "city_name" : "札幌市中央区", "town" : ObjectId("52b8378dab3de2fd4abb193e"), "town_name" : "南七条西十一丁目", "name" : "1281", "loc" : { "type" : "Point", "coordinates" : [ 141.342094, 43.050264 ] } }
Indexを張る
db.block_master.ensureIndex({pref_name:1}); db.block_master.ensureIndex({city_name:1}); db.block_master.ensureIndex({town_name:1});
今までは、複合インデックスを張っておかないと大変だったのだがさてどうなるか。。。
ちょっとデータを確認
Index intersection はそれぞれのインデックスで絞り込んだ結果の共通部分を抜き出すので、投げるクエリーを検討する。
- 幾つかの県にある同名の市区町村
- 幾つかの市区町村にある同名の町丁
が狙い目だ。
> db.block_master.aggregate([ { $group: {_id: {city: "$city_name", pref: "$pref_name"}}}, { $group: {_id: "$_id.city", num: {$sum: 1}, prefs: {$push: "$_id.pref"}}}, { $match: {num: {$gt:1}}}, { $sort: {num: -1}} ]) { "_id" : "伊達市", "num" : 2, "prefs" : [ "福島県", "北海道" ] } { "_id" : "府中市", "num" : 2, "prefs" : [ "東京都", "広島県" ] }
へー意外と少ない!
> db.block_master.aggregate([ { $group: {_id: {town: "$town_name", city: "$city_name"}}}, { $group: {_id: "$_id.town", num: {$sum: 1}}}, { $match: {num: {$gt:1}}}, { $sort: {num: -1}} ]) { "_id" : "本町", "num" : 144 } { "_id" : "本町二丁目", "num" : 141 } { "_id" : "本町一丁目", "num" : 140 } { "_id" : "栄町", "num" : 133 } { "_id" : "本町三丁目", "num" : 114 } { "_id" : "新町", "num" : 106 } { "_id" : "幸町", "num" : 97 } { "_id" : "中央二丁目", "num" : 93 } { "_id" : "中央一丁目", "num" : 92 } { "_id" : "本町四丁目", "num" : 84 } { "_id" : "東町", "num" : 84 } { "_id" : "末広町", "num" : 79 } { "_id" : "旭町", "num" : 77 } { "_id" : "栄町二丁目", "num" : 75 } { "_id" : "中央三丁目", "num" : 74 } { "_id" : "栄町一丁目", "num" : 73 } { "_id" : "緑町", "num" : 72 } { "_id" : "南町", "num" : 69 } { "_id" : "泉町", "num" : 65 } { "_id" : "中町", "num" : 63 } Type "it" for more >
おお!流石の『本町』!
意外な『栄町』!?
『新町』だと思ったのになぁ。。。
じゃなかった。。まあ兎に角この辺が狙い目。。
あと余談だけどaggregationがcursorで帰って来るようになったのも2.4から。
それまでは単に配列で帰ってくるわ、64MB超えるとコケるわ。。ダメダメだった。
このaggregationも13970程帰って来たから、市区町村名までArrayに含めたらコケたでしょうね。
さてintersectionに戻ろう。。
チャレンジ1
クエリー
> db.block_master.find({city_name:'府中市', pref_name:'東京都'}).count() 5219
explain(true)
> var explain = db.block_master.find({city_name:'府中市', pref_name:'東京都'}).explain(true) > explain.indexBounds { "city_name" : [ [ "府中市", "府中市" ] ] } > explain.allPlans [ { "cursor" : "BtreeCursor city_name_1", "isMultiKey" : false, "n" : 5219, "nscannedObjects" : 13272, "nscanned" : 13272, "scanAndOrder" : false, "indexOnly" : false, "nChunkSkips" : 0, "indexBounds" : { "city_name" : [ [ "府中市", "府中市" ] ] } }, { "cursor" : "BtreeCursor pref_name_1", "isMultiKey" : false, "n" : 0, "nscannedObjects" : 101, "nscanned" : 102, "scanAndOrder" : false, "indexOnly" : false, "nChunkSkips" : 0, "indexBounds" : { "pref_name" : [ [ "東京都", "東京都" ] ] } }, { "cursor" : "Complex Plan", "n" : 0, "nscannedObjects" : 0, "nscanned" : 103, "nChunkSkips" : 0 } ] > explain.stats { "type" : "KEEP_MUTATIONS", "works" : 13273, "yields" : 104, "unyields" : 104, "invalidates" : 0, "advanced" : 5219, "needTime" : 8053, "needFetch" : 0, "isEOF" : 1, "children" : [ { "type" : "FETCH", "works" : 13273, "yields" : 104, "unyields" : 104, "invalidates" : 0, "advanced" : 5219, "needTime" : 8053, "needFetch" : 0, "isEOF" : 1, "alreadyHasObj" : 0, "forcedFetches" : 0, "matchTested" : 5219, "children" : [ { "type" : "IXSCAN", "works" : 13272, "yields" : 104, "unyields" : 104, "invalidates" : 0, "advanced" : 13272, "needTime" : 0, "needFetch" : 0, "isEOF" : 1, "keyPattern" : "{ city_name: 1.0 }", "boundsVerbose" : "field #0['city_name']: [\"府中市\", \"府中市\"]", "isMultiKey" : 0, "yieldMovedCursor" : 0, "dupsTested" : 0, "dupsDropped" : 0, "seenInvalidated" : 0, "matchTested" : 0, "keysExamined" : 13272, "children" : [ ] } ] } ] }
あれ!?
効かない。。
13272件舐めて5219件抽出した。
IXSCANで13272件舐めてFETCHで13273件って事は普通にcity_nameだけ使ったな。
city_name: '東京都'が22万件ほどあって全然絞れないから、
普通に処理することを選択したっぽい。
チャレンジ2
クエリー
期待の『本町』だ。
クエリー
> db.block_master.find({city_name: '松戸市', town_name: '本町'}).count() 25
データ数
> db.block_master.find({city_name: '松戸市'}).count() 30661 > db.block_master.find({town_name: '本町'}).count() 6563
良い感じのバランスである。
explain(true)
> db.block_master.find({city_name: '松戸市', town_name: '本町'}).explain(true).indexBounds { "town_name" : [ [ "本町", "本町" ] ] } > db.block_master.find({city_name: '松戸市', town_name: '本町'}).explain(true).allPlans [ { "cursor" : "BtreeCursor town_name_1", "isMultiKey" : false, "n" : 25, "nscannedObjects" : 6563, "nscanned" : 6563, "scanAndOrder" : false, "indexOnly" : false, "nChunkSkips" : 0, "indexBounds" : { "town_name" : [ [ "本町", "本町" ] ] } }, { "cursor" : "BtreeCursor city_name_1", "isMultiKey" : false, "n" : 15, "nscannedObjects" : 6564, "nscanned" : 6565, "scanAndOrder" : false, "indexOnly" : false, "nChunkSkips" : 0, "indexBounds" : { "city_name" : [ [ "松戸市", "松戸市" ] ] } }, { "cursor" : "Complex Plan", "n" : 15, "nscannedObjects" : 0, "nscanned" : 6566, "nChunkSkips" : 0 } ] > db.block_master.find({city_name: '松戸市', town_name: '本町'}).explain(true).stats { "type" : "KEEP_MUTATIONS", "works" : 6565, "yields" : 153, "unyields" : 153, "invalidates" : 0, "advanced" : 25, "needTime" : 6538, "needFetch" : 0, "isEOF" : 1, "children" : [ { "type" : "FETCH", "works" : 6564, "yields" : 153, "unyields" : 153, "invalidates" : 0, "advanced" : 25, "needTime" : 6538, "needFetch" : 0, "isEOF" : 1, "alreadyHasObj" : 0, "forcedFetches" : 0, "matchTested" : 25, "children" : [ { "type" : "IXSCAN", "works" : 6563, "yields" : 153, "unyields" : 153, "invalidates" : 0, "advanced" : 6563, "needTime" : 0, "needFetch" : 0, "isEOF" : 1, "keyPattern" : "{ town_name: 1.0 }", "boundsVerbose" : "field #0['town_name']: [\"本町\", \"本町\"]", "isMultiKey" : 0, "yieldMovedCursor" : 0, "dupsTested" : 0, "dupsDropped" : 0, "seenInvalidated" : 0, "matchTested" : 0, "keysExamined" : 6563, "children" : [ ] } ] } ] }
まとめ
Index intersectionの気持ちは解りませんでした。。
、、かといって今は精進(source code reading)してる暇は無いんだよなぁ。。。
MongoDB2.6.1 でやっとメジャーバージョンアップ
チェンジログ
http://docs.mongodb.org/master/release-notes/2.6-changelog/
ひたすらヤバイ者揃いですが、やっとメジャーバージョンアップ程度の品質になったかと。
やっと頑張れば使えるかな?
暇が出来次第
http://mongodb.jp/
を早々に生贄にする所存。。
MongoDB 2.4 => 2.6 アップデートした
2.6.1(人柱バージョン)にチャレンジ
2.4.4 => 2.6.1 バージョンアップ手順
今回データファイルには互換性があるので超簡単
ディレクトリ構成
/usr/local/mongo |- bin -> mongodb-linux-x86_64-2.4.4/bin |- mongodb-linux-x86_64-2.4.4 |- data |- logs |- conf |- mongod.conf
手順
- Download & extract
$ cd /tmp $ wget http://fastdl.mongodb.org/linux/mongodb-linux-x86_64-2.6.1.tgz $ cd /usr/local/mongo $ tar xzvf /tmp/mongodb-linux-x86_64-2.6.1.tgz
- 既存のmongodを落とす
$ kill `cat /usr/local/mongo/logs/mongod.pid`
- Symlink切り替え
$ cd /usr/local/mongo $ ln -sfT mongodb-linux-x86_64-2.6.1/bin bin
- 新しいディレクトリ構成
/usr/local/mongo |- bin -> mongodb-linux-x86_64-2.6.1/bin |- mongodb-linux-x86_64-2.4.4 |- mongodb-linux-x86_64-2.6.1 |- data |- logs |- conf |- mongod.conf
- mongod起動
$ /usr/local/mongo/bin/mongod -f /usr/local/mongo/conf/mongod.conf
終わり
まとめ
も、何も一直線。
迷うところ無し。
相変わらずmongoの運用設計は秀逸!
ちょっと使ってみた所、geoJSON系が体感的に早くなってる気がする。
他の処理の互換性はいまチェック中。
今のところ大丈夫っぽいが、、
MongoDB2.6.0は時期尚早
MongoDBのメジャーバージョンアップはいつもの通り大混乱だ。
最早様式美ですらある。。
いつも思うがmongodb.incの連中はmongodb使って欲しい訳だ。
Eat yourown dog food !!
jira眺めてて、今問題が多そうな部分
- インデクシング(全体的におかしい)
- AggregateFW(今回の目玉だからね)
- mongodump
個人的にはバックアップが取れなくなるmongodumpがクラッシュする問題が一番困る。。
そんな訳でアップグレードは2.6.2 - 2.6.4 辺りまで様子見のつもり。。
=> お前が率先して人柱やれよ!!
ってツッコミを受けそうですが、、最近ちょっとそこまで余裕無いので。。。