プロジェクトのインデックス作成バッチを眺めていて気になったので、MongoDBのindexを実例ベースで読み解く
1. インデックス型の"ハマりどころ"
狙い: MongoDBの主要なindex型を押さえる
主要な型
| 型 | Postgres相当 | ハマりどころ |
|---|---|---|
| Compound | 複合btree (a,b,c) |
フィールド順で効き方が決まる(§2 ESR) |
| Multikey | 配列への GIN | 配列フィールドを含むindexは自動的にmultikey化(後述ケーススタディ参照) |
| Partial(直交オプション) | Partial Index(同概念) | Compound/Multikeyに重ねられる「文書を絞る」条件。例: {deletionFlag:false} で削除済み除外 → 除外割合だけサイズ縮小 |
他に TTL(自動削除)、Wildcard(任意フィールド)、2dsphere(地理空間)もある。
ケーススタディ: customProperties = Multikey
例: ユーザーが動的に追加するカスタム項目(customProperties)を配列で持つドキュメント:
{ "userId": "u1", "customProperties": [ { "name": "favorite_color", "value": "blue" }, { "name": "birth_year", "value": 1990 } ] }
customProperties.name と customProperties.value で検索したくて compound indexを張ると、自動的にmultikey化される:
db.users.createIndex({ "customProperties.name": 1, "customProperties.value": 1 })
- エントリ膨張: 配列N要素 → 1ドキュメントからNエントリ生成(1エントリの幅 × N でindexサイズが決まる)
- parallel arrays 制約: 同じcompound内に配列フィールドは1つだけ。
customPropertiesと別の配列(例:tags)を同居させようとすると、カルテシアン積で爆発するためMongoDBがエラー(cannot index parallel arrays)で拒否 - 勘所: 同じ配列に対して用途違いの個別index(値タイプ別など)が共存しているケースは、役割重複していないか
$indexStatsで要確認
2. ESRルール(核心)
狙い: compound indexの並び順の原則 ESR を理解する
ESR = クエリでのフィールドの使い方を基準に、compound indexの並び順を決める原則
("ESR" はMongoDB特有の命名だが、原則自体はB-tree複合indexで共通 → Postgresの (a,b,c) 複合indexでも同じ設計が有効)
- Equality: 完全一致フィルタ(
=,$in)で使うフィールド - Sort: ソートキーとして使うフィールド
- Range: 範囲フィルタ(
$gt$lt$gte$lte)で使うフィールド
→ これらを indexで E → S → R の順に並べる
(クエリの条件を分類 → その分類順にindexを定義する、という手順)
ESR順にする理由
前提: indexはB-treeで順序付きに保存されており、クエリの sort 順序と一致すればそのまま舐めて済む(不一致なら SORT stage でメモリ再ソート)。
compound index (a, b, c) の中身は「a でソート済み、同じ a の中で b でソート済み、同じ a,b の中で c でソート済み」という並び。
- Range(
$gt$lt) でフィールドを絞ると、その範囲内のドキュメント群は後続フィールドの順序が崩れる - つまり Range の後ろにある Sort フィールドは、index の並びで提供されない
- Sort を Range より先に置くことで、index 走査だけで sort も済む
→ これが E → S → R の理由
良例(ECの注文コレクションを想定)
// index: { status: 1, customerId: 1, createdAt: -1 } // status(E) → customerId(E) → createdAt(S, desc) db.orders.find({ status: "shipped", customerId: 123 }) .sort({ createdAt: -1 }) // Rangeなし、E→S の並びでindexがそのまま使える → SORT不要
悪例(ESR違反)
// NG: index { status: 1, createdAt: 1, productName: 1 } — E→R→S の順 db.orders.find({ status: "shipped", createdAt: { $gte: ISODate("2025-01-01") } }).sort({ productName: 1 }) // Range(createdAt) の後ろに並ぶ productName は index 上で順序が保証されない // → productName の並び替えが in-memory sort (SORT stage) に落ちる // 正解: { status: 1, productName: 1, createdAt: 1 } = E→S→R // productName が index 順に並ぶので SORT stage が不要
3. explain()の読み方
狙い:
explain()で何が読めるかを知る
explain() の基本形
§2の良例クエリを実際に読む:
db.orders.find({ status: "shipped", customerId: 123 }) .sort({ createdAt: -1 }) .explain("executionStats") // → IXSCAN + SORT stage なし になるはず
見るべき指標
stage— 実行戦略(後述の危険stageに注意)totalDocsExaminedvsnReturned— 舐めた数と返した数。近いほど良いexecutionTimeMillis— 実時間(参考値)
Postgres EXPLAIN との対訳
| MongoDB | Postgres相当 | 意味 |
|---|---|---|
IXSCAN |
Index Scan |
indexを使ってドキュメントを特定 |
COLLSCAN |
Seq Scan |
コレクション全走査 |
FETCH |
Index Scan のheap参照部分 |
indexで特定後に実ドキュメント取得 |
SORT |
Sort node |
メモリ上で並び替え |
| Covered Query | Index Only Scan |
heapに触らず index だけで完結 |
危険なstage
COLLSCAN— index効いてないSORT— in-memory sort(ESR違反の可能性)FETCH— index検索後に実ドキュメントを取得している。返すフィールドが少ないなら covered query 化可能
Covered Query(FETCH回避によるパフォーマンス最適化)
explain() で FETCH stageが見えたら、消せないか検討する番。返すフィールドが全てindex内に収まるなら、実ドキュメントを読まずにindexだけでクエリが完結する(= covered query)。ディスクI/Oがまるごと省かれるので、特に大きなドキュメントで効く。
- 条件:
projectionでindexフィールドのみ指定し、_id: 0で明示除外(_idはデフォルトで返るためindexに無いとFETCHが発生)
// index: { status: 1, customerId: 1, createdAt: -1 } 前提 db.orders.find({ status: "shipped" }, { _id: 0, customerId: 1, createdAt: 1 }) // → customerId/createdAt はindex内 → FETCH不要
Postgres版(Index Only Scan)もほぼ同じ仕組み。テーブルメンテナンス(VACUUM)が追いついていないと効きが落ちることがある、くらいが固有事情。
4. Atlas固有の機能
狙い: Atlas環境特有の機能を知る
Performance Advisor / Query Profiler
- Performance Advisor(有料Atlas): スロークエリから 推奨index / 削除候補 を提示。本番でまず見る場所。PG相当:
pg_stat_statements - Query Profiler: 100ms以上のクエリを自動記録(
system.profile)。PG相当:auto_explain
$indexStats(ローカルでも使える)
db.yourCollection.aggregate([{ $indexStats: {} }]) // 各indexの ops(使用回数)を返す → ops=0 は削除候補。PG相当: pg_stat_user_indexes.idx_scan
Atlas Search
標準のMongoDB indexとは別エンジン(Apache Lucene ベース)で動く全文検索機能。標準indexと共存できる。
- 対応範囲: 全文検索、ファジー検索、autocomplete、日本語形態素解析(kuromoji)
- 非同期反映: index作成は READY 待機が必要、ドキュメント反映も数秒ラグあり
本プロジェクト例:
account_search_index(AccountIndexDefinitionBuilder.kt:11-18)— n-gram(2-7) + edgeGram(2-20) + kuromoji で日本語のあいまい検索に対応。READY待機はAccountIndexCreationService.waitForIndexReadyで実装。
5. アンチパターン
狙い: ありがちな落とし穴と、本プロジェクトでの実例を知る
| アンチパターン | 問題 | 対策 |
|---|---|---|
$ne / $nin |
index使ってもfull scan同等 | 可能なら $in で書き換え |
$regex に先頭アンカー(^)なし |
/foo/ は全走査(PGの LIKE '%foo%' 相当) |
prefix match か Atlas Search |
| 低カーディナリティ単体index | deletionFlag 単体など |
compound の一部 or partial index の条件に使う |
| indexの貼りすぎ | 書き込みコスト増大 | $indexStats で未使用削除 |
よく見るパターン(実例)
$regexの非prefix使用 — 検索キーワードをPattern.quote()で特殊文字エスケープしたうえで$regexに渡す実装。エスケープ自体は正しいが、先頭アンカー(^)が無いとindexは効かず全走査。Atlas Search の n-gram への寄せ替え候補asc/descのindexペア量産 — ソート順だけ違うindex(xxx_ascとxxx_desc)が対になっている。MongoDBは逆スキャン可能なので片方で足りるケースが多い。$indexStatsで使用頻度を確認して半減できないか
持ち帰り
- MongoDBのindex型は Compound / Multikey / Partial が主役(TTL / Wildcard / 2dsphere は補助)
- compound indexは ESR順(Equality → Sort → Range) で並べるのが基本
- 遅いクエリは
explain("executionStats")でstageとtotalDocsExamined / nReturnedを見る - 配列フィールドへのcompound indexや
asc/descペアの量産は、サイズ・書き込みコスト面で深掘りの価値あり