ELW株式会社 テックブログ

リアルなログをそのままお届けします。

MongoDB インデックス

プロジェクトのインデックス作成バッチを眺めていて気になったので、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.namecustomProperties.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に注意)
  • totalDocsExamined vs nReturned — 舐めた数と返した数。近いほど良い
  • 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

標準のMongoDB indexとは別エンジン(Apache Lucene ベース)で動く全文検索機能。標準indexと共存できる。

  • 対応範囲: 全文検索、ファジー検索、autocomplete、日本語形態素解析(kuromoji)
  • 非同期反映: index作成は READY 待機が必要、ドキュメント反映も数秒ラグあり

本プロジェクト例: account_search_indexAccountIndexDefinitionBuilder.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_ascxxx_desc)が対になっている。MongoDBは逆スキャン可能なので片方で足りるケースが多い。$indexStats で使用頻度を確認して半減できないか

持ち帰り

  • MongoDBのindex型は Compound / Multikey / Partial が主役(TTL / Wildcard / 2dsphere は補助)
  • compound indexは ESR順(Equality → Sort → Range) で並べるのが基本
  • 遅いクエリは explain("executionStats")stagetotalDocsExamined / nReturned を見る
  • 配列フィールドへのcompound indexや asc/descペアの量産は、サイズ・書き込みコスト面で深掘りの価値あり