Open8

MongoDB データモデリングのベストプラクティス

nakaakistnakaakist

https://learn.mongodb.com/courses/introduction-to-mongodb-data-modeling

モデルの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クエリを発行しないといけないなどパフォーマンスの懸念もある

  • モデルの見通しを良くするため、積極的にnestやarrayを使うべき。下記リファクタ例

スキーマアンチパターン

  • 「Data that is accessed together should be sotred together」が基本
  • アンチパターン
    • 際限なくデータが肥大化するモデル(e.g., 記事に対するコメント一覧をembeddingで持つと、コメントが無限に増えていった時に困る)
    • 大量のcollection
    • 不要なインデックス/インデックスが足りない
    • ほぼほぼ同じタイミングでアクセスされるのに、別collectionになってる
  • mongodb atlasでこの辺のアンチパターンは確認できる。
nakaakistnakaakist

https://learn.mongodb.com/learn/course/mongodb-transactions/lesson-1-introduction-to-acid-transactions/learn?client=customer

  • 単一documentに対する操作は全てatomic (e.g., updateOne)
  • 複数documentに対する操作をatomicにするには明示的にtransactionにする必要がある
    • ただし、アプリの要件を考えて、本当に必要なところに慎重に使ったほうがいい
    • なぜなら、mongoは、transactionに含まれるすべてのドキュメントをロックするから。これにより、レイテンシなどパフォーマンスに悪影響を及ぼすことがある (RDBだとembeddingとかができないのでテーブルを分ける必要性が高くtransaction必須だが、mongoではまず一緒に更新するものを単一documentにすることを考えたほうが良い)
  • session: 一緒に実行すべきdb operationをまとめたもの。
  • transactionの最大実行時間は、最初のwriteから1分

参考: (ロックについてもう少し深掘りしたい)
https://www.mongodb.com/community/forums/t/understanding-locking-within-transactions-and-how-it-deals-with-high-concurrency/189518

トランザクション分離レベル

デフォルトではread uncommittedと、弱めの分離レベルにしてパフォーマンスを優先している

https://www.mongodb.com/docs/manual/core/read-isolation-consistency-recency/

nakaakistnakaakist

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されていく
  • ディスクの考慮
    • SSDをなるべく使うと良い。ただし、ほとんど参照されないログとかはHDDを使うのもあり
  • モデルの考慮
    • 上記制約を考えてモデルを作ると良い

The data modeling methodology

  • modelを決めるための3つのフェーズ
    1. ワークロードの特定
      • データ量
      • ops/second (read, write)
      • レイテンシ
      • durability
    2. リレーションの特定
      • 1-to-1 relationshipは基本的に同一document。それ以外をembeddingか、別collectionでreferencingにするか。
    3. デザインパターンの適用
      • よりパフォーマンスよく、見通し良くする

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操作は?: データサイエンティストが発行するクエリ。このクエリではどんなパターンがよく発行されるか?も特定しておく
nakaakistnakaakist

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の方も更新するのをアプリ側で実装する必要がある
  • 一般的な指針
    • 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するしか選択肢がない
  • モデル設計に加えて、クエリするときに特に注意を払うこと
nakaakistnakaakist

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通り
    • アプリケーションコード
    • lookupもしくはgraphLookup
  • それとは別に、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とアプリの間で複数回通信が生じるケースが生じる
nakaakistnakaakist

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., ランダムアクセス、bucketを超えて値をソートする)

Schema versioning pattern

  • スキーマの変更に伴うマイグレーションを容易にする
  • documentのフィールドとして、schema_versionを持たせることにより、そのドキュメントがどのバージョンのスキーマなのかを特定できる。
  • アプリの方で、documentのバージョンを見て、バージョンごとにハンドリングするロジックを書く
  • documentのライフサイクル
    • 新しいドキュメント: 最新のスキーマバージョンで作る
    • 古いドキュメントは下記2通りのやり方がある。どちらでも良い
        1. 古いのは古いまま残し、更新されるものだけ最新のバージョンにする
        1. マイグレーションバッチ走らせて全部新しくする。ただし、古いものが部分的に残っててもアプリは動くので安心。
  • これの欠点として、indexを貼ってるフィールドがversionごとに違う場合、複数のバージョンごとにindexを貼らないといけないため、冗長な感じになることがある

Tree patterns

  • 木構造のモデリング。
  • 木構造に対するクエリのユースケースで一般的なのは下記4つ。ユースケースに沿ったモデリングをする。
    1. ノードの全ての祖先を列挙
    2. ノードの直接の子を列挙
    3. ノードの全ての子孫を列挙
    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みたいな追加のフィールドを加えて特別扱いする