1. 現場の課題:インメモリソートによるMongoDBのダウン
先日の11/11セール期間中、運用のMongoDBレプリカセットクラスタでCPU使用率が98%に達しました。注文の作成や一覧参照のリクエストが次々とスタックし、ログにはHTTP 500エラーコードとともに、見慣れたエラーメッセージが出力されていました。
Executor error during query :: caused by :: Sort exceeded memory limit of 104857600 bytes, but did not allow sorting on disk.
MongoDBはデフォルトで、ソート処理1回あたりのRAM使用量を100MBに制限しています(MongoDB 4.2以前は32MB)。ソート対象のデータセットがインデックスのサポートを受けられず、この上限を超えると、メモリ枯渇を防ぐためにデータベースは即座にクエリを強制終了します。
当時のordersコレクションには約1400万件のドキュメント(約18GB)が存在していました。開発チームはstatus、amount、createdAtの3つのフィールドすべてにインデックスを作成済みでした。しかし、それでもMongoDBは数十万件のレコードをRAM上にロードし、最初から並び替える処理を行っていたのです。インデックスの順序設計を誤った結果、書き込みスループットが35%低下し、参照クエリも低速なままとなっていました。
2. 問題の本質:フィールドが揃っているのになぜRAMスキャンが発生するのか?
ordersコレクションのサンプルドキュメントを見てみましょう。
{
_id: ObjectId("65e0a1b2c3d4e5f6a7b8c9d0"),
status: "COMPLETED",
amount: 250000,
createdAt: ISODate("2026-03-01T10:00:00Z")
}
管理画面から発行される実際のクエリ:
db.orders.find({
status: "COMPLETED",
amount: { $gte: 100000 }
}).sort({ createdAt: -1 });
データベースに設定されていた既存のインデックス:
db.orders.createIndex({ status: 1, amount: 1, createdAt: -1 });
エンジンがどのように実行しているかをexplain("executionStats")で確認します。
"stage": "SORT",
"sortPattern": { "createdAt": -1 },
"memLimit": 104857600,
"inputStage": {
"stage": "IXSCAN",
"indexName": "status_1_amount_1_createdAt_-1"
}
B-Tree構造が示す原因とは?
複合インデックス(Compound Index)は、B-Tree上で左から右への順序でデータをソート・配置します。範囲検索フィールドであるamountをcreatedAtの前に配置すると、処理フローは次のようになります。
- エンジンはまず
status: "COMPLETED"のブランチを素早く特定します。 - 次に、
amount >= 100000の範囲を順にスキャンします。範囲検索(Range)であるため、該当レコードは複数のリーフノードに分散しています。 - その結果、
createdAtの並び順はamountの値ごとに細切れになってしまいます。MongoDBはインデックスからソート済みの結果を直接取得できず、条件に合致するすべてのドキュメントをRAM上に読み込んでソートアルゴリズムを実行(ブロッキングソート)せざるを得なくなります。
3. 一時的な回避策とその副作用
解決策1:allowDiskUseフラグの有効化
多くのエンジニアがallowDiskUse: trueオプションを指定してエラーを回避しようとします。
db.orders.find({
status: "COMPLETED",
amount: { $gte: 100000 }
}).sort({ createdAt: -1 }).allowDiskUse(true);
副作用:500エラー自体は解消されますが、レイテンシが60msから3.2秒以上に跳ね上がります。ディスクへの一時ファイル書き込みが継続的に発生することでSSDのIOPSが枯渇し、クラスタ全体の他の処理まで巻き込んで遅延させます。
解決策2:個別の単一フィールドインデックスの作成
status、amount、createdAtにそれぞれ独立したインデックスを作成し、Index Intersection(インデックス交差)に依存する方法です。
副作用:MongoDBはRecordIdポインタの集合をマージするために余計なCPUサイクルを消費します。インデックスサイズが無駄に肥大化するうえ、RAM上でのSORTステージを完全に排除することはできません。
4. 根本的な解決策:ESR原則の適用
絞り込みとソートの両方を最適化するには、複合インデックス内のフィールド順序を厳格にESR原則(Equality → Sort → Range)に従わせる必要があります。
ESR原則の仕組み
- E – Equality(等価一致):完全一致で検索するフィールド(
statusやshopIdなど)を先頭に配置します。このステップで、対象データをB-Treeの単一の連続したブランチに絞り込みます。 - S – Sort(ソート):ソート対象のフィールド(ここでは
createdAt)をその直後に配置します。Equalityステップによってデータがすでに連続した領域にまとまっているため、インデックスポインタは指定された方向にスキャンするだけで済み、RAM上で再ソートする必要がなくなります。 - R – Range(範囲検索):範囲フィルタのフィールド(
$gte、$lt、または$inを用いるamountなど)を末尾に配置します。インデックスはソート順に沿って走査しつつ、範囲条件に合致しないレコードを順次フィルタリングします。
本番環境への適用手順
まず、既存の古いインデックスを削除します。
db.orders.dropIndex("status_1_amount_1_createdAt_-1");
ESRの設計方針(Equality: status、Sort: createdAt、Range: amount)に従って新しいインデックスを作成します。
db.orders.createIndex({ status: 1, createdAt: -1, amount: 1 });
executionStatsによる再検証
db.orders.find({
status: "COMPLETED",
amount: { $gte: 100000 }
}).sort({ createdAt: -1 })
.explain("executionStats");
更新後の実行計画:
"stage": "FETCH",
"inputStage": {
"stage": "IXSCAN",
"indexName": "status_1_createdAt_-1_amount_1",
"direction": "forward"
}
"stage": "SORT"ノードが完全に消失しました。代わりに、MongoDBはIndex-assisted Sort(スキャンしながらソート済みのデータをそのまま取得する方式)を採用しています。実行時間は1840msからわずか4msへと激減し、totalBytesSortedも0になりました。
$in演算子を扱う際の実践的な注意点
構文上、$inは等価比較に分類されます。しかしストレージ層の実装において、複数の要素を含む配列(例:status: { $in: ["PENDING", "PAID", "SHIPPING"] })を対象とする$inは、複数のOR条件の組み合わせとほぼ同等に動作します。インデックスポインタが複数の異なる領域を行き来する必要があるため、MongoDBがソート順の連続性を保証することが難しくなります。データのカーディナリティ(分散度)に応じて、explain()を用いて{ status: 1, createdAt: -1 }と{ createdAt: -1, status: 1 }のパフォーマンスを比較検討してください。

