Open8
MongoDB データモデリングのベストプラクティス
MongoDB Universityから、気になったコンテンツを読んでいく。
モデルのrelationship
- 1-to-1, 1-to-many, many-to-many
- mongodbでのrelationshipの表現: embeddingとreferencingがある
- embedding
- nestの形で一つのドキュメントに関連するデータを埋め込む
- mongoDBのキー思想である、「Data that is accessed together should be sotred together」を実践している
- 1度のクエリで情報を取れるためクエリの数が減り、applicationでのjoinロジックが不要になりパフォーマンスも上がる
- 書き込みもatomicになる
- ただし、embeddingをやりすぎるとdocumentが肥大化し、メモリ使用が非効率に。パフォーマンスが劣化することも。極端なケースでは、arrayフィールドなどにどんどんデータが溜まっていき、mongoの1ドキュメントのデータ量最大値16 MBを超えてしまうことも。
- referencing
- linking, data normalizationと呼ばれることも
- embeddingと違い、データの重複を避けることができ、documentのサイズも小さくなる
- ただ、複数回readクエリを発行しないといけないなどパフォーマンスの懸念もある
- embedding
- モデルの見通しを良くするため、積極的にnestやarrayを使うべき。下記リファクタ例
スキーマアンチパターン
- 「Data that is accessed together should be sotred together」が基本
- アンチパターン
- 際限なくデータが肥大化するモデル(e.g., 記事に対するコメント一覧をembeddingで持つと、コメントが無限に増えていった時に困る)
- 大量のcollection
- 不要なインデックス/インデックスが足りない
- ほぼほぼ同じタイミングでアクセスされるのに、別collectionになってる
- mongodb atlasでこの辺のアンチパターンは確認できる。
- 単一documentに対する操作は全てatomic (e.g., updateOne)
- 複数documentに対する操作をatomicにするには明示的にtransactionにする必要がある
- ただし、アプリの要件を考えて、本当に必要なところに慎重に使ったほうがいい
- なぜなら、mongoは、transactionに含まれるすべてのドキュメントをロックするから。これにより、レイテンシなどパフォーマンスに悪影響を及ぼすことがある (RDBだとembeddingとかができないのでテーブルを分ける必要性が高くtransaction必須だが、mongoではまず一緒に更新するものを単一documentにすることを考えたほうが良い)
- session: 一緒に実行すべきdb operationをまとめたもの。
- transactionの最大実行時間は、最初のwriteから1分
参考: (ロックについてもう少し深掘りしたい)
トランザクション分離レベル
デフォルトではread uncommittedと、弱めの分離レベルにしてパフォーマンスを優先している
これをやっていく
Introduction to data modeling
The document model in MongoDB
- document: BSONの形で保存される。(=JSONのbinary版)。
- メンタルモデル的には、document = RDBのrowだが、他のテーブルとjoinして他の情報を含めたものとみなせる(nestの形などで表現される)
- RDBではテーブルを分けないと表現できないものも、一つのdocumentで表現できるのがmongoの強み。フレキシブル
Constraints in data modeling
- 様々な制約
- ハードウェア: コストとスピードのトレードオフ(ram, ssd, hdd)
- データ: サイズ(デカすぎるデータはramにのらない)、セキュリティ(アクセスコントロール)、データ主権
- アプリ: ネットワークレイテンシ
- MongoDB: ドキュメントサイズ(16 MB)、updateのatomicity
- mongoの機能
- working set
- アプリの通常オペレーションで使われるデータの総称。ドキュメントとインデックスのうち頻繁にアクセスされるもの。
- RAMに保持される(memory + FS cache)。RAMがいっぱいになったらevictされていく
- working set
- ディスクの考慮
- SSDをなるべく使うと良い。ただし、ほとんど参照されないログとかはHDDを使うのもあり
- モデルの考慮
- 上記制約を考えてモデルを作ると良い
The data modeling methodology
- modelを決めるための3つのフェーズ
- ワークロードの特定
- データ量
- ops/second (read, write)
- レイテンシ
- durability
- リレーションの特定
- 1-to-1 relationshipは基本的に同一document。それ以外をembeddingか、別collectionでreferencingにするか。
- デザインパターンの適用
- よりパフォーマンスよく、見通し良くする
- ワークロードの特定
Model for simplicity or performance
- simplicity
- 主に小さいアプリを設計する時はこっちを優先する
- モデリングは複雑なことを考えず、少ないcollectionでembeddingを多用する
- documentの内容が、アプリケーションで扱うobject相当のものにそのままマップされる
- 読み込みのクエリ回数も少ない
- performance
- 大規模、shardingなどを使うケース。ops/secondsが多い。大人数のチームが作業する
- ワークロードを定量的に特定
- collectionは多くなる傾向にある
- どちらを優先すべきか迷ったら?
- simplicityを優先する
- シンプルなものを改良するのは簡単。複雑性をとりのぞくのは難しい
Identifying the workload
- ケーススタディ: 1億個の天気センサから1分ごとにデータ収集、10人のデータサイエンティストが解析。10年分保存。1時間ごとに中間集計走らせる
- 想定されるユースケースごとに、ops/sec, data size, replicationのackを待つ必要があるか(欠損したらだめなデータは待ったほうがいい)などを特定する。
- 最もクリティカルな操作は?: デバイスからのデータ収集。
- read操作は?: データサイエンティストが発行するクエリ。このクエリではどんなパターンがよく発行されるか?も特定しておく
Relationships
Relationship types and cardinality
- 1-to-many, many-to-manyのmanyにもいろいろある(e.g., 母親に対する子供の数はせいぜい数人、一方twitterのフォロワーは1億とかある)。mongoのデータモデリングにおいてこれは重要
One-to-many relationship
- パターンとして「embed vs reference」「1 side or many side」の掛け合わせで4パターン
- 1の方にembedするケース
- e.g., item documentにreviewsを入れる。
- manyの数が小さく、シンプルなアプリだとこれが一番一般的。
- manyの方にembedするケース
- e.g., order documentにshipping addressを入れる。
- あまり一般的でないが、manyのほうの数が多く、頻繁にクエリされるならメリットある。
- また、embedされたshipping addressはduplicateされる。アプリ要件によってはそれが好ましい場合もある
- manyの方に1のidを持たせて、referenceするケース
- e.g., store documentにzip idを入れる(zip documentは別で存在する)
- referenceにするならこれが一般的。
- manyのほうが数が多い or 1つ1つのデータが大きくなっても耐えられる
- storeを削除してもzipはいじらなくて良い
- 1のほうにmanyのidをarrayで持たせて、refferenceするケース
- e.g., zip code documentにstoreIdsを入れる。
- 1の方をqueryするときに、manyの方の情報全部は入らず、特定のものだけあればいいケースだと有用
- cascade deleteはmongoだとサポートされてないので、storeを削除した時にzipの方も更新するのをアプリ側で実装する必要がある
- 1の方にembedするケース
- 一般的な指針
- embedding: シンプルさを優先するとき。manyのほうの数やサイズが小さいとき
- reference: 一番よく行われるクエリにおいて、related documentsの全内容が必ずしもいらないとき。manyの方の数やサイズが大きいとき
Many-to-many relationship
- RDBとかでは、many-to-manyの間にrelationshipを定義するテーブル(jumpテーブル)を別途作って、2つの1-to-manyにすることが多い
- mongoでは、arrayを持つことができるので、それによりmany-to-manyをduplicationありの1-to-manyにできる
- 例えば、peopleとphone_numbersを考える。人は複数の電話番号を持ちうるし、電話番号は複数の人にシェアされうる(e.g., 家の電話)
- phone_numbersをpersonにarrayで持たせることにより、1-to-manyにできる。
- これだと、家の電話番号は家族全員のdocumentに重複して持たせることになり、電話番号が変わった時に複数回のupdateが必要になってしまう。
- ただ、duplicateすることによるメリットもある。例えば、家族の一人(例えば息子)が引っ越して家の電話番号をupdateしたとき、その変更が家族全員に波及するのは良くない。duplicateしておけばこういうのを容易にハンドリングできる。(それってアプリの作り次第では?)
- mongoでのmany-to-manyのパターンも、embedかreference
- embed
- e.g., cart documentにitemsを入れる。
- よくクエリされる方のdocumentに、arrayでもう片方のdocumentを埋め込む。
- itemのデータは重複して持つことになる。重複するデータは、できればstaticで、重複によるメリットがあることが望ましい。
- reference
- e.g., item documentの中に、storeIdsを入れる
- データの重複が少なくなる。重複を避けたいケースではreferenceを使うと良い
One-to-one relationship
- 通常のRDBでも、1-to-1は同じtableに入れることが多い。mongoも同様
- embed, fields at the same level
- RDBのように全部フラットにフィールドを持たせる
- embed, using subdocuments
- ネストさせる
- 整理され、わかりやすくなる。フラットに持つよりもこちらが推奨される
- reference
- e.g., storesとstore_detailsがあり、両方に共通のstoreIdを持たせる
- 複雑になるので、スキーマ最適化の目的でのみ行うべき
- storesをクエリする時に、読み込むデータ量が減るのでパフォーマンスが改善する可能性がある
One-to-zillions relationship
- 1つのレコードに1億とか紐づく可能性がある、1-to-manyの特殊な場合
- manyの方に1のidを持たせてreferenceするしか選択肢がない
- モデル設計に加えて、クエリするときに特に注意を払うこと
Patterns (part 1)
Handling duplication, staleness and integrity
- patternの目的は、しばしば特定のユースケースでパフォーマンスやシンプルさを最適化すること。
- それと引き換えに、下記のようなコストが生じる場合がある。このコストとメリットを天秤にかけ、コストのほうが大きければpatternを使わないほうがいい
- データの重複
- データの一部が古くなる(stale)
- referenceしているdocument群の完全性を保つため、追加でアプリケーションロジックが必要
Handling duplication
- 前述のように、embeddingの結果、duplicationが生じることがある。前述のように、duplicationは常に悪、とは限らない。
- 緩和方法
- bulk update
Handling staleness
- データの更新頻度が高く、更新のたびに関連する全部のデータを更新してたらパフォーマンス悪くなる、みたいな時にstalenessを許容することが行われる。
- どれだけstalenessを許容するかはユースケース次第。(e.g., サイトの訪問者数とかは全然staleで良い)
- 緩和方法
- batch update: stalenessが許容される間隔でデータをバッチ更新
- change stream: あるデータの更新をトリガーに他のデータも更新
Referential integrity
- あるdocumentからreferenceしてるdocumentがないとかそういう場合。
- mongoはforeign keyやcascading deleteがないのでこういう問題が起こる。アプリ側でhandleする必要がある。
- 緩和方法
- change stream
- embeddingを使い、referencingを使わない
- transactionを使う
Attribute pattern
- ポリモーフィズムを利用した、最もよく使われるパターンの一つ。
- 似ているが違うオブジェクトを表現するために使う。
- e.g., 「商品」 という概念で、コーラとバッテリーを扱う場合、descriptionやpriceは共通のフィールド。sizeは共通だが両者で意味が違う。seetenerはコーラだけに存在。input,outputはバッテリーだけに存在。
- optionalなフィールドが増えると、indexの貼り方、スキーマバリデータの書き方などいろいろヤバくなってくる。
- これを避けるのがattributeパターン。下記のように、オプショナルなフィールドをkey/valueのペアのarrayとして持たせる。(kがkey, vがvalue, uが単位)
{
"description": "hoge",
"price": 123,
"add_specs": [
{ "k": "input", "v": 5, "u": "V" },
{ "k": "output", "v": 10, "u": "V" },
]
}
- add_specsに対してindexを貼るのも容易。
createIndex({ "add_specs.k": 1, "add_specs.v": 1 })
とすれば良い - attribute patternは、共通なfieldと、稀/予測不能なoptional fieldを構造化し、indexをシンプルにするのに有用
Extended reference pattern
- joinが多すぎる問題を解決する。mongoはRDBに比べjoinは少ないが、それでも多い場合がある。
- mongodbでjoinを行う方法は下記2通り
- アプリケーションコード
-
graphLookuplookupもしくは
- それとは別に、extended referenceパターンで、物理的なjoinを行わずに解決する方法もある
- e.g., ordersとcustomersコレクションで、ordersがcustomer_idをreferenceとして持っているとする。ordersは頻繁にクエリされ、その度にcustomerのshipping_addressだけが必要になり、都度customerとのjoinが生じているとする。
- このとき、ordersの方に、customer_idに加えてshipping_addressも持たせてしまうと、joinの必要がなくなる。
- ただし、duplicationが生じるので、ordersに含めるフィールドは、頻繁に変更されないことと、最小限のフィールドだけにするのが大事。また必要に応じて前述の緩和方法を行う
Subset pattern
- RAMにworking setが収まりきらないと、パフォーマンスが急激に悪化する。
- これを防ぐために、RAMのサイズを増やす、shardingするなどが考えられるが、working setのサイズを減らすのも重要
- このために、多数のフィールドを持つドキュメントから、あまり使われず、かつ容量を食っているフィールドを別collectionに切り出すのがsubset pattern。
- e.g., movie documentに、casts全てをembedするのではなく、castという別collectionを用意し、movie側にはtop 20のcastだけembedする。また、full_scriptは容量が大きいので別collectionに切り出す。
- 切り出した情報は、必要な時に$lookupでjoinする形にする
- ただし、collectionを増やすと、クエリ時にdbとアプリの間で複数回通信が生じるケースが生じる
Patterns (part 2)
Computed pattern
- read heavyなドキュメントで、よくある処理をあらかじめ計算しておくことでパフォーマンスを上げる
- 数学処理
- 複数ドキュメントのフィールドの和や平均値などの取得
- write処理時に、和などを計算しておき別ドキュメントに入れておく
- e.g., movie documentの合計viewer数fieldを、write時にあらかじめ計算しておく
- fan out処理:
- read時に、複数のdocumentを読まないといけない(=fan out on reads)のを、write時に複数のdocumentに書き込む(=fan ount on writes)にすることで、read時のオペレーション回数を減らす
- e.g., 写真snsで、写真を誰かがアップロードした時に、その写真情報を全followerのhome page documentにfan outして保存しておく
- roll-up処理
- roll-up: 複数のドキュメントをグルーピングしてまとめること。(e.g., 日次のデータをまとめて月毎の統計値にする)。一部の数学処理もroll-upの一種
- 数学処理と同様に、roll-upしたものを別documentに保存しておくことができる
- 数学処理
- いつcomputed patternを使うか
- CPUが逼迫している、readに時間がかかる
- ただし、computed patternを使いすぎるとアプリケーションコードが複雑になるため濫用注意
Bucket pattern
- documentのサイズは大きすぎても小さすぎて(document数が多すぎて)もダメ
- e.g., IoTデバイスから送られてくるデータ
- 1 device = 1 document (embedding)だと、時がたつにつれてdocumentが肥大化、16 MB上限に達する恐れ。逆に1送信データ = 1 document (referencing)だと数が多すぎて管理できなくなる
- そこで、1 device / 1 day の単位でdocumentにし、データはその中でarrayで持たせると、ちょうど良い粒度のdocumentができるかもしれない。
- 1 device / 1 dayなのか 1 hourなのかはクエリのユースケースやワークロードによる
- このように、データを適切な粒度でグルーピングし、arrayにしてdocumentにするのがbucket pattern。
- e.g., IoTデバイスから送られてくるデータ
- ただし、バケット単位以外の処理が難しくなる。(e.g., ランダムアクセス、bucketを超えて値をソートする)
Schema versioning pattern
- スキーマの変更に伴うマイグレーションを容易にする
- documentのフィールドとして、schema_versionを持たせることにより、そのドキュメントがどのバージョンのスキーマなのかを特定できる。
- アプリの方で、documentのバージョンを見て、バージョンごとにハンドリングするロジックを書く
- documentのライフサイクル
- 新しいドキュメント: 最新のスキーマバージョンで作る
- 古いドキュメントは下記2通りのやり方がある。どちらでも良い
-
- 古いのは古いまま残し、更新されるものだけ最新のバージョンにする
-
- マイグレーションバッチ走らせて全部新しくする。ただし、古いものが部分的に残っててもアプリは動くので安心。
-
- これの欠点として、indexを貼ってるフィールドがversionごとに違う場合、複数のバージョンごとにindexを貼らないといけないため、冗長な感じになることがある
Tree patterns
- 木構造のモデリング。
- 木構造に対するクエリのユースケースで一般的なのは下記4つ。ユースケースに沿ったモデリングをする。
- ノードの全ての祖先を列挙
- ノードの直接の子を列挙
- ノードの全ての子孫を列挙
- ノードの全ての子孫の親を別のノードにする
- mongo的には、単一documentで木の全てを表現することもできるが、一般的な4つのパターンがある
- parent references
- 直接のparentIdをフィールドに持つ
- ユースケース2,4は簡単。1, 3は、lookupやiterateすればいける
- child references
- childrenのidのarrayをフィールドに持つ
- ユースケース3は簡単。その他は複雑
- array of ancestors
- 全ての祖先のidのarrayをフィールドに持つ
- ユースケース1, 2, 3は簡単。4はiterate必要(?)
- materialized paths
- array of ancestorsと似てるが、idをarrayではなくドット区切りのstringとかで持つ
- ユースケース1は簡単。2, 3, 4は非効率だったり工夫必要だったりする
- 複数のパターンを組み合わせるのも全然OK
- 例えば、mongo公式のECアプリでは、parent referencesとarray of ancestorsを組み合わせている。
Polymorphic pattern
- 概念は似ているが、持っているフィールドが違うオブジェクト群を一つのcollectionで扱う。(課題感としてはattribute patternに近い)
- ドキュメントに、"type"的なフィールドを持たせ、それによって持たせるフィールドを変える。アプリケーションコードは、typeを見て処理を分岐させる。
- e.g., vehicles collection。vehicle_type: carの場合は、wheelsフィールドがあるが、vehicle_type: boatの場合はない。wheelsフィールドの有無は、vehicle_typeで判断する。
- e.g., people collection。サブドキュメントとしてaddressを持っているが、国ごとにaddressのfieldは違う。addressのcountryフィールドで、addressのフィールドを判定する。
- schema versioning patternもpolymorphic patternの一種。色々なパターンがpolymorphitc patternから派生する。
Other patterns
- Approximation pattern
- writeヘビーで、かつそんなに厳密なデータ必要ない場合。複数のwriteをまとめてwriteする
- e.g., サイトのpv数を保存する場合、10 pvごとに1回だけdbに書き込むようにアプリで制御する
- Outlier pattern
- 外れ値をなんとかカバーしようとして、残り99.99%のユースケースに対して微妙な設計を考えてしまう開発者は多い
- 外れ値は、アプリ側で扱うようにしてしまう
- e.g., snsで、1億人のフォロワーのユーザーのために最適化された設計を考えるのではなく、一定数のフォロワーを超えたユーザーのモデルには、has_over_flow_extras: true, number_extras: 1000みたいな追加のフィールドを加えて特別扱いする