深夜3時の本番インシデント
前四半期、注文件数がピークを迎える時間帯にアナリティクスダッシュボードが完全に停止しました。APIレスポンスのレイテンシは平均42msから一気に4,500ms超へと急上昇。MongoDBレプリカセットのCPU使用率は98%に達し、午前3時14分にオンコールエンジニアが呼び出される事態となりました。
その原因となったクエリは、一見すると何の問題もないように見えました。
db.orders.find({
status: "COMPLETED",
createdAt: { $gte: ISODate("2026-09-01T00:00:00Z") }
}).sort({ totalAmount: -1 }).limit(20);
12,000件のテストドキュメントを用意したステージング環境では、この処理は4ms未満で完了していました。しかし本番環境では、850万件のドキュメントと62GBに及ぶコレクションサイズによりWiredTigerキャッシュが飽和し、ディスクI/Oが逼迫してしまったのです。toolcraft.app/ja/tools/data/csv-to-json などの一括変換ツールでステージング用データを投入する際は、本番に近いデータ量を用意することが重要です。ドキュメント数が少ない環境では、インデックスの順序に関する問題がほぼ確実に隠れてしまいます。
explain() でボトルネックを診断する
MongoDBがフィルタをどのように実行しているのか推測するのはやめましょう。explain() に "executionStats" を渡すことで、実際の実行計画メトリクスを確認できます。
db.orders.find({
status: "COMPLETED",
createdAt: { $gte: ISODate("2026-09-01T00:00:00Z") }
}).sort({ totalAmount: -1 }).limit(20).explain("executionStats");
診断結果の警告サインを読み解く
実行トレースの中で、特に注目すべき警告サインが3つありました。
stage: "COLLSCAN": フルスキャン(全件走査)が発生しています。前方一致で利用できるインデックスが存在しなかったため、NVMeストレージから8,520,412件のドキュメントを順次読み込んでいました。totalDocsExamined: 8520412とnReturned: 20の乖離: 健全なクエリでは、走査件数と返却件数の比率(examined-to-returned ratio)が1:1程度に収まるのが理想です。わずか20件のデータを取得するために850万件以上のレコードを読み込むのは、膨大なメモリとCPU帯域の無駄遣いです。- インデックスパイプラインを伴わない
stage: "SORT": MongoDBのインメモリソートは、デフォルトで100MBまでに制限されています(MongoDB 4.4未満の古いバージョンでは32MB)。フィルタリングされた行のソート処理がallowDiskUse: trueなしにこのバッファを超えると、即座にExecutor error during find command: Sort exceeded memory limitエラーが発生します。
3つのインデックス戦略を比較する
複数フィールドに対するクエリが遅延した場合、多くの現場で次の3つのインデックス構成のいずれかが試されます。実際のプロダクショントラフィック下におけるそれぞれの挙動を見てみましょう。
1. 複数の単一フィールドインデックス
db.orders.createIndex({ status: 1 });
db.orders.createIndex({ createdAt: 1 });
db.orders.createIndex({ totalAmount: -1 });
MongoDBはインデックス交差(AND_SORTED)を試みることができますが、高負荷環境ではほとんど効果を発揮しません。エンジンは個別のインデックスB-treeを走査し、メモリ上でレコード識別子をマージした上で、依然としてブロッキングを伴うインメモリソートを実行します。さらに、挿入や更新のたびに1回ではなく3回分の書き込みオーバーヘッドが発生します。
2. 不適切な複合インデックス(範囲フィールドを先頭に配置)
// 不適切な順序: 一致条件やソートの前に範囲フィールドを配置している
db.orders.createIndex({ createdAt: 1, status: 1, totalAmount: -1 });
先頭に createdAt を配置すると、インデックス効率が著しく損なわれます。MongoDBは範囲演算子($gte)を評価した時点で、完全一致である status の検索のために複合B-treeの深層へ進むことができなくなります。さらに悪いことに、totalAmount の順序付けされたナビゲーションも失われ、コストの高いインメモリソートが再び発生してしまいます。
3. ESRルールに準拠した複合インデックス
// 順序が重要: Equality (status) -> Sort (totalAmount) -> Range (createdAt)
db.orders.createIndex({ status: 1, totalAmount: -1, createdAt: 1 });
この構成は物理的なB-tree構造と完全に合致しています。クエリプランナーは一致する status: "COMPLETED" レコードへ直接移動し、指定されたソート順通りにレコードを走査し、日付の境界に達するかlimitに達した時点で走査を終了します。
ESRルールの適用方法
ESRルールとは、Equality(完全一致)、Sort(ソート)、Range(範囲指定)の頭文字をとったものです。クエリで複数のフィルタ条件を組み合わせる際は、必ず複合インデックスのキーをこの順序で並べてください。
1. Equality(完全一致フィールド)を先頭に
完全一致条件(tenantId、status、userId など)をインデックスキーの先頭に配置します。これにより、評価対象の範囲をB-tree上のコンパクトで連続したパーティションに即座に絞り込むことができます。
2. Sort(ソートフィールド)を2番目に
次に、.sort() で指定するキーを定義します。要求されたソート順(またはその完全な逆順)と一致させることで、MongoDBはインデックスから直接ソート済みの行をストリーミングできます。レコードをRAMにバッファリングする必要がなくなるため、メモリへの負荷が解消されます。
3. Range(範囲フィールド)を最後に
範囲演算子($gt、$gte、$lt、$lte)や集合包含フィルタは末尾に配置します。エンジンが非連続なインデックスブロックにまたがる範囲スキャンを開始した後は、それ以降のインデックスフィールドを使ってソート処理やピンポイント検索を行うことができなくなるためです。
本番環境での改善検証
書き込み処理のロックを防ぐため、バックグラウンドでESRインデックスを作成しました。
// 1. 最適化されたインデックスを作成
db.orders.createIndex(
{ status: 1, totalAmount: -1, createdAt: 1 },
{ background: true }
);
// 2. クエリの実行計画を検証
const plan = db.orders.find({
status: "COMPLETED",
createdAt: { $gte: ISODate("2026-09-01T00:00:00Z") }
}).sort({ totalAmount: -1 }).limit(20).explain("executionStats");
const stats = plan.executionStats;
print("Root Stage: " + stats.executionStages.stage);
print("走査インデックスキー数: " + stats.totalKeysExamined);
print("走査ドキュメント数: " + stats.totalDocsExamined);
print("返却件数: " + stats.nReturned);
print("レイテンシ (ms): " + stats.executionTimeMillis);
運用の各種メトリクスに劇的な変化が見られました。
totalKeysExaminedは8,520,412件からわずか 20件 に減少しました。totalDocsExaminedも8,520,412件から 20件 へと激減(理想的な1:1の比率)。executionTimeMillisは4,510msから 1ms に短縮されました。- メモリ消費の大きかった
SORTステージは消え、インデックススキャン(IXSCAN)からダイレクトにFETCHへと続くパイプラインへと刷新されました。
レプリカセットのCPU使用率は14%まで低下しました。レイテンシの上昇が見られた際は必ず explain() の出力を確認し、ESRの原則に基づいて複合インデックスを設計してください。また、単純なクエリだけでなくMongoDB Aggregation Pipelineで複雑なデータの処理やグループ化を行う場合でも、適切なインデックス設計が性能向上の鍵となります。

