AND OR  

このページではドキュメント指向データベースのMongoDBに関するメモを書いています。また、ここに記載するサンプルコードはPythonで書かれており(一部はC++)、MongoDBドライバのPyMongo(バージョン2.0未満だとサポートされていない機能も多いので2.0以上を推奨)を利用しています。
C++ドライバについてはブログでも紹介しています(MongoDB C++クライアント)。


  • 環境
    CentOS 5.8 (x86_64)
    MongoDB 2.0.7
    C++ Driver v2.0-latest
    PyMongo 2.2

概要

スキーマレス

ドキュメント指向データベースはRDBMSと違ってスキーマを定義する必要がない。データはBSON(Binary jSON)と呼ばれるフォーマットで保存される。ドキュメントの集まりはコレクションと呼ばれるが、これはRDBに置き換えるとドキュメントはレコード、コレクションはテーブルと考えれば良い。

  • ドキュメントの例
    _id (ObjectId)フィールドはRDBでいうところの主キーに当たり、クライアントが値を指定しなければ自動的に挿入される。
    ## users コレクション
    {
      "_id" : ObjectId("4f26994d50f6ab7958514e94"),
      "name" : "ryo",
      "age" : 26 
    }
    このusersコレクションは以後、サンプルコード内でも利用していく。

実績

MongoDB公式サイトの Production Deployments を参照。SourceForge, FoursquareなどのIT系だけでなく、DisneyやCERNなどさまざまな業界の企業で採用実績があるらしい。

基本操作における特徴, 注意点など

insert

同期/非同期

インサートはデフォルトではデータベースからの応答を待たない(非同期)。クエリが成功したかどうか応答を確認する(同期)には insert の safe オプションをセットする。これによりエラー発生時に pymongo.errors.OperationFailure 例外が送出されるようになる。

insert だけでなく他の操作も基本的にデフォルトで非同期操作となっている。

ObjectIDの生成場所

"_id" フィールドを指定しない場合は自動的に生成されドキュメントに挿入される。このObjectIDはクライアント側で生成するかサーバ側で生成するかを選ぶことができる。pymongoでは manipulate オプションで指定できる。

ObjectIDをクライアント側で生成した場合は即座にObjectIDの値を取得できる。manipulate オプションをFalseにした場合はサーバ側でObjectIDが生成されるが、insert は非同期なのでドライバはサーバ側で生成したObjectIDを戻り値として返すことができない。

バルクインサート

複数のドキュメントを一括でインサートする機能も備えている。使うには複数のドキュメントを詰めた配列を insert に指定する。

update

upsert

アップデートの条件にヒットするドキュメントが存在せず、upsert オプションをセットしてある場合は新規にドキュメントを挿入する。デフォルトではFalseになっている。

クエリ内の$マーク付きのフィールドについては後述する。

マルチアップデート

アップデートはデフォルトでは最初にヒットしたドキュメント1つのみを更新する。ヒットした全てのドキュメントを更新対象としたい場合は update の multi オプションで指定する。

insert と同様に safe オプションをセットするとデータベースからの応答を待つ。

※ 公式リファレンスによると multi オプションのデフォルト値はいずれTrueになると書かれている。
collection – Collection level operations

アトミックな操作

クエリ内での$マーク付きのフィールドは modifier と呼ばれ、アトミックな操作を行う際に指定する。

  • $inc - 値に対して指定した数値でインクリメント
  • $set - 特定の値をセット、全ての型がサポートされている
  • $unset - フィールドを削除
  • $push - 配列に要素を追加
  • $pushAll - 配列に複数の要素を追加
  • $addToSet - 配列に要素を追加、重複は許可しない
  • $pull - 配列から要素を削除
  • $pullAll - 配列から複数の要素を削除
  • $pop - 配列の末尾/先頭の要素を削除
  • $rename - フィールドの名前を変更

remove

クエリを書くときの注意点

削除したいドキュメントそのものをクエリに指定したい場合があるかもしれないが、これは非効率なのできちんと"_id"などを指定した方が良い。また、削除対象のドキュメントを指定しない場合は全てのドキュメントが削除されるので注意。その場合、インデックスは削除されずに残る点も忘れないようにしたい。

insert, update と同様に safe オプションが指定可能。

find

フィールドの指定

find はデフォルトではドキュメント全体が返されるが一部のフィールドだけ返す指定もできる。ただし、取得したいフィールドを指定しても"_id"フィールドは必ず含まれているので注意。

スナップショット

MongoDBはデータサイズが大きく増えた場合、データをディスク上で移動させる。この瞬間に find すると同じドキュメントが2つ取れたりする可能性がある。これは find で取り出した複数のドキュメントを update するようなケースでは問題になるので、重複を排除して結果を返す snapshot 機能を使う。

データサイズの変化が小さい場合(数値のインクリメント/デクリメント、日時データの更新など)はディスク上でのデータ移動は起こらない。これは insert 時にある程度paddingを付けてディスクに書き込んでいるため。

スキップ/リミット

find クエリではスキップおよびリミット指定を行うことができる。ページング機能を実装するとき等に有用。

インデックス

B-Tree Index、Sparse Index、Unique Index の3つのインデックスがサポートされている。

  • B-Tree Index
    B-Treeによるインデックス、MySQLなどのRDBMSでおなじみ。
  • Sparse Index
    インデックスに設定されたフィールドを持っていないドキュメントは無視される。つまり、コレクション全体でSparse Indexに設定されたフィールドに値を持っているドキュメントが少ない場合に高いパフォーマンスが期待できる。
  • Unique Index
    Unique Indexに設定されたフィールドを持つドキュメントはユニークとなり、同じドキュメントを複数登録しようとすると重複エラーとなる。インデックスを貼ったキーが存在しないドキュメントを登録すると null が挿入される。

インデックスを扱う際の勘所はMySQLと同じで、インデックスのデメリットはコレクションへのデータの書き込み時にインデックス更新の必要もあるためオーバーヘッドが生じるしサイズも大きくなる。なので、書き込みが多いコレクションに対してインデックスは逆効果になることも多い。逆に頻繁に読み込みが行われるコレクションに対しては必ずインデックスを使うようにしたい。

レプリケーション

Master/Slave方式の他に Replica Sets と呼ばれるレプリケーション方式を備えている。

  • Master/Slave
    1つのサーバだけが書き込みに対してアクティブ(master)になる。
  • Replica Sets
    複数台以上のサーバでのフェイルオーバーをサポート。

詳細は調査中。

シャーディング

レンジパーティショニング

MongoDBのデータ分散戦略は他のNoSQLデータベースでよく採用されている Consistent Hashing ではなく、RDBMSではおなじみのレンジパーティションを採っている。MongoDBが範囲検索に強いと言われているのはこの分散戦略のため。

以下のようなドキュメントを持つコレクションがあるとする。シャーディングのキー(Shard Keyと呼ばれる)は age フィールドとし、データ量が十分に大きいと仮定した場合の分散戦略を示す。

// _id フィールドは省略
{"username" : "paul", "age" : 23}
{"username" : "simon", "age" : 17}
{"username" : "widdly", "age" : 16}
{"username" : "scuds", "age" : 95}
{"username" : "grill", "age" : 18}
{"username" : "flavored", "age" : 55}
{"username" : "bertango", "age" : 73}
{"username" : "wooster", "age" : 33}

MongoDBは初期チャンク(-∞, +∞)を、既存のデータ範囲の中間点あたりで2つのチャンクに分割する。例えば age フィールドの値が約半数のドキュメントで 25 より小さく、残りの半数が 25 より大きければMongoDBは 25 を選択する。つまり (-∞, 25), [25, +∞) という2つのチャンクができることになる。[25, +∞) のチャンクにデータを追加し続けたとしたら、そのチャンクはさらに2つに分割される。例えば [25, 36), [36, +∞) のように分かれる。これでコレクションを構成するチャンクは (-∞, 25), [25, 36), [36, +∞) の3つになる。その後もデータを追加し続ければ、MongoDBは既存のチャンクを分割して新たなチャンクを作るという処理を繰り返す。その際、範囲内にデータが1つしかないチャンクができることもありうるが、どのチャンクの範囲も他のチャンクとは決して同じになることはなく、一部が重複するということもない。

オートバランシング

Sharding Administrationによると、データは各シャード間のチャンク差が 8 を超えるまでは primary shard(shard0000) にのみ保持され分散は行われない。実運用ではこのバランシング機能によってチャンク移動が起こることは極力避けたいため(シャード間のデータ転送は高コスト)、最適なチャンクサイズをあらかじめ設定しておくか、あるいはバランシング機能自体をオフにする必要がある。

chunk migration

mongosのオートバランシング機能により、シャード間のchunk migration(シャード間でチャンク移動)が行われる。migration中は移動元、移動先に同じチャンクが存在する時間があるので、ユニークなはずのキーが重複して存在したり、countクエリが正しい値を返さない場合がある(実際は正しい値よりも大きな値が返ってくる)。完全に転送が完了してconfigサーバが更新されると正しい値を返すようになる。

Shard Keyの選定

シャーディングに利用するキー(Shard Key)の選定は慎重に行う必要がある。

  • Foursquareでの障害事例
    以前発生したFoursquareの大規模障害は、Shard KeyにユーザーIDを利用していたことが原因。ある一定のユーザーが他のユーザーよりも活発にサービスを利用している場合、その活発なユーザーのデータ更新が全て同じシャードへ向かい負荷が偏ってしまう。

チャンクサイズの設定

チャンクサイズはmongosの起動時(--chunkSize N)、あるいは設定ファイル内で設定できる。しかしデータ分散機能の確認のために、テストで1に設定する以外のケースでは無闇に値を弄らない方が良い。

MongoDBは最初の10チャンクほどに関しては、自動的にチャンクサイズをデフォルトの200MBから64MBに小さくする。チャンク数が一定数以上に増えればチャンクサイズは自動的に200MBに戻される。

GridFS

MongoDBに巨大なファイルを格納するための仕様、動画ファイルなどの巨大なファイルを扱うのに適している。(MongoDBのBSONオブジェクトは1ドキュメント16MBの制限がある)

mongofiles

MongoDBのパッケージにはGridFSを操作するための mongofiles というコマンドラインツールが付属している。大きめの動画像ファイルを準備してテスト。

MongoDBのログには以下のように出力された。ディスクがプリアロケーション済みの場合、600MB程度のファイルだと20秒ほど時間がかかっている。複数回試してみたが誤差は100ms前後だった。

## test1.wmv 97MB
[conn23777] command video.$cmd command: { filemd5: ObjectId('4f6ea577c5f3400dcd625147'), root: "fs" } ntoreturn:1 reslen:94 372ms

## test2.mp4 158MB
[conn23890] command video.$cmd command: { filemd5: ObjectId('4f6ed4524cb55e30ffb99535'), root: "fs" } ntoreturn:1 reslen:94 898ms

## test3.f4v 599MB
[conn23899] command video.$cmd command: { filemd5: ObjectId('4f6ed179b4d62c9b14827ce2'), root: "fs" } ntoreturn:1 reslen:94 19819ms

コレクションの中身は以下のようになっている。

アプリケーションからの利用

C++およびPythonからGridFSを利用する。

C++ドライバ

C++ドライバのインストール手順については後述。

  • 出力例
    ---------- Meta Data
    ObjectId: 4f85970d877c7e26f1f36edb
    File Name: files/test2.mp4
    Content Length: 165002988
    MD5: 617dd8559fc756d0c932756b96605beb
    Upload Date: Wed Apr 11 23:37:04 2012
    Chunk Size: 262144
    Num Chunks: 630
    ---------- Put File
    { _id: ObjectId('4f859772c816199f63d12b38'), filename: "files/test2.mp4", chunkSize: 262144, uploadDate: new Date(1334155126050), md5: "617dd8559fc756d0c932756b96605beb", length: 165002988 }
    ---------- File List
    { _id: ObjectId('4f6ed179b4d62c9b14827ce2'), filename: "files/test3.f4v", chunkSize: 262144, uploadDate: new Date(1332662714504), md5: "9cdf035bd66f62b3aafb7cf712fdc22a", length: 627365883 }
    { _id: ObjectId('4f85896c0fb072ce90ab4e89'), filename: "files/test1.wmv", chunkSize: 262144, uploadDate: new Date(1334151540196), md5: "126b430551e2486b84312c8a16c3eba7", length: 101616791 }
    { _id: ObjectId('4f859772c816199f63d12b38'), filename: "files/test2.mp4", chunkSize: 262144, uploadDate: new Date(1334155126050), md5: "617dd8559fc756d0c932756b96605beb", length: 165002988 }

Pythonドライバ (PyMongo)

  • 出力例
    [u'files/test1.wmv', u'files/test2.mp4', u'files/test3.f4v']
    files/test2.mp4 exists
    _id: 4f6ed4524cb55e30ffb99535
    chunk_size: 262144
    length: 165002988
    md5: 617dd8559fc756d0c932756b96605beb
    name: files/test2.mp4
    upload_date: 2012-03-25 08:16:33.741000

ファイルはバージョニングされており、get_version で任意のバージョンのファイルを取得できる。また、ファイルを保存する時は任意のメタデータを付与して保存することができる。

チューニング

explain

MySQLと同様に高度なオプティマイザを備えている。クエリの実行計画(explain)も見ることができるのでチューニングにはこれを利用すると良い。

explain は以下の情報を返す。

  • cursor
    カーソルタイプ。BasicCursor/BtreeCursor/GeoSearchCursor 。
  • isMultiKey
    クエリがマルチキーインデックスを使っているか。
  • n
    ヒットしたドキュメント数
  • nscannedObjects
    走査されるドキュメント数。
  • nscanned
    走査されるドキュメント/インデックスエントリー数。インデックスが使われた場合は nscannedObjects より多くなる。
  • nscannedObjectsAllPlans
    全てのクエリで走査されるドキュメント数。
  • nscannedAllPlans
    全てのクエリで走査されるドキュメント/インデックスエントリー数。
  • scanAndOrder
    インデックスを使わずに結果をソートして返すか。
  • indexOnly
    クエリがインデックスフィールドのみを使っているか。
  • nYields
    待っている書き込み処理を実行させるためにリードロックを解放した回数。
  • nChunkSkips
    シャーディング環境においてアクティブなチャンクのマイグレーションのためにスキップされたドキュメント数。
  • millis
    クエリを実行するのにかかった時間(ms)
  • indexBounds
    インデックスを使って走査された要素の値の範囲(範囲検索なら上限と下限)

いろいろ項目はあるけど、n と nscanned をとりあえず見ながらチューニングしていくと良さそうだ。また、_id フィールドには自動でB-Tree Indexが貼られる。このインデックスだけは特別扱いで dropIndex() でも削除することはできない。

その他注意点

MongoDBが使用するディスク容量

データベースファイルは {データベース名}.{0-x} となり、0から順に 64, 128, 256, 512, 1024, 2048MB の大きさで作られる。ファイル生成時は以下のようにログ出力され、アロケーションにはかなり時間がかかっていることがわかる。

[FileAllocator] done allocating datafile /var/lib/mongo/video.3, size: 512MB,  took 28.97 secs

[FileAllocator] done allocating datafile /var/lib/mongo/video.5, size: 2047MB,  took 87.078 secs

MongoDBはオブジェクトやコレクションが削除されても、ディスク領域は再利用のためにOSには返されない。mongoシェルから、db.repairDatabase() を実行するとディスク領域がOSに返される。ただしこのコマンド実行時には同じ容量のディスク領域が必要とされるため、残りディスク容量が少ないと以下のようなエラーが発生して失敗する。

ディスクがどうしても足りない場合は db.dropDatabase() を実行すればデータベースごと削除されてディスク領域もOSに戻される。

環境構築

MongoDB本体

CentOS 5.6 (x86_64) 上に構築します。
10genレポジトリを追加してyumでインストールするだけ。

C++ドライバ

構築、使い方についてはブログにて。MongoDB C++クライアント
簡単なCRUD操作のサンプルコードは以下の通り。

  • コンパイル/実行
    $ g++ -g -Wall -I/usr/local/include -L/usr/local/lib -lmongoclient  mongo_test.cpp -o mongo_test
    $ ./mongo_test
    --- insert ---
    { name: "Ryo", age: 26 }
    { name: "Ryo", age: 26, address: "tokyo" }
    --- find ---
    ObjectId: 4ebfc78bb7e28e3bc501cd08
    name: Ryo
    age: 26
    address:
    ObjectId: 4ebfc78bb7e28e3bc501cd09
    name: Ryo
    age: 26
    address: tokyo
    --- update ---
    { $set: { name: "Joe" } }
    { _id: ObjectId('4ebfc78bb7e28e3bc501cd08'), name: "Joe", age: 26 }
    { _id: ObjectId('4ebfc78bb7e28e3bc501cd09'), name: "Joe", age: 26, address: "tokyo" }
    --- remove ---

Pythonドライバ(PyMongo)

pipでインストールするだけ

ログメッセージ

気になったログメッセージに関するメモ。

info DFM::findAll(): extent 0:57700 was empty, skipping ahead

上のようなログが大量に出力されていた。どうやらドキュメントの削除によって空領域ができているよというメッセージ。無視して良いらしい。
参照: DFM::findAll()