複合インデックスは、複数の列をひとまとめにして並べた索引です。同じ3列を使っていても、並べる順番を1つ間違えるだけで、検索条件の半分が索引の絞り込みに使われなくなります。一覧画面が遅いという相談を受けて実行計画を見ると、索引はあるのに効いていないケースが目立ちます。
この記事では、複合インデックスが先頭の列から順に並ぶ仕組みと、範囲条件のあとに置いた列が絞り込みに使われなくなる理由を、MySQLとPostgreSQLの公式マニュアルの記述を根拠に整理します。そのうえで、等価・ソート・範囲の順に列を並べる手順、カバリングインデックス、EXPLAINのkey_lenとExtra列での確認方法、スキップスキャンで変わった点までを、コピーして試せるSQL付きで示します。データベース全般の位置づけはデータベースとは?種類・DBMS・RDBとNoSQLの選び方を参照してください。版の前提はPostgreSQL 18系とMySQL 9.7系(いずれも2026年10月時点)です。
まとめ:複合インデックスの列順を決めるときに先に押さえる4つの規則
結論を4点で先に置きます。1点目は、複合インデックスは先頭列から順に使われるという最左一致の規則です。(a, b, c)の索引はa、a, b、a, b, cの条件には使えますが、bだけの条件では原則として絞り込みに使えません。
2点目は、範囲条件の列より右側は絞り込みに効かないことです。a = 1 AND b >= 10 AND c = 5なら、索引の走査範囲を決めるのはaとbまでで、cは読んだ索引エントリをふるい落とす役にとどまります。
3点目は、列の並べ方は「等価条件の列、並べ替えの列、範囲条件の列」の順を基本にすることです。カーディナリティの高い列を先頭にするという助言は、等価条件どうしの並びを決めるときの補助規則と考えてください。
4点目は、必ずEXPLAINで確かめることです。MySQLならkey_lenの値で何列目まで使われたかが分かり、Extra列のUsing indexでテーブル参照が消えたかを確認できます。なおMySQL 8.0以降とPostgreSQL 18にはスキップスキャンがあり、先頭列に条件がなくても索引が使われる場面が出てきました。以下で順に根拠と手順を示します。
複合インデックスの仕組みとB-treeで先頭列から並ぶ索引キーの構造
複合インデックスを理解する近道は、電話帳を思い浮かべることです。電話帳は姓で並び、同じ姓の中で名が並びます。姓が分かれば探せますが、名だけでは最初から最後まで見るしかありません。
先頭列で並び同じ値の中で次の列が並ぶB-treeの索引キーの並び方
B-treeの複合インデックスは、索引キーを「1列目の値、同じなら2列目の値、さらに同じなら3列目の値」という辞書順で並べて保持します。(tenant_id, status, created_at)の索引なら、テナントごとにまとまり、その中で状態ごとにまとまり、さらに作成日時の順に並ぶ構造です。
この並びがあるので、tenant_id = 10 AND status = 2という条件は索引上の連続した1区間として取り出せます。一方でstatus = 2だけの条件は、テナントごとに散らばった区間を全部拾う必要があり、通常の走査では索引を先頭から読むことになります。
最左一致で使える条件と使えない条件を公式マニュアルの例で確認
MySQLの公式リファレンスマニュアル(9.7系)は、(col1, col2, col3)の索引について、(col1)、(col1, col2)、(col1, col2, col3)の組み合わせで検索に使えると説明しています。(last_name, first_name)の例では、姓だけの条件や、姓の等価に名の範囲を足した条件は索引を使い、名だけの条件と、姓と名をORでつないだ条件は使わないとしています。
ORの扱いは見落としやすいところです。last_name = 'Jones' OR first_name = 'John'は、名の側の条件を満たす行が索引のどこにあるか特定できないため、複合インデックスでは絞り込めません。PostgreSQLの複数列インデックスの章も、B-treeについて同じ考え方を示しています。
検証用テーブルを作り最左一致と範囲条件で索引が止まる位置を試す手順
規則は手で試すと定着します。ここではテナント制の注文テーブルを想定し、同じ3列で並び順だけが違う2本の索引を比べられる形にしました。MySQLでもPostgreSQLでも、DATETIMEをTIMESTAMPに変えればほぼそのまま動きます。
注文テーブルと列順だけが違う2本の複合インデックスを作るSQL
CREATE TABLE orders (
id BIGINT NOT NULL PRIMARY KEY,
tenant_id INT NOT NULL,
status TINYINT NOT NULL,
created_at DATETIME NOT NULL,
amount INT NOT NULL
);
-- 等価の列を前、範囲の列を後ろに置いた索引
CREATE INDEX idx_t_s_c ON orders (tenant_id, status, created_at);
-- 範囲の列を真ん中に置いた索引(比較用)
CREATE INDEX idx_t_c_s ON orders (tenant_id, created_at, status);
-- 試すクエリ:テナントと状態は等価、作成日時は範囲
EXPLAIN
SELECT id, created_at
FROM orders
WHERE tenant_id = 10
AND status = 2
AND created_at >= '2026-10-01'
AND created_at < '2026-11-01';
行数が少ないとオプティマイザは索引を使わずに全件を読むほうを選ぶため、比較するには数万行程度のデータを入れてから統計を更新してください。MySQLはANALYZE TABLE orders;、PostgreSQLはANALYZE orders;です。1本ずつ比べたいときは、もう一方の索引を一時的に削除するか、MySQLならFORCE INDEXで指定します。
範囲条件の列より右側が索引の走査範囲の絞り込みに使われなくなる理由
MySQLの公式マニュアルは、複数列の索引で区間を組み立てる規則として、比較演算子が=、<=>、IS NULLである間は次の列も使い、>、<、BETWEEN、LIKEなどが出た時点で、その列は使うがそれ以降の列は考慮しない、と書いています。例としてkey_part1 = 'foo' AND key_part2 >= 10 AND key_part3 > 10では、3つ目の条件は区間の構築に使われません。
先ほどのidx_t_c_sがこの形です。created_atの範囲で区間が決まり、status = 2は区間内の索引エントリを1件ずつ確かめるだけになります。idx_t_s_cならtenant_idとstatusの等価で区間が狭まり、その中をcreated_atの範囲で切り出す形です。PostgreSQLのマニュアルも、先頭側の等価条件と、等価条件のない最初の列の不等号条件が走査範囲を絞り、それより右の列の条件は索引内で確かめられるものの走査範囲を狭めるとは限らない、と同じ趣旨を述べています。
等価・ソート・範囲の順に列を並べる設計手順とORDER BYの扱い
列順は勘ではなく、検索条件の種類から機械的に決められます。基本は「等価、ソート、範囲」の順です。MongoDBではこれをESRと呼んでおり、考え方はMongoDBのインデックス設計:createIndexの書き方・ESRの並び順・explainの読み方で扱いました。RDBでも同じ順番がそのまま通用します。
等価条件の列を先頭に置きカーディナリティで列の並びを決める判断基準
最初に、WHERE句で=やINで指定される列を集めて前に置きます。等価条件どうしの並びは、その列だけで検索される頻度が高いものを先にします。最左一致のおかげで、先頭列だけの検索にも同じ索引が使い回せるからです。
頻度が同程度なら、異なる値の数が多い列を前寄りにします。ただし等価条件の列を全部前に並べるなら、走査範囲の広さはどの順でも同じになります。カーディナリティで迷う場面は、実際にはそれほど多くありません。異なる値の数の測り方と選択性の計算はカーディナリティとは?意味・高低の見分け方とインデックス設計に整理しています。
ORDER BYとLIMITを索引の並びで満たしてソート処理を消す条件
次に、並べ替えに使う列を等価条件の列の後ろに置きます。WHERE tenant_id = 10 ORDER BY created_at DESC LIMIT 20なら(tenant_id, created_at)の索引で、索引を逆向きに20件読むだけで済む仕組みです。PostgreSQLのインデックスとORDER BYの章は、ORDER BYとLIMITの組み合わせを重要な特例と位置づけ、明示的なソートは先頭の数件を決めるためだけに全データを処理する必要がある、と説明しています。
範囲条件の列をソート列より前に置くと、この恩恵は消えます。MySQLではExtra列にUsing filesortが出て、条件に合う行を全部集めてから並べ替える処理が必要です。昇順と降順が混在するORDER BY a ASC, b DESCは、MySQL 8.0以降の降順インデックスか、PostgreSQLの(a ASC, b DESC)指定で索引に合わせられます。オフセットを使わずに続きを読むカーソル方式の一覧は、この並びを前提にした設計です。設計の選び分けはページネーション設計とは?オフセットとカーソルの選び分けを参照してください。
カバリングインデックスでテーブル参照を消す設計とINCLUDE句の使い方
カバリングインデックスは、クエリが読む列をすべて索引に含めておき、テーブル本体を読まずに結果を返す設計です。複合インデックスの延長として作れますが、効く条件と効かない条件がはっきり分かれます。
InnoDBのセカンダリ索引に主キーが入る仕様を前提にした列の選び方
MySQLのInnoDBでは、公式マニュアルによれば、セカンダリインデックスの各レコードに索引の列と行の主キー列が入ります。先ほどのidx_t_s_cにはidも含まれているので、SELECT id, created_atはテーブルを読まずに返せる列の組み合わせです。amountをSELECTに足した瞬間、行ごとにテーブルを引きに行く形へ戻ります。
同じマニュアルは、主キーが長いとセカンダリインデックスが大きくなるため、短い主キーが有利だとも書いています。UUIDを文字列の主キーにしたテーブルで複合インデックスを何本も張ると、どの索引にもその文字列が複製されます。カバリングを狙う前に、主キーの型を確認しておいてください。
PostgreSQLのINCLUDE句でキー以外の列を索引に持たせる書き方
PostgreSQLには、検索キーにしない列を索引に持たせるINCLUDE句があります。Index-Only ScansとCovering Indexesの章によれば、INCLUDEに入れた列は索引の探索には使われず、値を返すためだけに格納されます。
-- tenant_id と status で探し、created_at の範囲で切り出し、amount は値を返すだけ
CREATE INDEX idx_orders_cover
ON orders (tenant_id, status, created_at)
INCLUDE (amount);
EXPLAIN (ANALYZE)
SELECT created_at, amount
FROM orders
WHERE tenant_id = 10
AND status = 2
AND created_at >= '2026-10-01';
計画にIndex Only Scanが出れば狙いどおりです。ただしPostgreSQLは行の可視性を確かめるため、可視性マップで「全行可視」と印されていないページはテーブル本体を読みに行きます。更新の多い表でHeap Fetchesの値が大きいままなら、索引の作りではなくVACUUMの周期を見直す場面です。周期の決め方はPostgreSQLのVACUUM運用|autovacuumのしきい値設計で扱いました。マニュアルは、幅の広い列を入れると索引が膨らみ、上限サイズを超えると挿入が失敗するとも注意しています。
EXPLAINのkey_lenとExtra列で複合インデックスの効き方を確かめる手順
索引が「使われた」ことと「何列目まで効いた」ことは別の事実です。EXPLAINのkey列に索引名が出ていても、先頭1列しか使われていないことは珍しくありません。
key_lenを列の型から計算して何列目まで使われたかを読む方法
MySQLの公式マニュアルは、key_lenを「使うと決めたキーの長さ」と定義し、この値から複数列の索引のうち何列を実際に使ったか判断できると説明しています。NULLを許す列は、NOT NULLの列より1大きくなります。次の表は、先ほどのidx_t_s_c(3列ともNOT NULL)で、区間に使われた列が増えるごとに値が積み上がる様子です。
| 区間に使われた列 | 足した列の型と長さ | key_len |
|---|---|---|
| 1列目のtenant_idまで | INTで4バイト | 4 |
| 2列目のstatusまで | TINYINTで1バイト | 5 |
| 3列目のcreated_atまで | DATETIMEで5バイト | 10 |
先ほどのクエリをidx_t_s_cで実行してkey_lenが10なら、3列すべてで区間が組まれています。idx_t_c_sなら9(INT+DATETIME)で止まり、statusは区間に入っていません。可変長の文字列列は文字セットによって長さが変わるので、utf8mb4のVARCHAR(n)なら最大4×nバイトに長さ情報の2バイトを足して見積もります。
Using indexとUsing index conditionを取り違えない読み分け
名前が似ていて取り違えやすいのが、Extra列のこの2つです。Using indexは索引だけで必要な列がそろい、テーブル行を読みに行っていないことを示します。カバリングインデックスが効いた印です。
Using index conditionは別物で、Index Condition Pushdownが働いたことを示します。公式マニュアルの例では(zipcode, lastname, firstname)の索引に対し、lastname LIKE '%etrunia%'は区間を狭められないものの、テーブル行を読む前に索引の値で判定してふるい落とします。テーブル行は読むので、Using indexは表示されません。PostgreSQLで同じことを確かめるなら、EXPLAINの使い方の章にあるとおり、Index Condが走査範囲の条件、Filterが取り出した後の条件です。読み方の全体はPostgreSQLの実行計画の読み方|EXPLAIN ANALYZEと見積もり乖離の診断にまとめています。
MySQL 8.0以降とPostgreSQL 18のスキップスキャンで変わった先頭列の常識
「先頭列に条件がなければ複合インデックスは使えない」という説明は、現行版では正確ではありません。両製品とも、条件付きで先頭列を飛ばして読む仕組みを持っています。
MySQLのSkip Scanが選ばれる条件とEXPLAINのExtra列の表示
MySQLの公式マニュアル(9.7系)によれば、Skip Scanは先頭列の異なる値ごとに範囲走査を繰り返す方式です。(f1, f2)の主キーに対しWHERE f2 > 40を投げると、f1 = 1 AND f2 > 40、f1 = 2 AND f2 > 40と順に区間を組んで読みます。
選ばれる条件は厳しめです。単一表であること、GROUP BYとDISTINCTを使わないこと、クエリが索引内の列だけを参照すること、飛ばした列の後ろの列に範囲条件があることなどが求められます。使われるとExtra列にUsing index for skip scanが出ます。optimizer_switchのskip_scanフラグで切り替えられ、既定はonです。SELECT *のように索引外の列を読むクエリでは選ばれないので、設計の前提にはしないでください。
PostgreSQL 18のB-treeスキップスキャンが効く先頭列の値の少なさ
PostgreSQLは18でB-treeのスキップスキャンに対応しました。PostgreSQL 18のリリースノートは、先頭の列に条件がない場合や等価でない条件しかない場合でも、後ろの列に有効な条件があれば複数列のB-tree索引を使える場面が増える、と説明しています。
マニュアルの複数列インデックスの章によれば、この方式が選ばれるのは、先頭列の異なる値が少なく、索引の大半を飛ばせるとプランナが見込んだ場合に限られます。(region, created_at)のように地域が数十種類の索引なら、created_atだけの条件にも使われうる、という程度に理解するのが妥当です。先頭列が会員IDのように値の多い列なら飛ばす意味がなく、従来どおり使われません。
複合インデックスを追加する判断と見送る場面を分ける運用上の条件
ここは判断を言い切ります。複合インデックスは、遅いクエリの条件と並び順が定まっていて、その形が画面やバッチの仕様として固定されている場合に追加します。条件の組み合わせが利用者の操作で毎回変わる検索画面には、複合インデックスを増やして対応しません。
遅いクエリを特定してから索引の列順を決めて追加するまでの3つの手順
- スロークエリログやpg_stat_statementsで、総実行時間の大きいクエリを1本に絞る
- そのクエリの
WHEREとORDER BYを等価・ソート・範囲に分類し、列順を決める - 本番と同程度の行数の環境で索引を作り、EXPLAINで
key_lenとExtraを変更前と比べる
手順1を飛ばして索引を足すと、効かない索引が残って書き込みだけが遅くなります。MySQLでの対象の絞り方はスロークエリログとは|MySQLのクエリログ設定と見方・解析を参照してください。既存の索引の先頭部分と同じ列で始まる索引を作る前に、既存側の列を後ろへ足せば済まないかも確かめます。(tenant_id)と(tenant_id, status)が並んでいるなら、前者は最左一致で後者に吸収できます。
列数を4列以上にする設計と条件が毎回変わる検索画面で追加を見送る判断
PostgreSQLのマニュアルは、複数列インデックスは控えめに使うべきで、3列を超える索引は表の使い方が極めて定型的でない限り役に立ちにくい、と書いています。上限はMySQLが16列、PostgreSQLがINCLUDE列を含めて32列ですが、上限まで使う理由はまずありません。
見送るべき典型は、絞り込み項目が10個並び、利用者が任意に組み合わせる管理画面です。組み合わせごとに索引を用意すると本数が膨らみ、更新のたびにすべての索引を書き換える負担が残ります。この場合は、よく使われる上位2〜3パターンだけに索引を張り、残りは全文検索エンジンや集計用テーブルへ逃がす設計を検討してください。既存システムの索引が増えすぎて更新処理が遅くなっている場合や、移行に合わせて索引設計を見直したい場合は、一創のデータベース設計・移行支援で、実行計画の採取から列順の見直しまでを引き受けています。
複合インデックスの列順・カバリング・確認手順に関するよくある質問
複合インデックスの設計で質問の多い点を5つ取り上げます。
単一列の索引を複数張るのと複合インデックス1本はどちらが速いですか?
条件が常に同じ組み合わせで使われるなら、複合インデックス1本のほうが有利です。MySQLは単一列の索引が複数あるとIndex Mergeによる索引の統合処理で組み合わせるか、より絞り込める1本を選びますが、2本の結果を突き合わせる処理が加わります。複合インデックスなら1回の区間読み取りで済み、並べ替えやカバリングにも使える設計です。条件の組み合わせが定まらない場合は単一列の索引で受けるほうが本数を抑えられます。
複合インデックスは何列まで作れますか?
MySQLの公式マニュアルでは1つの索引に最大16列、PostgreSQLではINCLUDE列を含めて最大32列です。ただし上限は設計の目安になりません。PostgreSQLのマニュアルは3列を超える索引が役に立つ場面は限られるとしており、実務でも3列程度に収まるかを最初に確かめるのが無難です。4列目が欲しくなったら、カバリング目的ならINCLUDEで足すか、主キーを含む仕様で足りないかを確認してください。
列の順番だけが違う複合インデックスを両方作るべきですか?
原則として作りません。順番違いの2本は同じ列のデータを二重に持ち、更新のたびに両方が書き換わります。まずクエリ側の条件を等価・ソート・範囲に分類し、1本で賄える順を探してください。どうしても先頭列の違う検索が両方とも高頻度で遅いときに限り、2本目を検討します。PostgreSQL 18やMySQLのスキップスキャンで2本目が不要になる場合もあるので、追加前にEXPLAINで確かめてください。
カバリングインデックスにすれば常に速くなりますか?
読み取りは速くなりやすいものの、常にではありません。索引に列を足すほど索引が大きくなり、更新処理の負担も増えます。PostgreSQLでは可視性マップの状態によってテーブル本体を読みに行くので、更新の多い表では効果が小さくなります。読む列が少なく、実行回数の多いクエリに絞って使うのが妥当です。
LIKE検索でも複合インデックスは効きますか?
前方一致のLIKE 'abc%'は範囲条件として扱われ、その列までは索引の区間に使われます。範囲条件なので、それより右の列は区間の構築に使われません。LIKE '%abc%'のように先頭が%の条件は区間を狭められず、MySQLではIndex Condition Pushdownで索引の値によるふるい落としに使われる程度です。部分一致の検索が主目的なら、全文検索インデックスを検討してください。
関連記事
- カーディナリティとは?意味・高低の見分け方とインデックス設計を実装目線で解説(等価条件どうしの並びを決める異なり数の測り方)
- PostgreSQLの実行計画の読み方|EXPLAIN ANALYZEと見積もり乖離の診断(索引が選ばれない理由をコストから読む)
- ページネーション設計とは?オフセットとカーソルの選び分けと実装を解説(ORDER BYに合わせた索引が前提になる一覧の実装)
- MongoDBのインデックス設計:createIndexの書き方・ESRの並び順・explainの読み方(同じ列順の考え方をMongoDBで適用する場合)
- スロークエリログとは|MySQLのクエリログ設定と見方・解析を8.4で実測(索引を足す前に対象クエリを絞る手順)