MongoDB におけるパラダイムとアンチパラダイム
この記事は『MongoDB権威ガイド』第8章から抜粋したもので、以下の2つの質問に完全に答えることができます:
データの表現方法は多岐にわたりますが、その中で最も重要な問題の1つは、どの程度データを正規化するかということです。正規化(normalization)とは、データを複数の異なるコレクションに分散させ、異なるコレクション間でデータを相互参照できるようにすることです。多くのドキュメントが特定のデータを参照することができますが、そのデータは1つのコレクションにのみ保存されます。したがって、そのデータを変更する場合は、そのデータを保存している1つのドキュメントを変更するだけで済みます。しかし、MongoDBは結合(join)ツールを提供していないため、異なるコレクション間で結合クエリを実行するには複数回のクエリが必要です。
非正規化(denormalization)は正規化とは逆のアプローチで、各ドキュメントに必要なデータをすべてドキュメント内部に埋め込むことです。各ドキュメントは自分自身のデータのコピーを持ち、すべてのドキュメントが同じデータのコピーを参照するのではありません。これは、情報が変更された場合、関連するすべてのドキュメントを更新する必要があることを意味しますが、クエリを実行する際には1回のクエリで全てのデータを取得できます。
正規化と非正規化のどちらを採用するかを決定するのは難しいです。正規化はデータの書き込み速度を向上させ、非正規化はデータの読み取り速度を向上させます。自分のアプリケーションのニーズに応じて慎重にバランスを取る必要があります。
データ表現の例
学生とコースの情報を保存すると仮定します。1つの表現方法は、studentsコレクション(各学生が1つのドキュメント)とclassesコレクション(各コースが1つのドキュメント)を使用し、3つ目のコレクションstudentsClassesを使用して学生とコースの関係を保存することです。
> db.studentsClasses.findOne({"studentsId": id});
{
"_id": ObjectId("..."),
"studentId": ObjectId("...");
"classes": [
ObjectId("..."),
ObjectId("..."),
ObjectId("..."),
ObjectId("...")
]
}
リレーショナルデータベースに慣れている場合、あなたはこのようなタイプのテーブル結合を以前に作成したかもしれませんが、あなたの各記録ドキュメントにはおそらく1人の学生と1つのコース(「_id」のリストではなく)が含まれています。コースを配列に入れるのはMongoDBのスタイルですが、実際には通常このようにデータを保存することはありません。なぜなら、実際の情報を取得するために多くのクエリを経なければならないからです。
学生が選択したコースを見つける必要があるとします。まず、studentsコレクションを検索して学生情報を見つけ、その後studentClassesを検索してコースの「_id」を見つけ、最後にclassesコレクションを検索して必要な情報を取得します。コース情報を見つけるためには、サーバーに3回のクエリを要求する必要があります。このようなデータ組織方法をMongoDBで使用したくないかもしれません。特に学生情報とコース情報が頻繁に変更される場合や、データ読み取り速度に要求がない場合を除いて。
コースの参照を学生ドキュメントに埋め込むと、1回のクエリを節約できます:
{
"_id": ObjectId("..."),
"name": "John Doe",
"classes": [
ObjectId("..."),
ObjectId("..."),
ObjectId("..."),
ObjectId("...")
]
}
"classes"フィールドは配列で、John Doeが受講するコースの「_id」を保存しています。これらのコースの情報を見つける必要がある場合は、これらの「_id」を使用してclassesコレクションを検索できます。このプロセスでは、2回のクエリが必要です。データが常にアクセスされる必要がなく、頻繁に変更されない場合(「常に」は「頻繁」よりも高い要求です)、このデータ組織方法は非常に良いものです。
読み取り速度をさらに最適化する必要がある場合は、データを完全に非正規化し、コース情報を学生ドキュメントの「classes」フィールドに内蔵文書として保存することができます。これにより、1回のクエリで学生のコース情報を取得できます:
{
"_id": ObjectId("..."),
"name": "John Doe"
"classes": [
{
"class": "Trigonometry",
"credites": 3,
"room": "204"
},
{
"class": "Physics",
"credites": 3,
"room": "159"
},
{
"class": "Women in Literature",
"credites": 3,
"room": "14b"
},
{
"class": "AP European History",
"credites": 4,
"room": "321"
}
]
}
この方法の利点は、1回のクエリで学生のコース情報を取得できることですが、欠点はより多くのストレージスペースを占有し、データの同期が難しくなることです。たとえば、物理学の単位が3から4に変更された場合(もはや3ではない)、物理学のコースを履修しているすべての学生ドキュメントを更新する必要があり、単に「Physics」ドキュメントを更新するだけでは済みません。
最後に、内蔵データと参照データを混合して使用することもできます。一般的な情報を保存するためのサブドキュメント配列を作成し、詳細情報を取得する必要がある場合は参照を使用して実際のドキュメントを見つけます:
{
"_id": ObjectId("..."),
"name": "John Doe",
"classes": [
{
"_id": ObjectId("..."),
"class": "Trigonometry"
},
{
"_id": ObjectId("..."),
"class": "Physics"
}, {
"_id": ObjectId("..."),
"class": "Women in Literature"
}, {
"_id": ObjectId("..."),
"class": "AP European History"
}
]
}
この方法も良い選択肢です。なぜなら、内蔵情報はニーズの変化に応じて変更でき、ページに含める情報を増やしたり減らしたりすることができるからです。
考慮すべきもう1つの重要な問題は、情報の更新がより頻繁か、情報の読み取りがより頻繁かということです。これらのデータが定期的に更新される場合、正規化はより良い選択です。データが頻繁に変化しない場合、更新効率を最適化するために読み書き速度を犠牲にすることは価値がありません。
たとえば、教科書で紹介される正規化の例は、ユーザーとユーザーの住所を異なるコレクションに保存することかもしれません。しかし、人々は住所をほとんど変更しないため、そのような確率の低い状況(誰かが住所を変更した)のために、各クエリの効率を犠牲にすべきではありません。この場合、住所はユーザードキュメントに内蔵すべきです。
内蔵ドキュメントを使用することを決定した場合、ドキュメントを更新する際には、すべてのドキュメントが成功裏に更新されることを確認するために定期的なタスク(cron job)を設定する必要があります。たとえば、複数のドキュメントに更新を広げようとしているとき、すべてのドキュメントの更新が完了する前にサーバーがクラッシュした場合、この問題を検出し、未完了の更新を再実行する必要があります。
一般的に、データ生成が頻繁であればあるほど、それらを他のドキュメントに内蔵すべきではありません。内蔵フィールドまたは内蔵フィールドの数が無限に増加する場合、それらは別のコレクションに保存し、参照を使用してアクセスするべきです。コメントリストやアクティビティリストなどの情報は、別のコレクションに保存し、他のドキュメントに内蔵すべきではありません。
最後に、特定のフィールドがドキュメントデータの一部である場合、それらのフィールドはドキュメントに内蔵する必要があります。ドキュメントをクエリする際に特定のフィールドを除外する必要が頻繁にある場合、そのフィールドは現在のドキュメントに内蔵するのではなく、別のコレクションに置くべきです。
| 内蔵に適したもの | 参照に適したもの |
|---|---|
| サブドキュメントが小さい | サブドキュメントが大きい |
| データが定期的に変わらない | データが頻繁に変わる |
| 最終的なデータの一貫性があればよい | 中間段階のデータは一貫している必要がある |
| ドキュメントデータがわずかに増加する | ドキュメントデータが大幅に増加する |
| データを取得するために通常二次クエリを実行する必要がある | データは通常結果に含まれない |
| 迅速な読み取り | 迅速な書き込み |
ユーザーコレクションがあると仮定します。以下は、必要なフィールドのいくつかと、それらがユーザードキュメントに内蔵されるべきかどうかです。
ユーザーの設定(account preferences)
ユーザーの設定は特定のユーザーに関連しており、他のユーザー情報と一緒にクエリされる可能性が高いです。したがって、ユーザーの設定はユーザードキュメントに内蔵すべきです。
最近の活動(recent activity)
このフィールドは最近の活動の増加と変化の頻度に依存します。固定長のフィールド(たとえば、最近の10回の活動)であれば、このフィールドはユーザードキュメントに内蔵すべきです。
友達(friends)
通常、友達情報はユーザードキュメントに内蔵すべきではありません。少なくとも、友達情報を完全にユーザードキュメントに内蔵すべきではありません。次のセクションでは、ソーシャルネットワークアプリケーションに関連する内容を紹介します。
ユーザーが生成したすべてのコンテンツ
ユーザードキュメントに内蔵すべきではありません。
基数
コレクション内に含まれる他のコレクションへの参照の数を基数(cardinality)と呼びます。一般的な関係には、一対一、一対多、多対多があります。たとえば、ブログアプリケーションがあります。各ブログ記事(post)にはタイトル(title)があり、これは一対一の関係です。各著者(author)は複数の記事を持つことができ、これは一対多の関係です。各記事には複数のタグ(tag)があり、各タグは複数の記事で使用される可能性があるため、これは多対多の関係です。
MongoDBでは、多(many)は2つのサブカテゴリに分けることができます:多(many)と少(few)。たとえば、著者と記事の間には一対少の関係があるかもしれません:各著者は数少ない記事しか発表していません。ブログ記事とタグの間には多対少の関係があるかもしれません:実際には記事の数がタグの数よりも多い可能性があります。ブログ記事とコメントの間には一対多の関係があります:各記事には多くのコメントがあります。
少と多の関係が決まれば、内蔵データと参照データの間でのトレードオフは比較的容易になります。一般的に、「少」の関係は内蔵方式が良く、「多」の関係は参照方式が良いです。
友達、フォロワー、そしてその他の厄介なこと
親しい友人には近づき、敵には遠ざかれ
多くのソーシャルアプリケーションは、人、コンテンツ、フォロワー、友達、その他の事柄をリンクする必要があります。これらの高度に関連するデータに対して、内蔵形式と参照形式のどちらを使用するかを評価するのは簡単ではありません。このセクションでは、ソーシャルグラフデータに関連する注意事項を紹介します。通常、フォロー、友達、またはお気に入りは、発行・購読システムに簡略化できます:ユーザーは他のユーザーに関連する通知を購読できます。これにより、2つの基本操作が比較的効率的に行われる必要があります:購読者を保存する方法と、イベントをすべての購読者に通知する方法です。
一般的な購読の実装方法は3つあります。最初の方法は、コンテンツの生産者を購読者のドキュメントに内蔵することです:
{
"_id": ObjectId("..."),
"username": "batman",
"email": "[email protected]",
"following": [
ObjectId("..."),
ObjectId("...")
]
}
これにより、特定のユーザードキュメントに対して、db.activities.find({"user": {"$in": user["following"]}})のような形で、そのユーザーが興味を持っているすべての活動情報をクエリできます。しかし、最近発表された活動情報に対して興味を持っているすべてのユーザーを見つけるには、すべてのユーザーの「following」フィールドをクエリしなければなりません。
別の方法は、購読者を生産者のドキュメントに内蔵することです:
{
"_id": ObjectId("..."),
"username": "joker",
"email": "[email protected]",
"followers": [
ObjectId("..."),
ObjectId("..."),
ObjectId("..."
]
}
この生産者が新しい情報を発表すると、どのユーザーに通知を発行する必要があるかをすぐに知ることができます。この方法の欠点は、ユーザーがフォローしているユーザーのリストを見つけるには、全ユーザーコレクションをクエリしなければならないことです。この2つの方法の利点と欠点は正反対です。
さらに、これら2つの方法にはもう1つの問題があります:それらはユーザードキュメントをますます大きくし、変更もますます頻繁になります。通常、「following」と「followers」フィールドは返す必要すらありません:フォロワーリストをクエリする頻度はどのくらいですか?ユーザーが頻繁に他の人をフォローしたり、フォローを解除したりすると、大量のフラグメントが発生します。したがって、最終的な解決策はデータをさらに正規化し、購読情報を別のコレクションに保存して、これらの欠点を回避することです。このような程度の正規化は少し行き過ぎかもしれませんが、頻繁に変更され、他のフィールドと一緒に返す必要がないフィールドには非常に有用です。「followers」フィールドのこのような正規化は意味があります。
発行者と購読者の関係を保存するためのコレクションを作成し、そのドキュメント構造は次のようになります:
{
"_id": ObjectId("..."), //フォローされているユーザーの"_id"
"followers": [
ObjectId("..."),
ObjectId("..."),
ObjectId("..."
]
}
これにより、ユーザードキュメントは比較的簡潔になりますが、フォロワーリストを取得するために追加のクエリが必要になります。「followers」配列のサイズは頻繁に変化するため、このコレクションで「usePowerOf2Sizes」を有効にして、users コレクションをできるだけ小さく保つことができます。フォロワーコレクションを別のデータベースに保存することで、users コレクションにあまり影響を与えずに圧縮することも可能です。
ウィル・ウィートン効果への対処
どのような戦略を使用しても、埋め込みフィールドはサブドキュメントや参照数が特に多くない場合にのみ効果を発揮します。有名なユーザーの場合、フォロワーリストを保存するためのドキュメントがオーバーフローする可能性があります。このような状況の解決策の一つは、必要に応じて「連続した」ドキュメントを使用することです。例えば:
> db.users.find({"username": "wil"})
{
"_id": ObjectId("..."),
"username": "wil",
"email": "[email protected]",
"tbc": [
ObjectId("123"), // just for example
ObjectId("456") // same as above
],
"followers": [
ObjectId("..."),
ObjectId("..."),
ObjectId("..."),
...
]
}
{
"_id": ObjectId("123"),
"followers": [
ObjectId("..."),
ObjectId("..."),
ObjectId("..."),
...
]
}
{
"_id": ObjectId("456"),
"followers": [
ObjectId("..."),
ObjectId("..."),
ObjectId("..."),
...
]
}
このような場合、アプリケーションに「tbc」(to be continued)配列からデータを取得するための関連ロジックを追加する必要があります。
何かを言う
No silver bullet.