MongoDBの複合インデックスはフィールドの順序で何が変わるのか

MongoDBで複合インデックスを定義するとき、{ userId: 1, createdAt: -1 } と { createdAt: -1, userId: 1 } は同じ二つのフィールドを含む。では、順序を入れ替えると何が変わるのか。

この記事では、検索条件、ソート、範囲条件ごとにインデックスの使われ方を確認する。後半では、PostgreSQLの複合インデックスとの共通点と、比較時に注意したい点を整理する。

複合インデックスのフィールド順序

次のインデックスでは、最初に userId、同じ userId の中で createdAt の降順にキーが並ぶ。

db.posts.createIndex({ userId: 1, createdAt: -1 });

このインデックスのプレフィックス(先頭から連続したフィールド)は { userId: 1 } と { userId: 1, createdAt: -1 } である。createdAt だけはプレフィックスではない。このため、userId で対象を絞ってから作成日時順に読むクエリと相性がよい。

ただし「先頭フィールドを指定しなければインデックスを一切使えない」という意味ではない。後続フィールドだけを条件にしてインデックスを走査する場合もある。重要なのは、使えるかどうかと走査範囲を十分に狭められるかを分けて見ることだ。MongoDBの公式資料も、プレフィックスと後続フィールドだけを指定した条件を区別して説明している。

検証するデータと見方

以下では投稿を想定し、userId、createdAt、score を持つ10万件のデータを使う。100人のユーザーに各1,000件を割り当てる。

結果は explain("executionStats") で確認する。主に見る項目は次のとおり。

項目意味
nReturned返したドキュメント数
totalKeysExamined調べたインデックスキー数
totalDocsExamined調べたドキュメント数
実行計画インデックス走査や追加のソートが含まれるか

実行時間も取得できるが、explain() の時間は通常のアプリケーションクエリの応答時間と同一ではない。まずは走査件数と実行計画を比較する。インデックス間の比較には hint() で対象を固定し、その後で hint() なしの実行計画も確認する。固定したインデックスで速くても、通常のプランナーが選ぶとは限らない。

MongoDBを起動する

mongosh で接続する前に、Dockerで検証用のMongoDBを起動する。

docker run --rm --name mongo-index-order \
  -p 127.0.0.1:27017:27017 \
  -d mongodb/mongodb-community-server:8.0-ubi8

起動を確認する。

docker ps --filter name=mongo-index-order

mongo-index-order が表示されたら、次節の検証スクリプトを実行する。検証後は以下で停止する。--rm を指定しているため、停止時にコンテナも削除される。

docker stop mongo-index-order

再現用スクリプト

以下を index-order.js として保存し、ローカルの検証用MongoDBに対して mongosh "mongodb://localhost:27017" index-order.js で実行できる。index_order_article データベース内の posts コレクションを削除して作り直すため、同名の既存データがない検証環境で実行する。

const database = db.getSiblingDB("index_order_article");
const posts = database.posts;
posts.drop();

const base = Date.parse("2025-01-01T00:00:00Z");
let batch = [];
for (let i = 0; i < 100000; i++) {
  batch.push({
    userId: i % 100,
    createdAt: new Date(base + i * 60000),
    score: (Math.floor(i / 100) * 37 + (i % 100) * 11) % 100,
  });
  if (batch.length === 1000) {
    posts.insertMany(batch);
    batch = [];
  }
}

posts.createIndex(
  { userId: 1, createdAt: -1 },
  { name: "user_date" }
);
posts.createIndex(
  { createdAt: -1, userId: 1 },
  { name: "date_user" }
);
posts.createIndex(
  { userId: 1, createdAt: -1, score: 1 },
  { name: "user_date_score" }
);
posts.createIndex(
  { userId: 1, score: 1, createdAt: -1 },
  { name: "user_score_date" }
);

function measure(label, filter, sort, limit, indexName) {
  let cursor = posts.find(filter);
  if (sort) cursor = cursor.sort(sort);
  if (limit) cursor = cursor.limit(limit);
  if (indexName) cursor = cursor.hint(indexName);

  const result = cursor.explain("executionStats");
  const stats = result.executionStats;
  printjson({
    label,
    index: indexName || "planner choice",
    nReturned: stats.nReturned,
    totalKeysExamined: stats.totalKeysExamined,
    totalDocsExamined: stats.totalDocsExamined,
    executionTimeMillis: stats.executionTimeMillis,
    winningPlan: result.queryPlanner.winningPlan,
  });
}

const recent = new Date("2025-02-15T00:00:00Z");
const cases = [
  ["user only", { userId: 42 }, null, 0, ["user_date", "date_user"]],
  ["date only", { createdAt: { $gte: recent } }, null, 0,
    ["user_date", "date_user"]],
  ["user and date", { userId: 42, createdAt: { $gte: recent } },
    null, 0, ["user_date", "date_user"]],
  ["latest 20", { userId: 42 }, { createdAt: -1 }, 20,
    ["user_date", "date_user"]],
  ["score and latest 20", { userId: 42, score: { $gte: 90 } },
    { createdAt: -1 }, 20, ["user_date_score", "user_score_date"]],
];

for (const [label, filter, sort, limit, indexes] of cases) {
  for (const name of indexes) measure(label, filter, sort, limit, name);
  measure(label, filter, sort, limit, null);
}

winningPlan の構造やステージ名の配置はMongoDBのバージョンや実行エンジンによって変わる。単一の固定パスだけで判定せず、実行計画全体を確認する。

検索条件を変えて比較する

{ userId: 42 } なら、user_date では userId が先頭にある。date_user では userId が後続フィールドなので、インデックスを強制した場合の走査量を比べる。

逆に { createdAt: { $gte: recent } } は date_user の先頭フィールドを使える。両方の条件を指定した場合も、userId が何件に絞るか、日付範囲が何件に絞るかによって有利な順序は変わる。

ここでは totalKeysExamined を比較し、「後続フィールドにも条件があるから両方同じ」と決めつけない。totalDocsExamined も合わせて見る。インデックス上で条件を確認できる場合、キーの走査数とドキュメントの読み込み数は一致しない。

ソートを加えて比較する

次のクエリは、指定ユーザーの新しい投稿を20件取得する。

db.posts.find({ userId: 42 }).sort({ createdAt: -1 }).limit(20);

{ userId: 1, createdAt: -1 } では、指定ユーザーの投稿が作成日時の降順に並んでいる。実測では20件を得るために20キーを走査した。

{ createdAt: -1, userId: 1 } でも作成日時順に読めるため、追加の SORT は発生しなかった。ただし、指定ユーザー以外の投稿も通過し、20件を得るまでに1,958キーを走査した。追加ソートの有無だけでは、検索の効率は判断できない。

なお、インデックス定義の 1 と -1 はデータの値ではなく、各フィールドの昇順・降順を指定する。MongoDBはインデックスを逆方向にも走査できるため、userId = 42 に絞って createdAt だけを並べる場合は、createdAt が昇順のインデックスでも降順のインデックスでも対応できる。複数フィールドを異なる方向でソートする場合は、方向の組み合わせが重要になる。

範囲条件を加えて比較する

次は、score >= 90 の投稿から新しい20件を探すクエリである。

db.posts.find({ userId: 42, score: { $gte: 90 } })
  .sort({ createdAt: -1 })
  .limit(20);

比較するインデックスは次の二つ。

{ userId: 1, createdAt: -1, score: 1 } // 等価 → ソート → 範囲
{ userId: 1, score: 1, createdAt: -1 } // 等価 → 範囲 → ソート

MongoDBのESR(Equality、Sort、Range)は、等価条件を先頭に置き、ソートと範囲条件の順番をクエリに応じて考える指針である。ソート処理を避けたいなら前者、範囲条件が非常に選択的なら後者が有利になりうる。後者では score が範囲条件なので、その後ろにある createdAt の順序だけでクエリ全体のソートを満たせるとは限らない。

この例でも「ESRが必ず最速」と結論づけず、キー走査数、追加ソート、返却件数を確認する。limit(20) の有無でも、先にソートを満たす利点の大きさは変わる。

実測結果

MongoDB 8.0.32をローカルのDockerコンテナで起動し、10万件の投稿データで測定した。データは100人のユーザーに各1,000件を割り当てた。各インデックスを hint() で指定し、explain("executionStats") で得た返却件数、キー走査数、ドキュメント走査数、追加ソートの有無を比較する。

条件インデックス返却件数キー走査数ドキュメント走査数追加ソート
userId のみuser_date1,0001,0001,000なし
userId のみdate_user1,000100,000100,000なし
createdAt のみuser_date35,200100,000100,000なし
createdAt のみdate_user35,20035,20035,200なし
userId と日時範囲user_date352352352なし
userId と日時範囲date_user35235,201352なし
指定ユーザーの新着20件user_date202020なし
指定ユーザーの新着20件date_user201,9581,958なし
高スコアの新着20件user_date_score2019920なし
高スコアの新着20件user_score_date2010020あり

userId だけで検索した場合、userId が先頭のインデックスは1,000キーを走査した。一方、createdAt が先頭のインデックスは10万キーを走査した。日時だけの検索では逆の傾向になった。

userId と日時範囲を併用した場合、両インデックスとも352件を返したが、キー走査数は352件と35,201件だった。date_user のドキュメント走査数は352件に抑えられているため、キー走査数とドキュメント走査数は分けて読む必要がある。

新着20件の取得では、どちらのインデックスにも追加の SORT はなかった。ただし、20件を見つけるまでのキー走査数は20件と1,958件だった。

score >= 90 を加えると、ソート優先の user_date_score は199キーを走査し、追加ソートは発生しなかった。範囲条件優先の user_score_date は100キーで済んだが、実行計画に SORT が現れた。このデータとクエリでは、プランナーは後者を選んだ。キー走査数が少ないことだけで、常に応答時間も短いとは断定できない。

PostgreSQLの複合インデックスとの比較

PostgreSQLでも、通常の CREATE INDEX で複数列を次の順序で指定できる。

CREATE INDEX posts_user_date_idx ON posts (user_id, created_at DESC);

先頭列の条件があると効率的に範囲を絞りやすい点、インデックス順をソートに使える点は、この記事のMongoDBの例と共通する。したがって「MongoDBだけ順序を気にする」という理解にはならない。

一方、先頭列に条件がなければ絶対に使えないとも言い切れない。PostgreSQLの公式資料は後続列だけの条件でもインデックスを利用しうると説明し、条件やデータ分布に応じたskip scanにも言及している。MongoDBについても、後続フィールドだけを条件にした走査と、効率よく範囲を絞れることは区別する必要がある。

この記事のMongoDB実験値をPostgreSQLの性能差として流用することはできない。データ分布、プランナー、実行環境をそろえた別の測定が必要である。ここで比較しているのは複合インデックスの列・フィールド順序を設計する際の考え方である。

まとめ

複合インデックスのフィールド順序は、先頭からどの条件で範囲を絞れるか、ソート順をそのまま使えるか、目的の件数を得るまでに何キーを読むかに影響する。等価条件、ソート、範囲条件をクエリ単位で整理し、候補を explain("executionStats") で比較するのが具体的な決め方になる。

測定では、hint() による候補同士の比較に加え、指定なしで実際に選ばれるプランも確認する。

参考資料

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です