遅いMongoDB集約パイプラインのプロファイリングと最適化方法

MongoDBのパフォーマンスをマスターするために、遅い集約パイプラインを診断する方法を学びます。このガイドでは、MongoDBプロファイラと`.explain('executionStats')`メソッドを有効にして使用し、複雑なステージ内のボトルネックを特定する方法を詳しく説明します。`$match`と`$sort`の最適なインデックス作成、および`$lookup`の効率的な使用に焦点を当て、データ変換を劇的に高速化するための実用的なチューニング戦略を紹介します。

遅いMongoDB集約パイプラインのプロファイリングと最適化方法

MongoDBの集約パイプラインは、一度に1つのステージずつ簡単に成長します。レポートは$matchから始まり、誰かが$lookupを追加し、次に$group、ソートを追加し、6か月後にはエンドポイントが遅くなり、誰も触れたくなくなります。

修正は証拠から始まります。どのステージが読み取りすぎ、展開しすぎ、ソートしすぎ、または結合が遅すぎるかを知る必要があります。MongoDBはその作業のために2つの実用的なツールを提供します。履歴の遅い操作のためのデータベースプロファイラと、1つのパイプラインを詳しく調べるための.explain("executionStats")です。

MongoDBプロファイラを理解する

MongoDBプロファイラは、findupdatedelete、そしてこのガイドで最も重要なaggregateコマンドを含むデータベース操作の実行詳細を記録します。操作にかかった時間、消費したリソース、およびレイテンシに最も貢献したステージを記録します。

プロファイリングレベルの有効化と設定

プロファイリングを行う前に、プロファイラがアクティブであり、必要なデータをキャプチャするレベルに設定されていることを確認する必要があります。プロファイリングレベルは0(オフ)から2(すべての操作をログ)までの範囲です。

レベル 説明
0 プロファイラは無効です。
1 slowOpThresholdMs設定より長い操作をログに記録します。
2 データベースに対して実行されたすべての操作をログに記録します。

プロファイラレベルを設定するには、db.setProfilingLevel()コマンドを使用します。過度なディスクI/Oを避けるために、パフォーマンステスト中は一時的にレベル1または2を使用することをお勧めします。

例:プロファイラをレベル1に設定(100msより遅い操作をログ)

// データベースに接続:use myDatabase
db.setProfilingLevel(1, { slowOpThresholdMs: 100 })

// 設定を確認
db.getProfilingStatus()

ベストプラクティス: 本番システムでプロファイラをレベル2のままにしないでください。すべての操作をログに記録すると、書き込みパフォーマンスに大きな影響を与える可能性があります。

プロファイルされた集約データの表示

プロファイルされた操作は、プロファイリングしているデータベース内のsystem.profileコレクションに保存されます。このコレクションをクエリして、最近の遅い集約を見つけることができます。

遅い集約クエリを見つけるには、opフィールドが'aggregate'で、実行時間(millis)がしきい値を超えている結果をフィルタリングします。

// 過去1時間のすべての遅い集約操作を見つける
db.system.profile.find(
  {
    op: 'aggregate',
    millis: { $gt: 100 } // 100msより遅い操作
  }
).sort({ ts: -1 }).limit(5).pretty()

集約パイプラインの実行詳細の分析

プロファイラからの出力は重要です。遅い集約ドキュメントを調べるときは、特にplanSummary、そしてより重要なのは結果内のstages配列を探してください。

.explain('executionStats')の詳細出力を活用する

プロファイラは履歴データをキャプチャしますが、.explain('executionStats')を使用して集約を実行すると、現在のデータセットでのMongoDBのパイプライン実行方法に関するリアルタイムの詳細情報が得られ、ステージごとのタイミングも含まれます。

Explainを使用した例:

db.collection('sales').aggregate([
  { $match: { status: 'A' } },
  { $group: { _id: '$customerId', total: { $sum: '$amount' } } }
]).explain('executionStats');

出力では、stages配列がパイプライン内の各演算子を詳細に示します。各ステージについて、以下を探します:

  • executionTimeMillis:その特定のステージの実行にかかった時間。
  • nReturned:次のステージに渡されたドキュメントの数。
  • totalKeysExamined / totalDocsExamined:I/Oコストを示すメトリクス。

executionTimeMillisが非常に高いステージ、または返されるよりもはるかに多くのドキュメント(totalDocsExamined)を調べているステージは、主要な最適化ターゲットです。

遅い集約ステージを最適化する戦略

プロファイリングがボトルネックのステージ(例:$match$lookup、またはソートステージ)を特定したら、ターゲットを絞った最適化手法を適用できます。

1. 初期フィルタリングの最適化($match

$matchステージは、可能であれば常にパイプラインの最初のステージにする必要があります。早期にフィルタリングすることで、後続のリソースを大量に消費するステージ($group$lookupなど)が処理する必要があるドキュメントの数が減ります。

インデックスの役割: 初期の$matchステージが遅い場合、ほぼ確実にフィルタで使用されるフィールドにインデックスがありません。$matchで使用されるフィールドをカバーするインデックスがあることを確認してください。

$matchステージにインデックスがないフィールドが含まれている場合、ステージはコレクションスキャンを実行する可能性があり、これはexplain出力で高いtotalDocsExaminedとして明確に表示されます。

2. $lookup(結合)の効率的な利用

$lookupステージは、しばしば最も遅いコンポーネントです。これは実質的に別のコレクションに対するアンチ結合を実行します。

  • 外部キーにインデックスを付ける: 結合する外部(ルックアップされる)コレクションのフィールドにインデックスが付けられていることを確認します。これにより、内部のルックアッププロセスが大幅に高速化されます。
  • ルックアップの前にフィルタリングする: 可能な限り、$lookup$matchステージを適用して、必要なドキュメントに対してのみ結合を行うようにします。

3. 高コストなソートへの対処($sort

ドキュメントのソートは、特に大規模な結果セットの場合、計算コストが高くなります。MongoDBは、インデックスのプレフィックスがクエリフィルタと一致し、ソート順序がインデックス定義と一致する場合にのみ、ソートにインデックスを使用できます。

$sortの主要な最適化: $sortステージが高コストに見える場合は、フィルタと必要なソート順序に一致するカバリングインデックスを作成してみてください。たとえば、{ status: 1 }でフィルタリングし、{ date: -1 }でソートする場合、{ status: 1, date: -1 }のインデックスを使用すると、MongoDBはコストのかかるメモリ内ソートを行わずに、必要な順序でドキュメントを取得できます。

4. $projectによるデータ移動の最小化

$projectステージを戦略的に使用して、パイプラインを通過するデータ量を削減します。後のステージで少数のフィールドのみが必要な場合は、パイプラインの早い段階で$projectを使用して、不要なフィールドや埋め込みドキュメントを破棄します。ドキュメントが小さいほど、パイプラインステージ間で移動するデータが少なくなり、メモリ使用率が向上する可能性があります。

5. インデックスを使用できない高コストなステージを避ける

$unwindのようなステージは多くの新しいドキュメントを作成し、処理オーバーヘッドを急速に増加させる可能性があります。時には必要ですが、$unwindへの入力を可能な限り小さくしてください。同様に、インデックスをサポートしない計算や複雑な式に依存するステージなど、データセットの完全な再評価を強制するステージは最小限に抑える必要があります。

現実的な最適化のウォークスルー

過去30日間の顧客別返金総額を表示するサポートダッシュボードを想像してください。最初は高速でしたが、1年分の注文が蓄積された後、遅くなりました。パイプラインは無害に見えます:

db.orders.aggregate([
  { $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customer"
  }},
  { $unwind: "$customer" },
  { $match: { status: "refunded", createdAt: { $gte: startDate } } },
  { $group: { _id: "$customerId", totalRefunded: { $sum: "$amount" } } },
  { $sort: { totalRefunded: -1 } },
  { $limit: 50 }
])

高コストな間違いは、作業の順序を見るまで明らかになりません。このパイプラインは、過去30日間の返金された注文にフィルタリングする前に、すべての注文を顧客に結合します。大規模なコレクションでは、MongoDBは後で破棄されるドキュメントに対して多くの結合を行うことになります。

より良い最初のバージョンは、早期にフィルタリングします:

db.orders.aggregate([
  { $match: { status: "refunded", createdAt: { $gte: startDate } } },
  { $group: { _id: "$customerId", totalRefunded: { $sum: "$amount" } } },
  { $sort: { totalRefunded: -1 } },
  { $limit: 50 },
  { $lookup: {
      from: "customers",
      localField: "_id",
      foreignField: "_id",
      as: "customer"
  }},
  { $unwind: "$customer" }
])

これで、結合はコレクション内のすべての注文ではなく、グループ化された上位50の顧客に対してのみ行われます。これがプロファイリングが導くべき変更の種類です:より少ないデータが高コストなステージに入力されます。

このバージョンでは、ordersに対する有用なインデックスは次のようになります:

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

正確なインデックスは実際のフィルタとソートのニーズに依存しますが、考え方は安定しています:初期の$matchをサポートし、可能な場合にパイプラインが余分なドキュメント読み取りを避けるのに役立つフィールドを含めます。customersコレクションでは、_idはすでにインデックス化されているため、$lookupは通常問題ありません。別のフィールドで結合する場合は、その外部フィールドにインデックスを付けます。

.explain("executionStats")を確認するときは、総実行時間だけを見ないでください。ファンアウトを探してください。あるステージが500ドキュメントを返し、次のステージが$unwindのために200万を返す場合、問題の形状を変えたステージを見つけたことになります。totalDocsExaminednReturnedよりもはるかに大きい場合、インデックスは十分に選択的でないか、期待した方法で使用されていません。大規模なグループの後にソートがパイプラインの後半に表示される場合は、早期に制限できるか、または毎秒の鮮度を必要としないダッシュボードのために別のコレクションに事前集約できるかを検討してください。

また、メモリの動作にも注意してください。$group$sort$setWindowFields、および一部の$lookupパターンは、多くのメモリを必要とする場合があります。allowDiskUse: trueは、パイプラインがメモリ内の制限を超えた場合に失敗するのを防ぐことができますが、それ自体がパフォーマンスの修正ではありません。ディスクへのスピルは、通常、パイプラインが一度にあまりにも多くの作業を行っていることを意味します。夜間のレポートでは許容されるかもしれません。ページが読み込まれるたびに実行されるユーザー向けAPIエンドポイントでは、ほとんど許容されません。

実用的な習慣の1つは、遅いパイプライン、explain出力、およびインデックスをインシデントノートに一緒に保存することです。次の人は、なぜインデックスが存在するのか、なぜ$lookup$limitの後に移動されたのかを再発見する必要はありません。集約のチューニングは、推論がデバッグセッションよりも長く存続する場合にはるかに簡単です。

集約に役立つインデックスと、役立つように見えるだけのインデックス

集約パイプラインは、しばしば弱い複合インデックスを露呈します。APIがテナントと日付でフィルタリングし、ステータスでグループ化するとします:

db.orders.aggregate([
  { $match: { tenantId, createdAt: { $gte: start, $lt: end } } },
  { $group: { _id: "$status", count: { $sum: 1 } } }
])

{ createdAt: -1 }のインデックスは少し役立つかもしれませんが、マルチテナントシステムでは、すべてのテナントに対して広い日付範囲をスキャンする可能性があります。{ tenantId: 1, createdAt: -1 }のインデックスは、通常、アクセスパターンによりよく一致します。最初にテナントを絞り込み、次に日付範囲をウォークするためです。ほとんどのクエリにステータスも含まれる場合は、そのワークロードに対して{ tenantId: 1, status: 1, createdAt: -1 }がより良いかどうかをテストしてください。推測しないでください。本番に近いデータでexplainを実行し、keysExamineddocsExamined、および経過時間を比較してください。

インデックスの先頭にあるカーディナリティの低いフィールドには注意してください。{ status: 1 }で始まるインデックスは、ほとんどすべての注文がcompleteである場合、選択的ではない可能性があります。他のフィールドと組み合わせると便利な場合もありますが、クエリの形状を反映する必要があります。最適なインデックスは、最も多くのフィールドを持つものではなく、不必要な書き込みオーバーヘッドを作成せずに検索スペースを早期に削減するものです。

パイプラインの最適化をいつ停止するか

時には、正しい修正は別のパイプラインの書き換えではありません。ダッシュボードがマネージャーがページを開くたびに同じ高コストな集約を実行する場合、事前集約の方がクリーンかもしれません。スケジュールされたジョブが時間ごとの合計をorder_stats_hourlyコレクションに書き込み、ダッシュボードはいくつかの小さなドキュメントを読み取ることができます。鮮度と予測可能なレイテンシをトレードオフします。

そのトレードオフは、人間がトレンドを読んでいる場合にはしばしば許容されます。パイプラインがチェックアウトの決定や不正ルールを動かす場合には、あまり許容されません。鮮度要件を明確にしてください。「5分以内」は事前集約とキャッシングの可能性を開きます。「最後に確認された注文を含める必要がある」は、おそらくより強力な書き込みと読み取り動作を持つライブ読み取りに近づけます。

集約の最適化は、すべてのパイプラインを賢くすることではありません。リクエストパスでデータベースが実行する必要のない作業を削除することです。

まとめと次のステップ

MongoDB集約パイプラインのプロファイリングと最適化には、体系的で証拠に基づいたアプローチが必要です。組み込みのプロファイラ(db.setProfilingLevel)を活用し、詳細な実行統計(.explain('executionStats'))を実行することで、複雑なパフォーマンスの問題を解決可能なステップに変換できます。

最適化のワークフローは次のとおりです:

  1. プロファイリングを有効にする: レベル1を設定し、slowOpThresholdMsを定義します。
  2. クエリを実行する: 遅い集約パイプラインを実行します。
  3. プロファイルされたデータを分析する: 最も時間を消費している特定のステージを特定します。
  4. 詳細をExplainする: 問題のあるパイプラインに対して.explain('executionStats')を使用します。
  5. チューニングする: 必要なインデックスを作成し、ステージを並べ替え(最初にフィルタリング)、高コストな演算子に渡すデータを簡素化します。

継続的な監視により、新しく追加された機能やデータ量の増加が、解決したパフォーマンスの問題を再導入しないようにします。