MongoDBのインデックスは、コレクション全体を読むCOLLSCANを避けて、クエリが調べるドキュメント数を減らすための仕組みです。効くかどうかは「貼ったか」ではなく、クエリの形とキーの並び順が合っているかで決まります。この記事ではMongoDB 9.0.4に20万件のコレクションを作り、インデックスなし・ESR順・ERS順で読み取りキー数と実行時間がどう変わるかをexplain("executionStats")の値で示します。createIndex()の書き方、型とプロパティの違い、uniqueと部分インデックスのnullの扱い、作成時に出るエラーまで扱います。MongoDBそのものの構造はMongoDBは使ってはいけない?向かない業務の判断基準とドキュメント構造・使い方入門で先に押さえておくと読みやすくなります。
まとめ:MongoDBインデックス設計の要点
- インデックスなしはCOLLSCAN:20万件で4,000件を返すクエリが、20万件すべてを読んでからメモリでソートしていた(67ms)。
- 複合インデックスはESR順が基本:Equality(等価一致)→Sort(並び替え)→Range(範囲)の順にキーを置く。ESRは並び順の指針で、「推定選択率」の略ではない。
- ESRが常に最速ではない:範囲条件が絞り込める(4〜8%)うえに全件を返すクエリでは、範囲を前に置くERS順の方が速かった(8ms対62ms)。
limit(20)を付けるとESR順が逆転して有利になる。 - uniqueはフィールド欠損もnullとして数える:欠損を複数許すなら
partialFilterExpressionを付ける。ただし部分インデックスは、クエリの条件が部分フィルターの範囲内に収まると判定できる形でないと使われない。 - 効いたかはexplainで確認:
IXSCANか、totalKeysExaminedとtotalDocsExaminedがnReturnedに近いか、SORTステージが出ていないかを見る。 - 使われていないインデックスは消す:
$indexStatsで使用回数を見て、hideIndex()で影響を確かめてからdropIndex()する。
COLLSCANとIXSCANの違い:文書走査とキー走査
インデックスは、指定したフィールドの値を並べた状態で持つB木のデータ構造です。使えるインデックスが無いと、MongoDBはコレクションのドキュメントを順に読んで条件と照合します。これがCOLLSCAN(コレクションスキャン)で、該当をすべて返すクエリではコレクション全体を読むため、件数に比例して遅くなります。クエリに適したインデックスが選ばれると、キーを走査するIXSCANなどが使われます。_idには作成時からユニークインデックスがあり、削除できません。
20万件のordersコレクションで、同じ種類のクエリを比べた結果です。
| クエリ | 実行計画 | 読んだキー | 読んだ文書 | 返した件数 | 時間 |
|---|---|---|---|---|---|
| status+qty範囲+orderDateで並び替え(索引なし) | SORT ← COLLSCAN | 0 | 200,000 | 4,000 | 67ms |
| qtyだけで検索(先頭がqtyの索引なし) | COLLSCAN | 0 | 200,000 | 2,000 | 51ms |
| orderNoで1件検索(orderNo索引あり) | FETCH ← IXSCAN | 1 | 1 | 1 | 1ms |
| _idで1件検索 | EXPRESS_IXSCAN | 1 | 1 | 1 | 0ms |
COLLSCANでもlimit付きで並び替えが無ければ、必要件数がそろった時点で止まります。find({ customerId: 7 }).limit(3)は10,008件を読んだところで終了しました。逆に言えば、該当が少ない条件ほど止まるまでに読む量が増えます。_idの等価検索で出たEXPRESS_IXSCANは、MongoDB 8.0で入った実行ステージで、通常のプラン選択を飛ばして最適化されたインデックス走査を使う限られたクエリに付きます(Explain Results)。8.0より前の解説記事には出てきませんが、異常ではありません。
インデックスの作成・確認・削除:createIndexと管理メソッド
操作はmongoshのメソッドで完結します。キーの値1は昇順、-1は降順です。名前を省くとstatus_1_createdAt_-1のように「フィールド名_向き」をつないだ名前が自動で付きます。
// 単一フィールド(昇順)
db.users.createIndex({ lastLoginAt: 1 })
// ユニーク
db.users.createIndex({ email: 1 }, { unique: true })
// 複合(status昇順・createdAt降順)に名前を付ける
db.orders.createIndex({ status: 1, createdAt: -1 }, { name: "status_createdAt" })
// 一覧を確認
db.orders.getIndexes()
// 削除(名前で指定)
db.orders.dropIndex("status_createdAt")
同じキー・同じオプションでcreateIndex()をもう一度実行しても、二重には作られず既存の名前が返ります。一方、{ email: 1 }が既にある状態で名前を省いて{ email: 1 }, { unique: true }を作ろうとすると、自動で付く名前email_1が既存と重なりIndexKeySpecsConflictで失敗します。9.0.4ではname: "email_unique"のように別名を付ければ通常とユニークを同じキーで併存でき、ユニーク側ができてから旧インデックスを削除すれば、一意性の保証が途切れずに入れ替えられます。TTLの秒数やhiddenのようにcollModで変えられる項目は、作り直さずに変更します。
大きなコレクションにインデックスを作るときの止まり方も押さえておきます。現在のインデックス構築は、構築の最初と最後だけコレクションに排他ロックを取り、それ以外の間は読み書きを通します(Index Builds on Populated Collections)。古い解説にあるbackground: trueは9.0.4でもエラーにはなりませんが、この構築方式の下では指定しても挙動は変わりません。
インデックスの型:単一・複合・マルチキー・text・ワイルドカード・hashed・2dsphere
「インデックスの種類」として並べられるものは、キーの持ち方を決める型と、既存の型に付けるプロパティの2軸に分かれます。uniqueやTTLは型ではなくプロパティなので、「ユニークな複合インデックス」「部分的なTTLインデックス」のように組み合わせられます。まず型です。
| 型 | キーの書き方 | 主な用途 | 制約 |
|---|---|---|---|
| 単一フィールド | { email: 1 } |
1項目での検索・並び替え | なし |
| 複合 | { status: 1, orderDate: 1 } |
複数条件+並び替え | 最大32フィールド |
| マルチキー | 配列のフィールドを指定 | 配列要素での検索 | 配列は1文書につき1フィールドまで |
| text | { title: "text" } |
$textでの単語検索 |
1コレクション1本 |
| ワイルドカード | { "attrs.$**": 1 } |
キー名が可変の埋め込み文書 | 複合は7.0以降 |
| hashed | { k: "hashed" } |
等価検索・ハッシュシャーディング | 範囲検索に使えない |
| 2dsphere | { loc: "2dsphere" } |
近傍・範囲内の位置検索 | GeoJSONかレガシー座標 |
マルチキーは明示して作るものではなく、インデックス対象のフィールドに配列が入ると自動でマルチキーになります。複合インデックス{ a: 1, b: 1 }に{ a: [1, 2], b: [3, 4] }を入れるとcannot index parallel arrays [b] [a]で挿入が拒否されます。複合マルチキーインデックスでは、1つの文書で配列にできるフィールドは1つだけです(Multikey Indexes)。
hashedは値のハッシュで並べるため、1,000件のコレクションで{ k: 5 }はIXSCANになり、{ k: { $gt: 5 } }はCOLLSCANになりました。範囲検索や並び替えが要るフィールドには通常の昇順インデックスを使い、hashedはシャードキーを分散させたいときに限ります。シャードキーの選び方はシャーディングとは?水平分割の仕組みとシャードキー設計を実装者目線で解説【2026年版】で扱っています。textインデックスは1コレクションに1本で、2本目を作ろうとするとIndexOptionsConflictになります。複数フィールドを対象にしたいときは1本のtextインデックスにまとめます。なお9.0のマニュアルは、textインデックスの代わりに、$searchで使う検索インデックスやVector Searchのインデックスを使うよう勧めています(Text Indexes)。日本語の全文検索が要件なら、textインデックスで粘るより先にこちらを検討します。
プロパティはcreateIndex()の第2引数で指定します。
| プロパティ | 指定 | 効果 |
|---|---|---|
| ユニーク | unique: true |
重複値の挿入を拒否 |
| 部分(partial) | partialFilterExpression |
条件に合う文書だけを索引化 |
| スパース | sparse: true |
フィールドを持つ文書だけを索引化 |
| TTL | expireAfterSeconds |
期限切れ文書を自動削除 |
| 非表示(hidden) | hidden: true |
プランナーから隠す |
スパースと部分は役割が重なります。公式ドキュメントは、どの文書を索引に入れるかを細かく制御できる部分インデックスをスパースの代わりに使うよう案内しています(Partial Indexes)。新しく作るなら部分インデックスで書きます。
単一フィールドのユニークインデックス:欠損とnullの重複判定
{ email: 1 }にユニークを付けたコレクションへ、emailを持たない文書を2件入れると、2件目が次のエラーで拒否されます。
E11000 duplicate key error collection: lab.users index: email_1 dup key: { email: null }
フィールドが無い文書は、インデックス上ではnullとして扱われるためです。「登録済みならメールアドレスは一意、未登録の人は何人いてもよい」という要件には、部分インデックスを組み合わせます。9.0.4で3通りを試した結果です。
| partialFilterExpression | 欠損を2件 | nullを2件 | find({ email: "[email protected]" }) |
|---|---|---|---|
| なし(uniqueのみ) | 2件目でE11000 | 2件目でE11000 | IXSCAN |
{ email: { $exists: true } } |
通る | 2件目でE11000 | EXPRESS_IXSCAN |
{ email: { $type: "string" } } |
通る | 通る | COLLSCAN |
$exists: trueは欠損を許しますが、明示的にnullを入れた文書はフィールドが存在するので索引に入り、重複になります。アプリが未設定をnullで保存するなら$type: "string"にします。ただしこの場合、find({ email: "[email protected]" })はインデックスを使わずCOLLSCANになりました。部分インデックスを使うには、クエリが同じ条件(またはその部分集合)を含んでいる必要があるためです(Partial Indexes)。find({ email: { $eq: "[email protected]", $type: "string" } })と条件を足すとIXSCANに変わります。部分インデックスを作ったら、アプリが実際に投げるクエリの形でexplainを確かめてください。
TTLインデックスの制約:単一フィールドと削除の遅延
TTLは日付フィールドにexpireAfterSecondsを付け、期限を過ぎた文書を自動で消します。セッションやワンタイムトークンのように、一定時間で不要になるデータに向いています。
// lastAccessから3600秒経過後、TTLタスクの削除対象になる
db.sessions.createIndex({ lastAccess: 1 }, { expireAfterSeconds: 3600 })
// kindがsessionの文書だけを期限切れにする(部分TTL)
db.events.createIndex(
{ lastAccess: 1 },
{ expireAfterSeconds: 3600, partialFilterExpression: { kind: "session" } }
)
複合インデックスには付けられず、{ a: 1, lastAccess: 1 }に指定するとTTL indexes are single-field indexes, compound indexes do not support TTL.で失敗します。部分インデックスとの組み合わせは作成できました。削除を行うバックグラウンドタスクは60秒ごとに動き、負荷が高いときや期限切れの文書が大量にあるときはさらに時間がかかります(TTL Indexes)。期限を過ぎた文書が1分以上検索結果に残ることがある前提で設計します。秒単位で消えている前提の処理(期限切れトークンを弾く判定など)は、アプリ側でも期限の日時を比較してください。MongoDB 5.1以降は既存の単一フィールドインデックスにcollModでexpireAfterSecondsを後付けできます。
非表示にしたインデックスはプランナーが選ばなくなりますが、書き込みのたびの更新は続き、ディスクとメモリも使い続けます。検証では、hidden: trueで作った{ y: 1 }に対するfind({ y: 1 })はCOLLSCANになり、unhideIndex("y_1")で戻すと即座にIXSCANに戻りました。削除と違って作り直しの時間がかからないため、「消して遅くなったら戻す」をすぐに試せます。
db.orders.hideIndex("status_createdAt") // プランナーから隠す
db.orders.unhideIndex("status_createdAt") // 元に戻す
非表示でもユニーク制約は効き続け、TTLも期限切れ文書を消し続けます。_idのインデックスは非表示にできず、非表示のインデックスをhint()で指定することもできません(Hidden Indexes)。
複合インデックスの並び順を決めるESR(Equality・Sort・Range)
複合インデックスで結果を最も左右するのはフィールドの並び順です。指針になるのがESRで、Equality(等価一致)・Sort(並び替え)・Range(範囲)の頭文字です。クエリの各条件をこの3つに分け、等価一致→並び替え→範囲の順にキーを置きます(The ESR (Equality, Sort, Range) Guideline)。
db.orders.find({
status: "active", // Equality
qty: { $gt: 90 } // Range
}).sort({ orderDate: 1 }) // Sort
// ESR順
db.orders.createIndex({ status: 1, orderDate: 1, qty: 1 }, { name: "ESR" })
// 比較用のERS順(範囲を並び替えより前に置く)
db.orders.createIndex({ status: 1, qty: 1, orderDate: 1 }, { name: "ERS" })
等価一致を先頭に置くと、その値の中ではorderDateが並んだ状態で読めるので、並び替えをインデックスの順番で済ませられます。範囲を並び替えより前に置くと、qtyの値ごとにorderDateの並びが分かれてしまい、取り出した後にメモリで並び替えるSORTステージが必要になります。
ESRとERSを20万件で比べた結果
上の2本のインデックスをhint()で切り替え、範囲条件の絞り込み率とlimitの有無を変えて測りました(status=activeは5万件。時間は7回の中央値で、測り直すと数ms揺れます)。
| 範囲条件 | limit | ESR:キー/時間 | ERS:キー/時間 | 速い方 |
|---|---|---|---|---|
| qty≧96(2,000件・4%) | なし | 50,001/64ms | 2,000/4ms | ERS |
| qty>90(4,000件・8%) | なし | 50,001/62ms | 4,000/8ms | ERS |
| qty>10(44,000件・88%) | なし | 50,001/67ms | 44,000/125ms | ESR |
| qty≧96(4%) | 20 | 504/0ms | 2,000/1ms | ESR |
| qty>90(8%) | 20 | 231/0ms | 4,000/2ms | ESR |
| qty>10(88%) | 20 | 26/0ms | 44,000/25ms | ESR |
ESR順は並び替えを省ける代わりに、status=activeの5万件分のキーを順に読んで範囲条件で落とします。全件を返すクエリで範囲が狭いと、この読み捨てが重くなり、ERS順(読んだ分だけメモリで並べる)に負けました。公式ドキュメントも、範囲条件の絞り込みが非常に強い場合は範囲を並び替えより前に置いてメモリソートの対象を減らしてよい、メモリソートを避けたいなら範囲を後ろに置く、と例外を書いています。
一方、limit(20)を付けた一覧取得では、ESR順は並び順に読んで20件そろった時点で止まれるため、どの絞り込み率でもESRが速くなりました。ERSはlimitがあっても範囲に合うキーを全部読んでから並べるので、範囲が広いほど遅くなります(88%で25ms)。画面に並べる一覧・ページング用ならESR、集計やバッチで範囲の全件を取るなら絞り込み率を見てERSも候補、というのが判断の目安です。ページングの設計はページネーション設計とは?オフセットとカーソルの選び分けと実装を解説も参考にしてください。
両方のインデックスがある状態でプランナーに任せると、8%・limitなしではESRが選ばれました(実測では8ms対62msで不利な方)。4%ではERSが選ばれています。試行型のプラン選択では、試行中に少ない処理量で多くの結果を返す計画が一般に優先されるため、全件を返すクエリで最速の計画を選ぶとは限りません。迷う並び順は、2本とも作ってhint()で比べてから1本に絞ります。
$inとソート方向による実行計画の違い
等価条件をstatus: { $in: ["active", "pending"] }にすると、ESRインデックスでもSORTではなくSORT_MERGE(値ごとのIXSCANを並び順のまま合流させる)になり、limit(20)で読んだキーは374でした。$inの値が少ないうちはESRの順で問題ありません。
並び替えの向きは、インデックス全体を逆にたどれる組み合わせなら追加の索引は要りません。{ status: 1, orderDate: 1, qty: 1 }でsort({ orderDate: -1 })はインデックスを逆にたどり、5件取得でキー5件でした。ところがsort({ orderDate: 1, qty: -1 })のように向きを混ぜるとSORTステージが出て、5件取得のために5万キーを読みました。向きを混ぜた並び替えを多用する画面があるなら、{ status: 1, orderDate: 1, qty: -1 }のように同じ向きの組み合わせでインデックスを作ります。
explainによるインデックス使用状況の確認
作ったインデックスが使われているかは、クエリに.explain("executionStats")を付けて確かめます。
db.orders.find({ status: "active", qty: { $gt: 90 } })
.sort({ orderDate: 1 })
.explain("executionStats")
explainの確認項目:実行計画・走査数・実行時間
- winningPlanの実行計画ツリー:
FETCHやPROJECTIONの下(inputStage、実行エンジンによってはqueryPlan配下)までたどり、IXSCAN・EXPRESS_IXSCANがあればインデックスを使用、COLLSCANならコレクション走査。 totalKeysExamined:読んだインデックスのキー数。ESR順・limitなしの例では、返した4,000件に対して50,001キーを読んでいた。totalDocsExamined:読んだ文書数。nReturned(返した件数)より桁違いに大きければ、インデックスで絞り切れていない。SORTステージの有無:並び替え付きのクエリで出ていれば、メモリで並び替えている。executionTimeMillis:同じクエリでも1回ごとにぶれるので、比べるときは複数回の中央値を見る。
キー数と文書数がnReturnedに近く、SORTが無ければ意図どおりです。キー数だけが大きいならキーの並び順、文書数だけが大きいなら索引に含まれない条件で文書を読んでから捨てている、と原因の当たりを付けられます。
covered queryの判定:文書取得の有無とtotalDocsExamined
検索条件と返すフィールドが1本のインデックスに含まれ、nullとの等価比較などの制約にも該当しなければ、文書本体を読まないcovered queryにできる場合があります。これがcovered queryです(Query Optimization)。_idは指定しない限り返されるので、明示的に外します。
// covered(_idを外す)
db.orders.find(
{ status: "active", orderDate: { $gte: ISODate("2026-06-01") } },
{ _id: 0, status: 1, orderDate: 1 }
)
| 射影 | 実行計画 | 読んだ文書 | 時間 |
|---|---|---|---|
{ _id: 0, status: 1, orderDate: 1 } |
PROJECTION_COVERED ← IXSCAN | 0 | 13ms |
{ status: 1, orderDate: 1 } |
PROJECTION_SIMPLE ← FETCH ← IXSCAN | 29,313 | 45ms |
返したのはどちらも29,313件です。_id: 0を書き忘れただけでFETCHが入り、全件の文書本体を読みに行きました。PROJECTION_COVEREDは9.0.4での表示例で、出力の形は実行エンジンによって変わります。判定の本筋は、IXSCANの上にFETCHが無いこととtotalDocsExaminedが0であることです。高頻度で呼ばれる一覧やID取得のAPIでは、返す項目を絞ってcovered queryにできないかを先に検討します。
インデックス作成時に出るエラーと原因
MongoDB 9.0.4で実際に出したエラーを、原因と対処の順に並べます。
| エラー(code) | 原因 | 対処 |
|---|---|---|
| E11000 duplicate key(11000) | 一意制約の対象キーが重複 | 重複を解消。欠損除外は部分索引を検討 |
| cannot index parallel arrays(171) | 複合の2フィールドが配列 | 配列は1フィールドに |
| IndexKeySpecsConflict(86) | 同名で異なるキー仕様など | 名前・仕様の競合を解消 |
| IndexOptionsConflict(85) | textの2本目など | 1本にまとめる |
| too many compound keys(13103) | 複合が33フィールド以上 | 32以下に減らす |
| too many indexes(CannotCreateIndex) | 64本を超える | 不要な索引を削除 |
| TTL indexes are single-field(67) | 複合にTTLを指定 | 単一フィールドで作る |
本数の上限は_idを含めて64本で、検証では_id以外に63本作った時点で64本目が拒否されました。複合インデックスのフィールド数の上限32とあわせて、公式の制限値の一覧に載っています(MongoDB Limits and Thresholds)。
未使用インデックスの検出と削除判断
インデックスは読み取りを速くする代わりに、文書を1件書き込むたびに関係する全インデックスも更新します。容量も食います。検証の20万件では、データ本体24.8MBに対し、複合インデックス1本が約2.6MB、_idが約1.9MBでした。「とりあえず全フィールドに単一インデックス」は、書き込みの遅さとメモリの圧迫で返ってきます。
減らせる典型は、複合インデックスの先頭側と重なる単一インデックスです。{ status: 1, orderDate: 1, qty: 1 }があれば、statusだけの検索はこのインデックスのIXSCANで賄えたので、{ status: 1 }はuniqueや部分条件などのオプションに差が無ければ削除候補です。ただし先頭以外のqtyだけの検索はCOLLSCANでした。先頭フィールドを含む条件なら、途中のフィールドを省いても使える場合がありますが、走査効率は低下し得ます。
削除の候補は$indexStatsで探します。
db.orders.aggregate([
{ $indexStats: {} },
{ $project: { name: 1, "accesses.ops": 1, "accesses.since": 1 } }
])
accesses.opsはそのインデックスを使ったユーザー操作の回数、accesses.sinceは集計を始めた時刻です。この値はmongodの再起動やインデックスの作り直しでリセットされ、クエリを実行したノードの分しか数えません($indexStats)。レプリカセットでセカンダリに読み取りを振っているなら、全メンバーで確認してから判断します。opsが0のインデックスは、hideIndex()で隠して月末処理などの周期をまたいで様子を見て、問題が無ければdropIndex()します。
逆に、件数が数千件までの小さなコレクションや、月に1回しか走らないバッチのクエリには、インデックスを作らない判断もあります。インデックス設計を含めたデータベースの見直しを外部に頼む場合は、データベース設計・移行支援で相談を受けています。Atlasで運用している場合のクラスタ構成や料金はMongoDB Atlasとは?クラスタ階層・料金・セキュリティ設定と採用判断を実装者目線で解説【2026年版】、Node.jsからMongooseでスキーマとあわせてインデックスを定義する場合はMongooseとは?Node.js用MongoDB ODMの使い方とv9の変更点を参照してください。
よくある質問
MongoDBでインデックスが使われているかはどう確認しますか?
クエリに.explain("executionStats")を付けて実行し、winningPlanの実行計画ツリーを子ステージまで確認し、IXSCAN(8.0以降はEXPRESS_IXSCANの場合もある)かを見ます。COLLSCANならインデックスは使われていません。あわせてtotalKeysExaminedとtotalDocsExaminedがnReturnedに近いかを確認します。
ESRの法則とは何ですか?
複合インデックスのフィールドを、Equality(等価一致)→Sort(並び替え)→Range(範囲)の順に置くというMongoDB公式の指針です。「Estimated Selectivity Ratio(推定選択率)」の略ではありません。範囲条件の絞り込みが強く全件を返すクエリでは、範囲を並び替えより前に置く方が速い場合があります。
ユニークインデックスでnullの重複エラーが出るのはなぜですか?
ユニークインデックスは、フィールドを持たない文書もnullとして扱うため、欠損した文書が2件目になった時点でdup key: { email: null }のE11000になります。欠損を複数許すならpartialFilterExpression: { email: { $exists: true } }、明示的なnullも複数許すなら{ email: { $type: "string" } }を付けます。後者はクエリにも同じ条件を入れないとインデックスが使われません。
COLLSCANとは何ですか?
コレクションの文書を走査する処理を指し、explainの実行計画に出るステージ名です。適したインデックスがない場合などに選ばれます。全件走査では返す件数が少なくても全体を読みますが、limitなどにより途中で終了する場合もあります。
複合インデックスがあれば単一フィールドのインデックスは不要ですか?
複合インデックスの先頭から連続するフィールドでの検索は、その複合インデックスで賄えるので、先頭フィールドだけの単一インデックスは、unique・sparse・部分条件・照合順序などに差がなければ削除候補になります。先頭以外のフィールドだけで検索するクエリには使えないため、そのクエリが多いなら別のインデックスが必要です。