MongoDB: Data Model Design

📝 MongoDB のドキュメントを箇条書きしていく。随時更新。

Env / Versions

  • MongoDB v3.6

Embedded Data Models (埋め込みモデル)

Example:

{
  _id: <ObjectId1>,
  username: "123xyz",
  contact: {
    phone: "123-456-7890",
    email: "xyz@example.com"
  },
  access: {
    level: 5,
    group: "dev"
  }
}
  • 非正規化モデル
  • より少ないクエリとアップデートで済む
  • 次のような時に使用する
    • エンティティ間に「包含」関係がある。1対1の関係
    • エンティティ間に1対多の関係がある。子ドキュメントは常に親ドキュメントのコンテキストで表現される
  • 🙆‍♀️ Strengths
    • 単一のデータベースから関連するデータを取得するのと同じように、参照操作のパフォーマンスを向上させる
    • 1回のアトミックな更新操作で関連するデータを更新することができる
  • 🤦‍♀️ Weaknesses
    • 関連データをドキュメントに埋め込むと、作成後にドキュメントサイズが大きくなる
    • MMAPv1ストレージエンジンを使用すると、ドキュメントの増大が書き込みのパフォーマンスに影響を与え、データの断片化を招く可能性がある
    • 📝 ドキュメントのサイズは最大で 16 megabytes

Normalized Data Models (参照モデル)

Example:

// user document
{
  _id: <ObjectId1>,
  username: "123xyz"
}

// contact document
{
  _id: <ObjectId2>,
  user_id: <ObjectId1>,
  phone: "123-456-7890",
  email: "xyz@example.com"
}

// access document
{
  _id: <ObjectId3>,
  user_id: <ObjectId1>,
  level: 5,
  group: "dev"
}
  • 正規化モデル
  • 次のような時に使用する
    • embedded による読み込みのパフォーマンスのメリットがデータが重複してしまうデメリットを上回らないとき
    • より複雑な多対多の関係を表現するとき
    • 大きな階層化されたデータセットをモデル化するとき
  • 参照モデルは埋め込みモデルよりもフレキシブル。
  • ただし、クライアント側アプリケーションは、参照モデルを解決するためにフォローアップクエリを発行する必要がある。
    • 📝ドキュメント間の関連性の解決はクライアント側が担う(JOINとかないので、複数クエリの発行が必要)
  • 正規化されたデータモデルでは、サーバーへのより多くのラウンドトリップが必要になる可能性がある

Links

Data Model Design — MongoDB Manual

MongoDB : Replica Set の参照設定

📝MongoDB のドキュメントを箇条書きしていく。随時更新

Env / Versions

  • MongoDB v3.6

参照の優先度設定

  • ⚠️ Primary から Secondary への非同期レプリケーションには遅延が発生するため、古いデータが返される可能性がある

参照のモード

  • 各 MongoDB ドライバーは以下の5つのモードをサポートしている

    primary

  • Primary からのみ参照する
  • Primary が使用できない場合はエラーになる
  • デフォルト

    primaryPreferred

  • Primary から参照するが使用できない場合は Secondary から参照する

    secondary

  • Secondary からのみ参照する
  • 使用可能な Secondary が無い場合はエラーになる

    secondaryPreferred

  • Secondary から参照するが使用可能な Secondary が1台もない場合は Primary から参照する

    nearest

  • ネットワークレイテンシが最も低いメンバーから参照する
  • Primary, Secondary 問わない

サーバ選択アルゴリズム

  • サーバーの選択は操作ごとに1回発生し、参照設定とlocalThresholdMS の設定によって決められる
  • サーバ選択アルゴリズムは各クライアントで実装されている
  • secondary または secondaryPreferred モードを指定した場合
    • 最も低いレイテンシ + 15msec 以内のサーバを「使用可能」とする
    • 15msec はデフォルト値であり変更可能
    • 「使用可能」なサーバ群からランダムで接続先が決定される

Links

🍣 Itamae & Serverspec ことはじめ

Itamae を使って Vagrant 環境を構築してみます。 また、構築した環境は Serverspec でテストします。

今回のサンプルコードは GitHub にアップしました。

Env

Vagrant セットアップ

まずは Vagrant の設定を。

$ mkdir workdir; cd workdir
$ vagrant init

CentOS7 で試してみたことがあったので、今回は Ubuntu でいきます。

$ vagrant init ubuntu/trusty64; vagrant up --provider virtualbox

ref: https://git.io/vrsCY

起動した Vagrant にログインできることを確認。

$ vagrant ssh

Itamae で Nginx をインストール

Itamae も Serverspec も Ruby の Gem で公開されています。 Bundler を使ってインストールします。

$ bundle init
$ vim Gemfile
$ # diff

+ gem 'itamae'
+ gem 'serverspec'

$ bundle install --path vendor/bundle --jobs 4

ref: https://git.io/vrsB4

Itamae には決まったディレクトリ構成はありませんが、推奨の構成があります。Best Practice · itamae-kitchen/itamae Wiki 今回はこの構成で進めていきます。

$ bundle exec itamae init
$ tree
.
├── Gemfile
├── Gemfile.lock
├── Vagrantfile
├── cookbooks
└── roles

手始めに Nginx のインストールレシピを書いてみます。

$ vim cookbooks/nginx/default.rb
$ vim roles/web.rb

ref: https://git.io/vrsB4

このレシピを Vagrant に反映します。

$ # .ssh/config に Vagrant のホスト設定を追加
$ vagrant ssh-config --host localhost.ubuntu >> ~/.ssh/config
$
$ # Itamae を実行
$ itamae ssh --vagrant --host localhost.ubuntu roles/web.rb
INFO : Starting Itamae...
INFO : Recipe: /your/workdir/roles/web.rb
INFO : Recipe: /your/workdir/cookbooks/nginx/default.rb

Vagrant 上で起動を確認

$ vagrant ssh
vagrant@localhost:~$ service status nginx
status: unrecognized service

Serverspec で Nginx 周りをテスト

さて、せっかくサーバのセットアップがコード化できたので、設定の確認作業もコードで管理したいところです。

Serverspec でテストコードを書いていきます。 Serverspec も Itamae 同様にディレクトリをどう構成するするかは自由ですがコマンドでテンプレートを作ることができます。 こちらもデフォルトの構成で進めます。

serverspec-init コマンドを使って対話式でテンプレートを作成します。

$ bundle exec serverspec-init
Select OS type:

1) UN*X
2) Windows

Select number: 1

Select a backend type:

1) SSH
2) Exec (local)

Select number: 1

Vagrant instance y/n: y
Auto-configure Vagrant from Vagrantfile? y/n: n
Input vagrant instance name: localhost.ubuntu
+ spec/
+ spec/localhost.ubuntu/
+ spec/localhost.ubuntu/sample_spec.rb
+ spec/spec_helper.rb
+ Rakefile
+ .rspec

ref: https://git.io/vrs7b

spec 配下に作成されたディレクトリはテスト対象のホスト名です。 テストコードを対象ホスト単位でまとめるのか、ロール(web や db など)でまとめるのかは自由です。 Rakefile のコードを修正することで自由に編成できます。

Serverspecのサイトにあるサンプルが参考になります。 ref: Serverspec - Advanced Tips

Nginx のインストールとサービス設定、そして 80 ポートを Listen してるかのテストをします。

ref: https://git.io/vrs7A

テストは Rake で実行します。Rake タスクは動的に定義されます。(Rakefile 参照) 定義された Rake タスクは rake -T で確認できます。

$ rake -T
rake spec:localhost.ubuntu # Run serverspec tests to localhost.ubuntu

テストを実行します。

$ rake spec:localhost.ubuntu
/Users/keisuke/.rbenv/versions/2.3.1/bin/ruby -I/Users/keisuke/.rbenv/versions/2.3.1/lib/ruby/gems/2.3.0/gems/rspec-support-3.4.1/lib:/Users/keisuke/.rbenv/versions/2.3.1/lib/ruby/gems/2.3.0/gems/rspec-core-3.4.4/lib /Users/keisuke/.rbenv/versions/2.3.1/lib/ruby/gems/2.3.0/gems/rspec-core-3.4.4/exe/rspec --pattern spec/localhost.ubuntu/\*_spec.rb

Package "nginx"
should be installed

Service "nginx"
should be enabled
should be running

Port "80"
should be listening

Finished in 0.58268 seconds (files took 5.66 seconds to load)
4 examples, 0 failures

テスト成功です :)

実際にはテストコードを共通化したり、複数ホストにテストを同時実行したりと、もっとカスタマイズすることになるでしょう。 その辺も実装してみたので、また今度まとめたいですね。

おわり

たまにしかやらないからこそ、コード化できていると良いですね。仕事でも導入しましたが、インフラの変更履歴も共有でき、何よりテストが自動化できたのには安心感が増します。

とりあえず、初めの1歩でした :P

👀 MongoDB 3.x インデックス生成前後のexplain()結果を読む

はじめに

MongoDB 3.0 から explain() の出力結果が変わり、読み解くのに時間がかかってしまいました。 今回はインデックスの生成前後で explain() の結果がどう変わるかを確認してみます。

環境

  • Mac OS X 10.10.3
  • MongoDB 3.0.2
  • MongoDB storage engine: mmapv1 (default)

サンプルデータの準備

DBを用意

$ mongo

use sample_db
switched to db sample_db

サンプルデータ追加

とりあえず10万件のドキュメントを生成しておきます。

> for (var i=0; i < 100000; i++) { ... db.items.insert({ name: 'item_' + i, price: 100 + i }) ... } > db.items.count()
100000

インデックスが無い状態

itemsコレクションから119円の商品を探すクエリを実行します。

> db.items.find({price: 119}).explain("executionStats")
{
    "queryPlanner": {
        "plannerVersion": 1,
        "namespace": "sample_db.items",
        "indexFilterSet": false,
        "parsedQuery": {
            "price": {
                "$eq": 119
            }
        },
        "winningPlan": {
            "stage": "COLLSCAN",
            "filter": {
                "price": {
                    "$eq": 119
                }
            },
            "direction": "forward"
        },
        "rejectedPlans": []
    },
    "executionStats": {
        "executionSuccess": true,
        "nReturned": 1,
        "executionTimeMillis": 59,
        "totalKeysExamined": 0,
        "totalDocsExamined": 100000,
        "executionStages": {
            "stage": "COLLSCAN",
            "filter": {
                "price": {
                    "$eq": 119
                }
            },
            "nReturned": 1,
            "executionTimeMillisEstimate": 20,
            "works": 100002,
            "advanced": 1,
            "needTime": 100000,
            "needFetch": 0,
            "saveState": 781,
            "restoreState": 781,
            "isEOF": 1,
            "invalidates": 0,
            "direction": "forward",
            "docsExamined": 100000
        }
    },
    "serverInfo": {
        "host": "MacBook-Pro.local",
        "port": 27017,
        "version": "3.0.2",
        "gitVersion": "nogitversion"
    },
    "ok": 1
}

まずは queryPlannner.winningPlan に着目します。"stage": "COLLSCAN" となっています。 これは MongoDB 2.x の時の BasicCursor に相当します。

インデックスを使わずに全走査してることがわかります。

※ COLLSCAN => COLLECTION SCAN

実際に走査対象となったドキュメント数やクエリにかかった時間は queryPlannner.executionStats を確認します。

key description value
"nReturned": 1 見つかったドキュメント数 1
"executionTimeMillis": 59 実行時間 59 msec
"totalKeysExamined": 0 検索したインデックス数 0
"totalDocsExamined": 100000 検索したドキュメント数 100000

totalDocsExamined からも全ドキュメントが検索対象だったことがわかりますね

※ インデックス生成してないので当たり前ですが

インデックス生成後

それではインデックスを生成してみます。

> db.items.createIndex({ price: 1 })
{
  "createdCollectionAutomatically" : false,
  "numIndexesBefore" : 1,
  "numIndexesAfter" : 2,
  "ok" : 1
}

> db.items.getIndexes()
[
  {
    "v" : 1,
    "key" : {
      "_id" : 1
    },
    "name" : "_id_",
    "ns" : "sample_db.items"
  },
  {
    "v" : 1,
    "key" : {
    "price" : 1
  },
    "name" : "price_1",
    "ns" : "sample_db.items"
  }
]

price_1 という名前の priceフィールド昇順 のインデックスができました。

追記

db.collection.ensureIndex() は MongoDB 3.0.0 で deprecated と なりましたので db.collection.createIndex() に書き直しました。 参考: db.collection.ensureIndex()

再度、items コレクションから119円の商品を探すクエリを実行します。

> db.items.find({price: 119}).explain("executionStats")
{
    "queryPlanner": {
        "plannerVersion": 1,
        "namespace": "sample_db.items",
        "indexFilterSet": false,
        "parsedQuery": {
            "price": {
                "$eq": 119
            }
        },
        "winningPlan": {
            "stage": "FETCH",
            "inputStage": {
                "stage": "IXSCAN",
                "keyPattern": {
                    "price": 1
                },
                "indexName": "price_1",
                "isMultiKey": false,
                "direction": "forward",
                "indexBounds": {
                    "price": [
                        "[119.0, 119.0]"
                    ]
                }
            }
        },
        "rejectedPlans": []
    },
    "executionStats": {
        "executionSuccess": true,
        "nReturned": 1,
        "executionTimeMillis": 13,
        "totalKeysExamined": 1,
        "totalDocsExamined": 1,
        "executionStages": {
            "stage": "FETCH",
            "nReturned": 1,
            "executionTimeMillisEstimate": 0,
            "works": 2,
            "advanced": 1,
            "needTime": 0,
            "needFetch": 0,
            "saveState": 0,
            "restoreState": 0,
            "isEOF": 1,
            "invalidates": 0,
            "docsExamined": 1,
            "alreadyHasObj": 0,
            "inputStage": {
                "stage": "IXSCAN",
                "nReturned": 1,
                "executionTimeMillisEstimate": 0,
                "works": 2,
                "advanced": 1,
                "needTime": 0,
                "needFetch": 0,
                "saveState": 0,
                "restoreState": 0,
                "isEOF": 1,
                "invalidates": 0,
                "keyPattern": {
                    "price": 1
                },
                "indexName": "price_1",
                "isMultiKey": false,
                "direction": "forward",
                "indexBounds": {
                    "price": [
                        "[119.0, 119.0]"
                    ]
                },
                "keysExamined": 1,
                "dupsTested": 0,
                "dupsDropped": 0,
                "seenInvalidated": 0,
                "matchTested": 0
            }
        }
    },
    "serverInfo": {
        "host": "MacBook-Pro.local",
        "port": 27017,
        "version": "3.0.2",
        "gitVersion": "nogitversion"
    },
    "ok": 1
}

queryPlannner.winningPlan を確認します。 "stage": "IXSCAN" に変わりました。 これは MongoDB 2.x の時の BtreeCursor に相当します。 INDEXを使用していることがわかります :)

※ IXSCAN => INDEX SCAN

ここでまた、 queryPlannner.executionStats を確認します。

key description value
"nReturned": 1 見つかったドキュメント数 1
"executionTimeMillis": 13 実行時間 13 msec
"totalKeysExamined": 1 検索したインデックス数 1
"totalDocsExamined": 1 検索したドキュメント数 1

おわりに

最低限見ておきたい項目について、インデックスの生成前後での差分を見てみました。 実際の運用においては1コレクションに1インデックスという事は少なく、複数のインデックスや 複合キーインデックスが生成されていると思います。

そうすると今回見ていった項目以外にも rejectedPlans で採用されなかったインデックスを確認したり ソート指定した場合のインデックスの使われ方を確認したりといった事が必要となるでしょう。

それぞれの項目の説明についてはまた別途まとめましょうか。

参考

Explain Results - MongoDB Manual 3.0.2