TT Lab
ブログ はじめる
学ぶ 学習パス コース

MongoDB — ドキュメントDBの判断

インデックス設計 — 順序・カバー・部分・TTL

TT Labで続きを見る

目標

注文60,000件の上で、同じ一覧クエリをインデックスなし・フィールドの順序どおり(ERS)・等価→ソート→範囲(ESR)で測って数字で比較し、 カバードクエリ・部分インデックス・TTL・正規表現の接頭辞を実行計画の数字で確認します。最後に、3つの画面が遅くなったインシデントを切り分けます。

なぜ重要か

インデックスの問題は、たいてい「ないから」ではなく「合っていないから」です。順序1つで走査するキーが数千倍変わり、正規表現1つで IXSCANと書かれた計画がキーをすべて走査します。ステージ名だけを見ていると見逃し、totalKeysExamined・totalDocsExamined・nReturned の3つの数字とSORTステージの有無を見ると見えてきます。このラボではその数字を自分で測り、採点ツールも同じ数字を測り直して照合します。

ステップ

  1. 素材の投入とベースライン(インデックスなし)
  2. フィールドの順序どおりに作ったインデックス(ERS)
  3. 等価→ソート→範囲のインデックス(ESR)とプランナーの選択
  4. ドキュメントを開かないクエリ(カバード)
  5. 部分インデックス
  6. TTLと文字列の時刻
  7. 正規表現の接頭辞
  8. インシデント — 3つの画面のクエリが遅くなった

接続と素材

python3 /opt/lab/fixtures/mg/mgkit.py load-index    # idx.orders 60,000건(다시 돌리면 인덱스까지 처음 상태로)
mongosh idx

一覧画面のクエリ(ステップ1–3): db.orders.find({status: "shipped", amount: {$gte: 150000}}).sort({createdAt: -1}).limit(20)

参考

素材を投入してベースラインを測る

python3 /opt/lab/fixtures/mg/mgkit.py load-indexでidx.ordersに注文60,000件を投入します。一覧画面のクエリdb.orders.find({status: "shipped", amount: {$gte: 150000}}).sort({createdAt: -1}).limit(20)をidx DBでインデックスなしでexplain('executionStats')により読み、/root/work/mgidx/01-baseline.txtにplan:・keysExamined:・docsExamined:・nReturned:の4行を書きます。planは、外側のステージ(winningPlan)からinputStageをたどってステージ名を>でつないだものです。

方向: 直す前に測っておかないと、直した後と比べられません。このクエリはstatusが一致しamountが150000以上のものを絞り込んだ後、createdAtでソートして20件だけ使います — インデックスがないと何をどれだけ走査するかを見ます。 ツール: explain('executionStats')、queryPlanner.winningPlanとその中のinputStage、executionStatsの3つの数字。 具体的には: 計画ツリーは外側が最後に行う処理です。ソートのステージがあれば、その内側に何があるかを下っていって確認してください。

idx.ordersが60,000件のまま · 01-baseline.txtの4つの値がインデックスなしの計画と一致

クエリに出てくる順序どおりにインデックスを作る

{status: 1, amount: 1, createdAt: -1}インデックスを作成し、同じ一覧クエリをそのインデックスで(hint)測って、/root/work/mgidx/02-ers.txtに同じ4行を書きます。ベースラインと比べて何が変わり、何が変わらないかを見てください。

方向: インデックスを使ったら終わりではありません — ソートのステージが残っているか、キーをいくつ走査したかを見ます。 ツール: createIndex、cursor.hint({<keys>})、explain('executionStats')。 具体的には: 範囲(amount)で走査したキーはcreatedAtの順序ではありません。そのため計画のどこかに別途ソートが付きます — そのステージの位置を書いてください。

{status: 1, amount: 1, createdAt: -1}インデックスがあり、そのインデックスで測った02-ers.txtの4つの値が一致

等価 → ソート → 範囲の順序で作る

{status: 1, createdAt: -1, amount: 1}インデックスを作成し、同じ一覧クエリをそのインデックスで測って、/root/work/mgidx/03-esr.txtに同じ4行を書きます。さらに、ヒントなしで問い合わせたときにプランナーが選んだインデックス名をwinner: <indexName>の1行で追加します。

方向: 等価の区間の中がすでにcreatedAtの順序なら、その順序で進みながらamountを絞り込み、20件で止まることができます。 ツール: createIndex、hint、ヒントなしのexplain()のwinningPlan内のindexName、負けた計画はrejectedPlans。 具体的には: ステップ2とkeysExamined・planを並べて見てください。プランナーは候補の計画を少しずつ実行してみて、先に結果を出した方を選びます。

{status: 1, createdAt: -1, amount: 1}インデックスで測った03-esr.txtの4つの値とwinnerが現在の計画と一致

ドキュメントを開かずに答える

ある一覧画面は、statusがpaidで、createdAtが2026-10-01T00:00:00Z以降(その時刻を含む)の注文のcreatedAt・amountだけを表示します。この画面のクエリを/root/work/mgidx/04-covered.jsonに{"filter": …, "projection": …}として書き(日付はExtended JSONの{"$date": "…"})、ドキュメントを1つも開かずに(totalDocsExamined: 0)答えるようにします。必要ならインデックスを追加してもかまいません。

方向: 返すフィールドがすべてインデックスのキーの中にあれば、ドキュメントを開く理由はありません。 ツール: 射影、ステップ3のESRインデックス、explain('executionStats')のtotalDocsExaminedとPROJECTION_COVERED、日付の確認はEJSON.stringify。 具体的には: 射影に書かなくてもデフォルトで付いてくるフィールドが1つあります — そのフィールドがインデックスになければ、そのためにドキュメントを開きます。

04-covered.jsonのクエリがその画面の結果と同じで、totalDocsExaminedが0

必要なドキュメントだけを含むインデックス

未処理の注文(status: "pending")を顧客ごとに探す画面のために、customerIdに、statusがpendingのドキュメントだけを含む部分インデックスを作成します。/root/work/mgidx/05-partial.txtに、そのインデックスに実際に入ったキー数partialKeys: <n>、顧客3728の{customerId: 3728, status: "pending"}検索の計画withStatus: <plan>、{customerId: 3728}検索の計画withoutStatus: <plan>の3行を書きます(planはステップ1と同じく、ステージ名を>でつないだもの)。

方向: 部分インデックスは小さくて安上がりですが、クエリの条件がそのインデックスの条件を含むときにしか使えません。 ツール: createIndexのpartialFilterExpression、キー数はdb.orders.validate().keysPerIndex、計画はexplain()。 具体的には: statusを外して問い合わせると、プランナーはそのインデックスがすべてのドキュメントを含んでいないので使えないと判断します — 2つの計画がどう違うかを書いてください。

customerIdの部分インデックスがあり、05-partial.txtのキー数・2つの検索の計画が現在と一致

TTLで古いセッションを削除する

python3 /opt/lab/fixtures/mg/mgkit.py load-sessionsでidx.sessionsに300件を投入します(もう一度実行すると最初の状態に戻ります)。最終アクセス(lastSeen)から1時間(3600秒)が過ぎたセッションを、データベースが自分で削除するようにしてください。古いアプリが書いたセッションの一部は、lastSeenが文字列です — それも削除されるようにする必要があります。生きているべきセッションは削除せず、時刻はそのまま移します。

方向: TTLインデックスは、バックグラウンドの監視プロセスが60秒ごとに日付フィールドを見て削除します。日付でない値は素通りします。 ツール: createIndex(…, {expireAfterSeconds: …})、$typeで文字列を数える、更新パイプラインの$toDate。 具体的には: 文字列を日付に変換した後、1分ほど待って残りの件数をもう一度数えてみてください。手で削除すると、次に期限切れになるセッションはまた残ります。

lastSeenのTTL 3600秒 · 文字列の時刻0件 · 期限切れのセッションはTTLが削除し、生きているセッションは残る

正規表現は接頭辞のときだけ絞り込める

ordersのskuにインデックスを作成し、3つの検索{sku: /^TN-123/}・{sku: /TN-123/}・{sku: /^tn-123/i}を測って、/root/work/mgidx/07-prefix.txtにanchored:・unanchored:・ignoreCase:の3行でkeysExamined=<n> nReturned=<n>を書きます。

方向: 3つの検索とも、計画にはIXSCANと表示されます。違いはステージ名ではなく、いくつのキーを走査したかにあります。 ツール: createIndex、explain('executionStats')のtotalKeysExamined・nReturned、indexBounds。 具体的には: indexBoundsも一緒に見てください — 先頭が固定された正規表現だけが区間が狭く、残りは最初から最後までです。

skuインデックスを使い、07-prefix.txtの3つの検索の走査したキー・返した件数が現在と一致

インシデント — 3つの画面のクエリが遅くなった

incident mg-slow-endpointsを実行するとインシデントが発生します(前のステップとは無関係のinc DBを新しく作ります)。画面ep1・ep2・ep3のクエリ(/root/slowapp/queries/<ep>.json)が、3つとも遅いクエリのアラートを鳴らします。/root/slowapp/slowapp run|explain|indexes <ep>で実行してみながら画面ごとに原因を見つけ、返す結果はそのままにして、まるごと走査(COLLSCAN)とメモリ内ソート(SORT)なしで必要な分だけ走査するように直してください。メールアドレスの画面は、大文字と小文字を区別せずに検索する画面です。データは削除も変更もしません。/root/incident/mg-slow-endpoints/report.txtにep1: <code>・ep2: <code>・ep3: <code>の3行とevidence: <line>(直す前に見たslowapp explain・slowapp indexesの出力1行をそのまま)を書きます。コードは画面ごとに次のいずれかです: collation-mismatch(クエリとインデックスのcollationが異なり、インデックスを使えない)、hidden-index(合うインデックスが隠されている)、index-missing(合うインデックスがまったくない)、sort-after-range(インデックスが範囲の後ろにソートフィールドを置いていて、メモリ内ソートになる)、unanchored-regex(正規表現の先頭が開いていて、キーをすべて走査する)。

方向: 画面ごとに「計画(explain)」と「そのコレクションのインデックス一覧」を並べて見ます。まるごと走査しているのに合うインデックスが一覧にあるなら、なぜプランナーがそれを使えないのかが原因です。インデックスを使っているのにキーを多く走査していたりSORTがあったりするなら、インデックスの形かクエリの形が原因です。 ツール: slowapp explain・slowapp indexes、mongoshのunhideIndex・createIndex・dropIndex、クエリファイルのcollation・$regex。 具体的には: 直す場所は画面ごとに異なります — インデックス側(復活させる・順序を変えた新しいインデックス)か、クエリ側(collationを入れる・正規表現の先頭を固定する)かを見分けてください。直した後、slowapp explainでkeys・docsがreturnedに近づいたか、slowapp runの件数が変わっていないかを確認します。

3つの画面とも結果が同じで、COLLSCAN・メモリSORTなしで必要な分だけ走査 · 原因コードと証拠の行