MongoDBインデックスを極めてクエリパフォーマンスを最適化する

MongoDBのインデックス技術を習得して、最適なクエリパフォーマンスを実現しましょう。この包括的なガイドでは、単一フィールド、複合、マルチキーインデックスについて解説し、カバリングクエリの威力を説明し、`explain()`を使用したパフォーマンス分析の方法を案内します。読み取り操作を高速化し、データベースの負荷を軽減し、より応答性の高いアプリケーションを構築するためのベストプラクティスを学びます。パフォーマンスチューニングに注力するMongoDB開発者にとって必読の内容です。

MongoDBインデックスを極めてクエリパフォーマンスを最適化する

MongoDBのインデックスが面白くなるのは、データベースがもはや運任せで済むほど小さくなくなったときです。開発中は一瞬だったクエリが、コレクションに数百万のドキュメントが存在し、ダッシュボードがソートを追加し、APIエンドポイントが複数のフィールドでフィルタリングを始めると、本番環境で苦痛になることがあります。

目標はすべてのフィールドにインデックスを作成することではありません。それは通常、書き込みを遅くし、メモリとディスクを消費し、重要なクエリをカバーできないままにします。目標は、アプリケーションが実際に依存する少数のクエリ形状を理解し、それらの形状に一致するインデックスを構築することです。

MongoDBインデックスの理解

その核心において、インデックスは本の索引のようなものです。トピックを見つけるために本全体を読む代わりに、ソートされた参照を調べて目的のページにジャンプします。MongoDBのインデックスは、クエリプランナーがコレクション全体をスキャンせずに一致するドキュメントを見つけるのに役立ちます。有用なインデックスがない場合、MongoDBはコレクションスキャンを実行し、一致するものを見つけるまでドキュメントを一つずつ調べることがあります。

コレクションスキャンは常に悪いわけではありません。小さなコレクションのスキャンは問題ないかもしれません。月に一度の管理レポートの実行も問題ないかもしれません。しかし、高トラフィックのリクエストパス内でのコレクションスキャンは別です。それは通常の読み取りや書き込みと競合し、データが増えるにつれて悪化し、予測不可能なレイテンシとして現れることがよくあります。

インデックスの仕組み

MongoDBは通常のフィールドインデックスにB-treeスタイルのインデックスを一般的に使用します。重要な実用的な詳細は、インデックス化された値が順序付けられて保存されることです。この順序付けにより、クエリ形状がインデックスと一致する場合、MongoDBは等価フィルター、範囲フィルター、ソートを効率的に処理できます。

例えば、{ email: 1 }のインデックスは以下に最適です:

db.users.findOne({ email: "[email protected]" })

しかし、以下には役立ちません:

db.users.find({ lastLoginAt: { $lt: ISODate("2025-01-01") } })

2番目のクエリはlastLoginAtで始まるインデックスが必要か、スキャンが必要です。

インデックスを使用するタイミング

インデックスは、以下のような頻繁に使用されるフィールドに最も効果的です:

  • クエリ条件(find()findOne()): クエリのfilterドキュメントで使用されるフィールド。
  • ソート条件(sort()): クエリ結果の順序付けに使用されるフィールド。
  • _idフィールド: デフォルトで、MongoDBは_idフィールドにインデックスを作成し、一意性とIDによる高速なルックアップを保証します。

ただし、インデックスにはコストもあります:

  • ストレージスペース: インデックスはディスク容量を消費します。
  • 書き込みパフォーマンス: ドキュメントが挿入、更新、削除されるたびにインデックスを更新する必要があり、書き込み操作が遅くなる可能性があります。
  • メモリプレッシャー: 頻繁に使用されるインデックスページはキャッシュを競合します。大きすぎるインデックスが多すぎると、ワーキングセットをメモリに保持することが難しくなります。

したがって、一般的な読み取り操作で最もパフォーマンス向上が見込めるフィールドに焦点を当てて、戦略的にインデックスを作成することが重要です。

インデックスの作成と管理

MongoDBは、インデックスを作成するためのcreateIndex()メソッドと、既存のインデックスを表示するためのgetIndexes()メソッドを提供します。インデックスを削除するにはdropIndex()メソッドを使用します。

基本的なインデックス作成

単一フィールドインデックスを作成するには、フィールド名とインデックスタイプ(通常は昇順に1、降順に-1)を指定します。

db.collection.createIndex( { fieldName: 1 } );

例: usernameフィールドを昇順でインデックス化:

db.users.createIndex( { username: 1 } );

インデックスの表示

コレクションのインデックスを表示するには:

db.collection.getIndexes();

例: usersコレクションのインデックスを表示:

db.users.getIndexes();

これにより、デフォルトの_idインデックスを含むインデックス定義の配列が返されます。

ビジーなコレクションでは、意図的にインデックスを作成します。最新のMongoDBバージョンは多くの一般的なケースでオンラインインデックスビルドをサポートしていますが、インデックスビルドは依然としてCPU、ディスクI/O、メモリを消費します。本番システムでは、大規模なインデックスビルドを静かな時間帯にスケジュールし、レプリカセットを実行している場合はレプリケーションラグを監視してください。

インデックスの削除

インデックスを削除するには:

db.collection.dropIndex( "indexName" );

indexNamegetIndexes()の出力から見つけることができます。または、createIndex()と同じ形式でインデックス化されたフィールドを指定してインデックスを削除することもできます:

db.collection.dropIndex( { fieldName: 1 } );

例: usernameインデックスを削除:

db.users.dropIndex( "username_1" ); // インデックス名を使用
// または
db.users.dropIndex( { username: 1 } ); // インデックス定義を使用

インデックスを削除する前に、まだ使用されているかどうかを確認してください:

db.users.aggregate([{ $indexStats: {} }])

これにより、サーバー起動以降のアクセスカウンターが表示されます。カウンターがゼロの場合は手がかりになりますが、絶対的な証拠ではありません。サーバーが最近再起動したか、クエリが週次のジョブでのみ実行される可能性があります。重要なシステムでは、$indexStats、アプリケーションコードの検索、クエリログ、および短い観察期間を組み合わせてください。

複合インデックス

複合インデックスは複数のフィールドを含みます。複合インデックスにおけるフィールドの順序は重要です。MongoDBは、filtersort句に複数のフィールドが含まれるクエリに対して複合インデックスを使用します。

複合インデックスを使用するタイミング

複合インデックスは、クエリが頻繁にフィールドの組み合わせでフィルタリングやソートを行う場合に最も効果的です。インデックスは、インデックスで定義された順序と同じ順序のフィールド、またはインデックスのプレフィックスに一致するクエリを満たすことができます。

例: userIdorderDatestatusのようなフィールドを持つordersコレクションを考えます。特定のユーザーの注文を頻繁にクエリし、日付でソートする場合、{ userId: 1, orderDate: 1 }の複合インデックスが非常に有益です。

db.orders.createIndex( { userId: 1, orderDate: 1 } );

このインデックスは、以下のようなクエリを効率的にサポートできます:

  • db.orders.find( { userId: "user123" } ).sort( { orderDate: -1 } )
  • db.orders.find( { userId: "user123", orderDate: { $lt: ISODate() } } )

ただし、userIdも指定されていない場合にorderDateのみでフィルタリングするクエリや、フィールドの順序が異なる場合には、それほど効果的ではないかもしれません。

フィールドの順序が重要

複合インデックスにおけるフィールドの順序は、どのクエリパターンをうまくサポートできるかを決定します。経験則として、等価フィールドを最初に、次にソートフィールド、最後に範囲フィールドを配置します。これはESRガイドライン(等価、ソート、範囲)と呼ばれることがよくあります。これはガイドラインであり、法則ではありませんが、多くの悪いインデックス設計を防ぎます。

あなたの注文ページが次のクエリを実行するとします:

db.orders.find({
  tenantId: "t1",
  status: "paid",
  createdAt: { $gte: ISODate("2025-01-01") }
}).sort({ createdAt: -1 })

適切なインデックスは次のようになります:

db.orders.createIndex({ tenantId: 1, status: 1, createdAt: -1 })

tenantIdstatusは等価フィルターです。createdAtはソートと範囲をサポートします。代わりに{ createdAt: -1, status: 1, tenantId: 1 }を作成した場合、MongoDBは一部のケースでそれを使用するかもしれませんが、通常はこのクエリにあまり適合しません。

結果をソートするクエリの場合、インデックスのフィールド順序は、最適なパフォーマンスを得るためにsort()操作のフィールド順序と一致する必要があります。クエリにフィルターとソートの両方が含まれ、インデックスがフィルターフィールドに一致する場合、ソート用の別のコレクションスキャンなしでソートにも使用できます。

複合インデックスはプレフィックスクエリにも対応できます。{ tenantId: 1, status: 1, createdAt: -1 }のインデックスは、tenantIdのみ、またはtenantIdstatusのクエリに役立ちます。statusが先頭フィールドではないため、statusのみのクエリにはあまり役立ちません。

カバリングクエリ

カバリングクエリとは、MongoDBがインデックスのみを使用してクエリ全体を満たすことができるクエリです。つまり、インデックスにクエリとプロジェクションで使用されるすべてのフィールドが含まれていることを意味します。カバリングクエリは、コレクション自体からドキュメントをフェッチすることを回避するため、非常に高速です。

カバリングクエリを実現する方法

カバリングクエリを実現するには、以下を確認してください:

  1. クエリのフィルターで使用されるすべてのフィールドを含むインデックスがあること。
  2. プロジェクションにそれらのインデックス化されたフィールド(またはそのサブセット)のみを含めること。

例: nameagecityのフィールドを持つemployeesコレクションを考えます。{ city: 1, age: 1 }のインデックスがあり、特定の都市の従業員の名前と年齢を取得したい場合、カバリングクエリを作成できます:

db.employees.find( { city: "New York" }, { name: 1, age: 1, _id: 0 } ).explain()

このクエリでは、cityはインデックスにあり、nameageはプロジェクションに含まれています。インデックスにnameageも含まれていれば、カバリングクエリになります。

真のカバリングクエリのためにインデックスとクエリを改良しましょう:

// クエリとプロジェクションに必要なすべてのフィールドを含むインデックスを作成
db.employees.createIndex( { city: 1, age: 1, name: 1 } );

// これで、cityでフィルタリングし、nameとageをプロジェクションするクエリをカバーできます
db.employees.find( { city: "New York" }, { name: 1, age: 1, _id: 0 } )

このクエリでexplain("executionStats")を実行すると、カバーされたプランは、コレクションから完全なドキュメントをフェッチせずにインデックスキーを調べるはずです。多くのexplainプランでは、FETCHステージなしのIXSCANが表示され、totalDocsExamined0になるはずです。Explainの出力はMongoDBのバージョンやクエリ形状によって異なるため、特定のラベルを探すのではなく、実際のプランステージと検査されたカウントに注目してください。

カバリングクエリは、オートコンプリート、小さなリストビュー、権限チェックなどのホットリードパスに役立ちます。プロジェクションに大きなフィールド、多くのフィールド、または頻繁に変更されるフィールドが含まれる場合、それほど有用ではありません。クエリをカバーするためだけにインデックスにフィールドを追加しすぎると、インデックスが肥大化し、書き込みパフォーマンスが低下する可能性があります。

その他の重要なインデックスタイプ

MongoDBは特定のユースケース向けにさまざまなインデックスタイプを提供しています:

マルチキーインデックス

マルチキーインデックスは、配列フィールドをインデックス化すると自動的に作成されます。これにより、配列内の要素をクエリできます。

例: tags配列フィールド["electronics", "gadgets"]を持つproductsコレクションがある場合:

db.products.createIndex( { tags: 1 } );

このインデックスは、db.products.find( { tags: "electronics" } )のようなクエリをサポートします。

配列は複合インデックスで特別な注意が必要です。マルチキーインデックスは配列要素のエントリを保存するため、インデックスサイズが急速に増加する可能性があります。また、同じドキュメント内で複数のインデックス化されたフィールドが配列を含むことができる場合、MongoDBには複合マルチキーインデックスに関する制限があります。データモデルに複数の配列と複雑なフィルターがある場合は、スカラーフィールドインデックスと同じように動作すると仮定する前に、代表的なデータで正確なクエリをテストしてください。

テキストインデックス

テキストインデックスは、ドキュメント内の文字列コンテンツの効率的な検索をサポートします。$text演算子を使用したテキスト検索クエリに使用されます。

db.articles.createIndex( { content: "text" } );

これにより、db.articles.find( { $text: { $search: "database performance" } } )のような検索が可能になります。

テキストインデックスは基本的なテキスト検索に役立ちますが、完全な検索プラットフォームではありません。高度な関連性チューニング、タイポ許容、ファセット、ハイライト、言語固有の検索動作が必要な場合は、MongoDB Atlas Searchや専用の検索エンジンの方が適しているかもしれません。

地理空間インデックス

地理空間インデックスは、$near$geoWithin$geoIntersects演算子を使用した地理データの効率的なクエリに使用されます。

db.locations.createIndex( { loc: "2dsphere" } ); // 2dsphereインデックスの場合

ユニークインデックス

ユニークインデックスは、フィールドまたはフィールドの組み合わせに一意性を強制します。重複する値が挿入または更新されると、MongoDBはエラーを返します。

db.users.createIndex( { email: 1 }, { unique: true } );

本番ユーザーテーブルでは、一意性を強制する前に正規化してください。メールアドレスは一般的な例です。アプリケーションが[email protected][email protected]を同じユーザーとして扱う場合は、emailLowerのような正規化されたフィールドを保存し、そこにユニークインデックスを配置してください。同時実行下での重複を防ぐためにアプリケーションコードだけに依存しないでください。

部分インデックス

部分インデックスは、フィルター式に一致するドキュメントのみをインデックス化します。クエリがコレクションのサブセットに焦点を当てている場合に役立ちます。

db.orders.createIndex(
  { tenantId: 1, createdAt: -1 },
  { partialFilterExpression: { status: "open" } }
)

これは、アプリケーションが頻繁にオープンな注文を読み取り、クローズされた注文がコレクションの大部分を占める場合に役立ちます。部分フィルターに一致しないドキュメントを除外するため、インデックスは小さくなります。MongoDBがそれを使用するには、クエリに互換性のある条件を含める必要があります。

TTLインデックス

TTLインデックスは、設定された時間が経過すると自動的にドキュメントを削除します。セッション、一時トークン、短期間のイベントに一般的に使用されます。

db.sessions.createIndex(
  { expiresAt: 1 },
  { expireAfterSeconds: 0 }
)

TTL削除は正確な有効期限時に即座に行われるわけではありません。MongoDBは期限切れのドキュメントをバックグラウンドで削除します。クリーンアップに使用し、トークンが即座に無効になる必要がある正確なセキュリティタイミングには使用しないでください。アプリケーションは読み取り中にも有効期限をチェックする必要があります。

explain()によるパフォーマンス分析

MongoDBがクエリをどのように実行するかを理解することは、クエリを最適化するために重要です。explain()メソッドは、インデックスが使用されたかどうかやその方法を含む、クエリ実行計画に関する洞察を提供します。

db.collection.find( {...} ).explain( "executionStats" );

explain()の出力で確認すべき主要なフィールド:

  • winningPlan.stage:実行計画のステージを示します(例:COLLSCANはコレクションスキャン、IXSCANはインデックススキャン)。
  • executionStats.totalKeysExamined:検査されたインデックスキーの数。
  • executionStats.totalDocsExamined:検査されたドキュメントの数。

良い実行計画では、totalDocsExaminedが返されるドキュメント数に近いか等しく、totalKeysExaminedがコレクション内の総ドキュメント数よりも大幅に少なくなります。totalDocsExaminedが非常に高い場合やCOLLSCANが使用されている場合は、インデックスが欠落しているか、効果的に使用されていないことを示唆しています。

explainプランを読む私の簡単な方法は次のとおりです:

  1. COLLSCANを探します。これがホットパスでコレクションが大きい場合、通常は最初の問題です。
  2. IXSCANの後にFETCHがあるかどうかを確認します。クエリがインデックス外のフィールドを必要とする場合、フェッチは正常ですが、過剰なドキュメント検査はインデックスの選択性が十分でないことを意味します。
  3. nReturnedtotalKeysExaminedtotalDocsExaminedを比較します。25個のキーを検査して20個のドキュメントを返すのは健全です。500,000個のキーを検査して20個のドキュメントを返すのは健全ではありません。
  4. メモリ内ソートに注意してください。MongoDBがフィルタリング後に大きな結果セットをソートする必要がある場合、ソートをサポートする複合インデックスが役立つかもしれません。

テスト時には現実的なフィルターを使用してください。tenantId: "demo"のexplainプランは、数百万のドキュメントを持つ大規模なテナントとは一致しないかもしれません。データ分布が重要です。

実践的なインデックス設計のウォークスルー

ticketsコレクションを持つアプリケーションを想像してください。サポートエージェントは、次のフィルターを持つキュー画面を使用します:

db.tickets.find({
  tenantId: "acme",
  status: "open",
  assigneeId: "u123"
}).sort({ updatedAt: -1 }).limit(50)

フィールドリストではなく、クエリ形状から始めてください。コレクションはマルチテナントで、エージェントは通常ステータスと担当者でフィルタリングし、UIは最新の更新を最初にソートします。実用的なインデックスは次のとおりです:

db.tickets.createIndex({
  tenantId: 1,
  status: 1,
  assigneeId: 1,
  updatedAt: -1
})

次に別の画面を考えます:マネージャーは担当者に関係なく、すべてのオープンチケットを表示します:

db.tickets.find({
  tenantId: "acme",
  status: "open"
}).sort({ updatedAt: -1 }).limit(100)

前のインデックスはプレフィックス{ tenantId, status }を使用できますが、assigneeIdupdatedAtの前にあるため、このマネージャークエリのソートを十分にサポートできない可能性があります。2番目のインデックスが必要になるかもしれません:

db.tickets.createIndex({
  tenantId: 1,
  status: 1,
  updatedAt: -1
})

これは通常のトレードオフです。1つのインデックスですべての画面を完璧にサポートすることはほとんどありません。重要なのは、すべての書き込みにコストがかかる重複したインデックスを大量に作成せずに、重要なパスをサポートすることです。

MongoDBインデックスのベストプラクティス

  • 必要なものだけをインデックス化する: めったにクエリやソートされないフィールドにインデックスを作成しないでください。各インデックスはオーバーヘッドを追加します。
  • 複合インデックスを賢く使用する: クエリパターンに基づいてフィールドを正しく順序付けます。最も選択性の高いフィールドを最初に検討します。
  • カバリングクエリを目指す: 読み取りパフォーマンスが重要な場合は、一般的な読み取り操作をカバーするようにインデックスを設計します。
  • インデックス使用状況を監視する: explain()db.collection.aggregate([{ $indexStats: {} }])を使用して定期的にインデックス使用状況を確認し、未使用または非効率なインデックスを特定します。
  • インデックスの選択性を考慮する: カーディナリティが低い(異なる値が少ない)フィールドのインデックスは、カーディナリティが高いフィールドのインデックスほど効果的ではない場合があります。
  • インデックスを小さく保つ: カバリングクエリにどうしても必要な場合を除き、大きなフィールドや配列をインデックスに含めないでください。
  • インデックスをテストする: 新しいインデックスが読み取りと書き込みの両方のパフォーマンスに与える影響を、現実的な負荷条件下で常にテストしてください。
  • 冗長なインデックスを注意深く削除する: { a: 1, b: 1 }がある場合、別の{ a: 1 }インデックスは多くのワークロードで冗長になる可能性があります。削除する前に使用状況を確認してください。
  • 実際の画面とジョブに基づいて設計する: インデックスはアプリケーションの動作(ログインルックアップ、キュー画面、レポートフィルター、バックグラウンドワーカースキャン)に対応する必要があります。
  • スキーマ変更後に再検討する: 新しいフィールド、新しいソート順、新しいテナントモデルにより、古いインデックスが役に立たなくなることがあります。

良いインデックス設計の感覚

良いMongoDBインデックス設計は通常、静かなものです。重要なクエリは、返すデータ量とほぼ同じ量のデータを検査します。ソートは高コストな作業に波及しません。書き込みは、十数もの推測的なインデックスによって負担をかけられません。新しい機能が新しいクエリ形状を追加するときは、それが本番インシデントになる前にexplain("executionStats")でテストします。

実践的な習慣はシンプルです:実際のクエリを収集し、そのクエリ形状に対して最小限の有用なインデックスを設計し、代表的なデータでテストし、時間の経過とともにインデックス使用状況を確認し続けることです。この習慣は、すべてのインデックスタイプを暗記するよりもMongoDBのパフォーマンスに貢献します。