MongoDB - 外部キー

提供: MochiuWiki : SUSE, EC, PCB

概要

MongoDBは、ドキュメント指向NoSQLデータベースであり、リレーショナルデータベース管理システム (RDBMS) のような外部キー制約をネイティブにサポートしていない。

NoSQLデータベースは、柔軟性とスケーラビリティを重視した設計思想に基づいており、厳格なスキーマや参照整合性の制約よりも、高速なデータアクセスと水平スケーリングを優先する。
そのため、MongoDBでは外部キー制約による自動的な参照整合性の保証は提供されていない。

しかし、MongoDBには参照関係を管理するための複数のアプローチが存在する。
手動参照 (Manual References)、DBRefs、埋め込みドキュメント (Embedded Documents) といった方法を使用して、コレクション間の関連性を表現できる。

これらのアプローチでは、参照整合性の管理はアプリケーション層の責任となる。
開発者は、データモデリング時に読み取りパターン、更新頻度、データの関係性を考慮して、適切な設計を選択する必要がある。


MongoDBにおける参照の種類

MongoDBでドキュメント間の関連性を表現する主な方法は以下の3種類である。

MongoDBにおける参照の種類
参照方法 説明
手動参照 (Manual References) 最も推奨される標準的な方法である。
参照先ドキュメントのObjectIdをフィールドに格納する。

シンプルで柔軟性が高く、パフォーマンスも良好である。
DBRefs 複数のデータベースやコレクションをまたいだ参照が必要な場合に使用する。
$ref$id$db の3つのフィールドを持つ特殊な形式である。

手動参照よりも冗長であるため、特別な理由がない限り推奨されない。
埋め込みドキュメント (Embedded Documents) 参照の代わりに、関連データを同一ドキュメント内に埋め込む方法である。
データの読み取り頻度が高く、関連データが常に一緒に取得される場合に有効である。

データの重複や更新の複雑さが増す可能性がある。



手動参照の基本構文

手動参照は、参照先ドキュメントの _idフィールド (通常は、ObjectId) を別のドキュメントに格納する方法である。

ユーザと投稿の1対多の関係

usersコレクション と postsコレクションの例を以下に示す。

 // usersコレクション
 {
    _id: ObjectId("507f1f77bcf86cd799439011"),
    name: "山田太郎",
    email: "yamada@example.com"
 }
 
 // postsコレクション
 {
    _id: ObjectId("507f191e810c19729de860ea"),
    title: "MongoDBの参照について",
    content: "MongoDBでは手動参照が推奨されます。",
    author_id: ObjectId("507f1f77bcf86cd799439011"),  // usersコレクションへの参照
    created_at: ISODate("2024-01-15T10:30:00Z")
 }


参照先ドキュメントの取得

参照先のドキュメントを取得するには、2回のクエリを実行する必要がある。

 // 1. 投稿を取得
 const post = db.posts.findOne({_id: ObjectId("507f191e810c19729de860ea")});
 
 // 2. 投稿の著者情報を取得
 const author = db.users.findOne({_id: post.author_id});
 
 // 結果の結合
 console.log(`タイトル: ${post.title}`);
 console.log(`著者: ${author.name}`);



$lookup集計パイプライン

$lookup ステージは、MongoDBの集計パイプラインで提供されるステージであり、SQLのJOINに相当する機能を提供する。
異なるコレクションのドキュメントを結合して、1回のクエリで関連データを取得できる。

基本構文

$lookup ステージは、以下の4つの主要なフィールドを持つ。

$lookup ステージの主要フィールド
フィールド 説明
from 結合する対象のコレクション名を指定する。
localField 現在のコレクションで結合に使用するフィールドを指定する。
foreignField 結合先コレクションで結合に使用するフィールドを指定する。
as 結合結果を格納する新しい配列フィールドの名前を指定する。


 db.コレクション名.aggregate([
    {
       $lookup: {
          from: "結合先コレクション名",
          localField: "ローカルフィールド名",
          foreignField: "外部フィールド名",
          as: "結果フィールド名"
       }
    }
 ])


具体的な使用例

以下の例では、投稿と著者情報を結合している。

 // postsコレクションとusersコレクションを結合
 db.posts.aggregate([
    {
       $lookup: {
          from: "users",              // usersコレクションと結合
          localField: "author_id",    // postsのauthor_idフィールド
          foreignField: "_id",        // usersの_idフィールド
          as: "author_details"        // 結果をauthor_details配列に格納
       }
    },
    {
       $unwind: "$author_details"     // 配列を展開して単一オブジェクトに変換
    },
    {
       $project: {                    // 必要なフィールドのみを選択
          title: 1,
          content: 1,
          "author_details.name": 1,
          "author_details.email": 1
       }
    }
 ])


出力例を以下に示す。

 {
    _id: ObjectId("507f191e810c19729de860ea"),
    title: "MongoDBの参照について",
    content: "MongoDBでは手動参照が推奨されます。",
    author_details: {
       name: "山田太郎",
       email: "yamada@example.com"
    }
 }


パフォーマンス最適化

$lookup ステージのパフォーマンスを向上させるには、結合に使用するフィールドにインデックスを作成することが重要である。

 // author_idフィールドにインデックスを作成
 db.posts.createIndex({author_id: 1});
 
 // _idフィールドはデフォルトでインデックスが存在する



DBRefsの構文

DBRefsは、MongoDBが提供する参照の標準化された形式であり、$ref$id、オプションで $db の3つのフィールドを持つ。

DBRef形式の説明

DBRefは、以下に示す構造を持つドキュメントである。

DBRefのフィールド
フィールド 説明
$ref 参照先のコレクション名を指定する。
$id 参照先ドキュメントの _id フィールドの値を指定する。
$db 参照先のデータベース名を指定する。(オプション)
異なるデータベースを参照する場合に使用する。


 {
    $ref: "コレクション名",
    $id: ObjectId("参照先のID"),
    $db: "データベース名"  // オプション
 }


使用例

以下の例では、投稿ドキュメントでDBRefを使用して著者を参照している。

 // postsコレクションのドキュメント
 {
    _id: ObjectId("507f191e810c19729de860ea"),
    title: "DBRefsの使用例",
    content: "DBRefsは標準化された参照形式です。",
    author: {
       $ref: "users",                              // usersコレクションを参照
       $id: ObjectId("507f1f77bcf86cd799439011"),  // 参照先のObjectId
       $db: "myDatabase"                           // データベース名 (オプション)
    },
    created_at: ISODate("2024-01-15T10:30:00Z")
 }
 
 // MongoDBドライバを使用した参照の解決
 // 注意: MongoDBシェルではDBRefの自動解決はサポートされていない
 //       そのため、アプリケーションコードで手動で解決する必要がある
 
 const post = db.posts.findOne({_id: ObjectId("507f191e810c19729de860ea")});
 const authorRef = post.author;
 const author = db[authorRef.$ref].findOne({_id: authorRef.$id});


使用すべきシーンと制限事項

DBRefsの使用が推奨される場合は、以下の通りである。

  • 複数のデータベースを跨いだ参照が必要な場合
  • 参照先のコレクション名を動的に変更する可能性がある場合
  • 開発チームで参照の形式を統一する場合


DBRefsの制限事項は、以下の通りである。

  • MongoDBシェルでは、DBRefの自動解決がサポートされていない。
  • 手動参照よりもストレージ容量を多く消費する。
  • クエリのパフォーマンスが手動参照と比較して若干低下する可能性がある。
  • $lookupとの統合が手動参照ほどスムーズではない。


手動参照との違い

手動参照とDBRefsの主な違いは、以下の通りである。

手動参照とDBRefsの比較
項目 手動参照 DBRefs
記述方法 author_id: ObjectId("...") author: {$ref: "users", $id: ObjectId("...")}
ストレージ効率 高い (ObjectIdのみ格納) 低い (コレクション名も格納)
データベース間参照 サポートしない サポートする
$db フィールド
$lookup との統合 容易 やや複雑
推奨度 高い (標準的な方法) 低い (特殊なケースのみ)



埋め込みドキュメント vs 参照

MongoDBでは、関連データを表現する際に、埋め込みドキュメントと参照のどちらを選択するかが重要な設計上の決定となる。

選択基準

下表は、埋め込みドキュメントと参照の選択基準をまとめたものである。

埋め込みドキュメントと参照の選択基準
条件 埋め込みドキュメント 参照
データの読み取り頻度 常に一緒に読み取られる 別々に読み取られることが多い
データの更新頻度 低い (更新が少ない) 高い (頻繁に更新される)
データサイズ 小さい (数KB以下) 大きい (数十KB以上)
関連データの独立性 低い (親なしでは意味がない) 高い (独立して存在する)
1対1の関係 推奨 場合による
1対多の関係 多側のデータが少ない場合 多側のデータが多い場合
多対多の関係 非推奨 推奨


埋め込みドキュメントの例

以下の例では、ユーザと住所の1対1の関係で、住所を埋め込んでいる。

 // 埋め込みドキュメント (推奨)
 {
    _id: ObjectId("507f1f77bcf86cd799439011"),
    name: "山田太郎",
    email: "yamada@example.com",
    address: {                    // 住所を埋め込み
       street: "東京都渋谷区1-2-3",
       city: "東京都",
       postal_code: "150-0001",
       country: "日本"
    }
 }


参照の例

以下の例では、ブログ投稿とコメントの1対多の関係で、参照を使用している。

 // postsコレクション
 {
    _id: ObjectId("507f191e810c19729de860ea"),
    title: "MongoDBの設計パターン",
    content: "埋め込みと参照の使い分けが重要です。",
    author_id: ObjectId("507f1f77bcf86cd799439011")
 }
 
 // commentsコレクション (参照を使用)
 {
    _id: ObjectId("507f1f77bcf86cd799439012"),
    post_id: ObjectId("507f191e810c19729de860ea"),  // 投稿への参照
    author_id: ObjectId("507f1f77bcf86cd799439013"),
    content: "とても参考になりました。",
    created_at: ISODate("2024-01-15T11:00:00Z")
 }


ハイブリッドアプローチ

場合によっては、埋め込みと参照を組み合わせる方法も有効である。

 // 投稿に最新の3件のコメントを埋め込み、全コメントは別コレクションで管理
 {
    _id: ObjectId("507f191e810c19729de860ea"),
    title: "MongoDBの設計パターン",
    content: "埋め込みと参照の使い分けが重要です。",
    author_id: ObjectId("507f1f77bcf86cd799439011"),
    recent_comments: [            // 最新3件を埋め込み
       {
          author: "佐藤花子",
          content: "とても参考になりました。",
          created_at: ISODate("2024-01-15T11:00:00Z")
       },
       {
          author: "鈴木一郎",
          content: "もっと詳しく知りたいです。",
          created_at: ISODate("2024-01-15T11:30:00Z")
       }
    ],
    total_comments: 15            // 全コメント数
 }
 
 // 全コメントはcommentsコレクションで管理



スキーマバリデーション

MongoDBでは、スキーマバリデーション機能を使用して、ドキュメントのデータ型や構造を検証できる。
ただし、RDBMSの外部キー制約のような参照整合性を強制することはできない。

JSON Schemaによるバリデーション

JSON Schemaを使用して、フィールドのデータ型やフォーマットを検証することができる。

 // postsコレクションにバリデーションを設定
 db.createCollection("posts", {
    validator: {
       $jsonSchema: {
          bsonType: "object",
          required: ["title", "content", "author_id"],
          properties: {
             title: {
                bsonType: "string",
                description: "タイトルは必須の文字列です"
             },
             content: {
                bsonType: "string",
                description: "内容は必須の文字列です"
             },
             author_id: {
                bsonType: "objectId",
                description: "著者IDは必須のObjectIdです"
             },
             created_at: {
                bsonType: "date",
                description: "作成日時はDate型です"
             }
          }
       }
    },
    validationLevel: "strict",    // 全ての挿入・更新でバリデーションを実行
    validationAction: "error"     // バリデーション失敗時はエラーを返す
 })


既存コレクションへのバリデーション追加

既存のコレクションにバリデーションを追加するには、collMod コマンドを使用する。

 // 既存のpostsコレクションにバリデーションを追加
 db.runCommand({
    collMod: "posts",
    validator: {
       $jsonSchema: {
          bsonType: "object",
          required: ["title", "content", "author_id"],
          properties: {
             title: {
                bsonType: "string"
             },
             content: {
                bsonType: "string"
             },
             author_id: {
                bsonType: "objectId"
             }
          }
       }
    },
    validationLevel: "moderate",  // 既存ドキュメントは検証しない
    validationAction: "warn"      // バリデーション失敗時は警告を記録
 })


参照整合性を強制できない限界

MongoDBのスキーマバリデーションには、以下に示すように限界がある。

  • author_id フィールドがObjectId型であることは検証できるが、その値が実際に users コレクションに存在するかは検証できない。
  • 参照先のドキュメントが削除されても、参照元のドキュメントは自動的に削除されない。(CASCADE削除はサポートされない)
  • 参照の整合性は、アプリケーション層で管理する必要がある。


バリデーションの例を以下に示す。

 // この挿入は成功する (ObjectId型のため)
 db.posts.insertOne({
    title: "テスト投稿",
    content: "テスト内容",
    author_id: ObjectId("000000000000000000000000")  // 存在しないIDでも挿入可能
 })
 
 // この挿入は失敗する (文字列型のため)
 db.posts.insertOne({
    title: "テスト投稿",
    content: "テスト内容",
    author_id: "invalid-id"  // バリデーションエラー
 })



参照整合性の管理

MongoDBでは、参照整合性はアプリケーション層で管理する必要がある。

アプリケーション層での管理方法

参照整合性を保つために、アプリケーションコードで以下に示すような処理を定義する。

 // Node.jsでの例: 投稿作成時に著者の存在を確認
 
 async function createPost(title, content, authorId) {
    // 1. 著者が存在するか確認
    const author = await db.collection('users').findOne({_id: authorId});
    if (!author) {
       throw new Error('指定された著者が存在しません');
    }
 
    // 2. 投稿を作成
    const result = await db.collection('posts').insertOne({
       title: title,
       content: content,
       author_id: authorId,
       created_at: new Date()
    });
 
    return result;
 }


削除時の考慮事項

参照されているドキュメントを削除する場合は、孤立参照を防ぐための対策が必要である。

 // CASCADE削除の定義例
 
 async function deleteUser(userId) {
    // 1. ユーザに関連する投稿を削除
    await db.collection('posts').deleteMany({author_id: userId});
 
    // 2. ユーザに関連するコメントを削除
    await db.collection('comments').deleteMany({author_id: userId});
 
    // 3. ユーザを削除
    await db.collection('users').deleteOne({_id: userId});
 }
 
 // RESTRICT削除の定義例
 async function deleteUserRestrict(userId) {
    // 1. ユーザに関連する投稿があるか確認
    const postCount = await db.collection('posts').countDocuments({author_id: userId});
    if (postCount > 0) {
       throw new Error('このユーザには投稿があるため削除できません');
    }
 
    // 2. 投稿がなければユーザを削除
    await db.collection('users').deleteOne({_id: userId});
 }
 
 // SET NULLの定義例 (MongoDBではフィールド削除またはnull設定)
 async function deleteUserSetNull(userId) {
    // 1. ユーザに関連する投稿のauthor_idをnullに設定
    await db.collection('posts').updateMany(
       {author_id: userId},
       {$set: {author_id: null}}
    );
 
    // 2. ユーザを削除
    await db.collection('users').deleteOne({_id: userId});
 }


更新時の考慮事項

参照先のIDを更新する場合は、参照元のドキュメントも更新する必要がある。

 // ユーザIDを変更する場合の例 (非推奨: ObjectIdは通常変更しない)
 async function updateUserId(oldUserId, newUserId) {
    // 1. 新しいユーザドキュメントを作成
    const oldUser = await db.collection('users').findOne({_id: oldUserId});
    await db.collection('users').insertOne({
       _id: newUserId,
       ...oldUser
    });
 
    // 2. 参照元のドキュメントを更新
    await db.collection('posts').updateMany(
       {author_id: oldUserId},
       {$set: {author_id: newUserId}}
    );
 
    await db.collection('comments').updateMany(
       {author_id: oldUserId},
       {$set: {author_id: newUserId}}
    );
 
    // 3. 古いユーザドキュメントを削除
    await db.collection('users').deleteOne({_id: oldUserId});
 }


Change Streamsを使った監視

MongoDB 3.6以降では、Change Streamsを使用してドキュメントの変更をリアルタイムで監視できる。
これを利用して、参照整合性を保つための処理を定義することができる。

 // ユーザ削除時に関連投稿を自動削除する例
 const changeStream = db.collection('users').watch([
    {$match: {'operationType': 'delete'}}
 ]);
 
 changeStream.on('change', async (change) => {
    const deletedUserId = change.documentKey._id;
 
    // 削除されたユーザに関連する投稿を削除
    await db.collection('posts').deleteMany({author_id: deletedUserId});
 
    console.log(`ユーザ ${deletedUserId} の関連投稿を削除しました`);
 });



推奨される事柄

読み取りパターンに基づいたデータモデリング

MongoDBのデータモデリングは、読み取りパターンを最優先に考える。

  • データがどのように読み取られるかを分析する
    常に一緒に読み取られるデータは埋め込む。
    別々に読み取られるデータは参照にする。

  • クエリの頻度を考慮する
    頻繁に実行されるクエリを最適化する。
    JOIN操作 ($lookup ステージ) の回数を最小限に抑える。

  • データの一貫性よりも読み取り性能を優先する
    一部のデータ重複は許容する。
    非正規化によって読み取り性能を向上させる。


インデックスの活用

参照フィールドには必ずインデックスを作成する。

 // 参照フィールドにインデックスを作成
 db.posts.createIndex({author_id: 1});
 db.comments.createIndex({post_id: 1});
 db.comments.createIndex({author_id: 1});
 
 // 複合インデックスの活用
 db.posts.createIndex({author_id: 1, created_at: -1});


埋め込みと参照の適切な使い分け

以下に示すような基準で埋め込みと参照を使い分ける。

  • 埋め込みを選択する場合
    1対1の関係で、常に一緒に読み取られるデータ
    1対少数の関係で、子データのサイズが小さい場合
    データの更新頻度が低い場合
    ドキュメント全体のサイズが16[MB]を超えない場合

  • 参照を選択する場合
    1対多の関係で、多側のデータが多数存在する場合
    多対多の関係
    データが独立して存在する意味を持つ場合
    データの更新頻度が高い場合


非正規化のトレードオフ

MongoDBでは、読み取り性能を向上させるために、意図的にデータを重複させる非正規化が推奨される場合がある。

 // 非正規化の例: 投稿に著者名を埋め込む
 
 {
    _id: ObjectId("507f191e810c19729de860ea"),
    title: "MongoDBの設計パターン",
    content: "非正規化によって読み取り性能を向上させます。",
    author_id: ObjectId("507f1f77bcf86cd799439011"),
    author_name: "山田太郎",  // 非正規化: 著者名を重複させる
    created_at: ISODate("2024-01-15T10:30:00Z")
 }


非正規化のメリットとデメリットは、以下の通りである。

  • メリット
    • 読み取りクエリが高速化される。(JOINが不要)
    • $lookup ステージの使用を避けられる。
    • アプリケーションコードが簡潔になる。

  • デメリット
    • データの更新時に複数箇所を更新する必要がある。
    • ストレージ容量が増加する。
    • データの一貫性を保つための注意が必要である。


非正規化を採用する場合は、以下に示す事柄を考慮する。

  • 重複させるデータは更新頻度が低いものを選ぶ。
  • 更新時には、関連する全てのドキュメントを更新する処理を定義する。
  • 定期的なデータ整合性チェックを行う。