PostgreSQL インデックス設計 チュートリアル

データベースのパフォーマンスは、クエリの書き方以上に「インデックス設計」で決まります。

インデックス設計とは

データベースにおけるインデックス設計とは、膨大なデータから目的の情報を素早く見つけるための「索引」を、どのデータに対して、どのように作成するかを計画することです。インデックスを適切に設計すると、検索速度が劇的に向上しますが、闇雲に作成すると逆にシステムのパフォーマンスを下げてしまうこともあります。

インデックスの仕組み

索引がない場合(フルスキャン)
例えば、1,000ページある技術書から「インデックス」という単語を探したいとします。索引がなければ、1ページ目から順番に最後まで読んでいくしかありません。これをデータベースではフルテーブルスキャンと呼びます。データ量が増えるほど、探す時間は膨大になります。
索引がある場合(インデックススキャン)
巻末の索引で「あ行」の「インデックス」を探せば、「324ページにある」と即座にわかります。辞書本体を全部読まなくても、数ページめくるだけで目的の場所にたどり着けます。これがインデックスの役割です。

「設計」とは

全ての項目に索引をつければ良いのではないかと思うかもしれませんが、それではうまくいかない理由が3つあります。

データベースに対して適切にインデックスを「設計」することが、パフォーマンスを向上させるために必要なのです。

インデックスを作成すべきパターン

レコード数の多いテーブル
レコード数の少ないテーブルではそもそもフルスキャンした方が高速な場合があります。1万レコード以下であればインデックスの効果は薄いです。しかし、サーバーのスペックなどにもよるため実測しながら決定することが望ましいです。

カーディナリティの高いカラム
カーディナリティとは「値の分散度」を示し、これがインデックスを作る際に最も重要な指針となります。特定の列について多くの種類の値を持っていればカーディナリティが高く、少なければカーディナリティが低いということを指します。例えば、性別や都道府県のようにパターン数が決まっている項目はインデックスの効果が望みにくいですが、社員番号やメールアドレスといった重複しない項目はインデックスの効果が高くなります。

SQLの抽出条件や結合条件に使用されるカラム
SQLを発行する際、以下のように使われるカラムにインデックスを作成することで効果を発揮します。

逆に言えば検索条件や結合条件に利用されないカラムにインデックスを作成するのは無意味です。

事前準備:SQL演習環境の構築(任意)

SQL演習環境を作成する場合は以下の手順を実行してください。

Docker

  1. Docker Desktopの準備: Docker Desktopをインストールし、起動状態(Running)にしておきます。
  2. イメージの取得: 公式リポジトリ(Docker Hub)からPostgreSQLとDbGateのイメージを取得(pull)します。
docker image pull postgres:17.6
docker image pull dbgate/dbgate:6.6.3
  1. コンテナの作成と起動: docker container runコマンドを使用し、環境変数やポートマッピング(-p 5432:5432)、バックグラウンド実行(-d)を指定してコンテナを起動します。
docker container run --name pg17 -e POSTGRES_USER=student -e POSTGRES_PASSWORD=secret123 -e POSTGRES_DB=playground -p 5432:5432 -d postgres:17.6

SQL演習用環境

  1. リポジトリのクローン: 演習用教材のGitリポジトリ(https://github.com/TakeshiWada1980/DB-2025-PostgreSQL.git)をローカルに複製し、VSCodeで開きます。
  2. 依存関係のインストール: npm iコマンドを実行し、プロジェクトに必要なライブラリをインストールします。
  3. 環境変数の設定: プロジェクトルートに.envファイルを作成し、PostgreSQLへの接続文字列を記述します。
DATABASE_URL="postgresql://student:secret123@localhost:5432/playground?schema=public"
  1. コンテナの起動: npm run db:upコマンドを実行します。これにより、PostgreSQLとDbGate(DB管理ツール)のコンテナがまとめて起動します。

事前準備:データ生成 (init_data.sql)

まずは実験台となる100万件の注文履歴データを作成します。2章の演習でも同じ orders テーブルを使用します。

貼り付けて実行してください。

-- 100万件のダミーデータ生成スクリプト
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
    order_id SERIAL PRIMARY KEY,
    customer_id INTEGER NOT NULL,
    order_date DATE NOT NULL,
    status VARCHAR(20) NOT NULL,
    total_amount DECIMAL(10, 2) NOT NULL,
    region VARCHAR(50) NOT NULL
);
INSERT INTO orders (customer_id, order_date, status, total_amount, region)
SELECT 
    (random() * 10000)::INTEGER + 1,
    CURRENT_DATE - (random() * 365 * 3)::INTEGER,
    (ARRAY['pending', 'processing', 'shipped', 'delivered', 'cancelled'])[floor(random() * 5 + 1)],
    (random() * 10000)::DECIMAL(10, 2),
    (ARRAY['Tokyo', 'Osaka', 'Nagoya', 'Fukuoka', 'Sapporo'])[floor(random() * 5 + 1)]
FROM generate_series(1, 1000000);
ANALYZE orders;

SQLの意味は次の通りです。

customer_id: 10,000倍して整数化 → 1〜10,000のランダムな顧客ID

order_date: 今日を起点として0〜3年分の日付をランダムに減算
→ 過去3年程度のランダムな注文日

status: pending / processing / shipped / delivered / cancelled のいずれかをランダムに付与

total_amount: 0〜10,000円程度のランダムな金額

region: Tokyo / Osaka / Nagoya / Fukuoka / Sapporo のいずれかをランダムに付与

ANALYZE orders;

ANALYZE orders;

PostgreSQLのクエリオプティマイザに判断材料を与えるためのコマンド。

クエリオプティマイザとは
PostgreSQLは、クエリを実行する前に「どの方法が一番速いか」を自動判断します。その判断に必要な情報が統計情報です。

ANALYZEで収集される情報

ANALYZEが必要なタイミング

出力の見方

ANALYZEを実行すると、画面上には ANALYZE と1行だけ表示されます。これは統計情報の収集が正常に完了したことを意味します。収集された統計はDB内部に保存され、その後のクエリ計画の作成時に自動的に利用されます。

統計情報の効果を確認するには、EXPLAIN ANALYZE 付きでクエリを実行します。主な出力項目は以下の通りです。

ANALYZEを実行していないと、オプティマイザが誤った計画を選び、rows の見積もりが大きく外れることがあります。必ずデータ投入後・インデックス作成後に実行してください。

パフォーマンスの計測

📝 演習:インデックスなしで検索

まだインデックスを作成していない状態で検索を実行し、どのような計画になるかを確認します。

EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 5000;

SQLの意味は次の通りです。

確認ポイント

Seq Scan が表示され、実行時間が長めになることを確認してください。これが「インデックスなし検索」の基準になります。

📝 演習:インデックス設計後の検索

次にインデックスを作成し、同じ検索を実行して効果を確認します。

START TRANSACTION;
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
ANALYZE orders;
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 5000;
ROLLBACK;

SQLの意味は次の通りです。

この例は単一列に素直にインデックスを貼るだけなので、設計の工夫はほとんどありません。インデックス作成後は、DBが自動的に適切な実行計画を選ぶため「設計している感」は薄いケースです。

確認ポイント

Index Scan が表示され、実行時間が大きく短くなることを確認してください。先ほどの結果と比べると差がはっきり分かります。

なぜ単一列インデックスで速くなるのか

この例では customer_id にB-Treeというものが作成されます。内部的には「customer_id の値」と「該当する行の場所(TID)」の対応表が作られ、木構造として整理されます。

検索時は、テーブル全体を順番に見るのではなく、B-Treeを辿って該当する customer_id の位置に直接ジャンプします。さらに一致する行の場所がまとまっていれば、読み込みの回数も減るため、全件走査よりも大幅に高速化されます。

結果として、Seq Scan から Index Scan に変わり、処理量が大きく削減されます。

1. B-Treeのデータ構造について

インデックスがない場合、DBは1枚ずつページをめくって探します(全件走査:Seq Scan)。100万件なら100万回のステップが必要です。

B-Treeインデックスは、データを木構造で整理します。100万件あっても、わずか数回の「枝分かれ」を辿るだけで目的のデータに辿り着きます。

B-Treeのデータ構造について、構成要素・検索処理・ノード分割とマージの観点で整理します。

B-Treeの構成要素

B-Treeは、ルートノード、内部ノード、リーフノードという3つのタイプのノードによって構成される階層的なデータ構造です。

B-Treeのデータ構造

階層構造の頂点に位置するのがルートノードであり、このノードは親を持たず、木構造の出発点となります。

階層の最下部に存在するリーフノードは、子ノードを持ちません。

内部ノードは、ルートノードとリーフノードの中間に配置されるノードで、効率的なデータ検索を実現するための案内役として機能します。

B-Treeの各ノードには、複数のキー値とポインタが保存されます。キー値は常にソート済みの状態で保持され、ノード内では昇順に配列されています。例として、従業員IDをキーとして利用する際には、ノード内のキー値は小さい値から順に配列されます。

各ノードに保存可能なキーの個数には、上限値と下限値が定められています。この上限値と下限値は、B-Treeの次数(degree)により決まります。次数mのB-Treeでは、各ノードは最小(m/2)-1個、最大(m-1)個のキーを保持できます。ただし、ルートノードについては例外的に、最小1個のキーを保持すればよいとされています。

ノード内のキー値は、その子ノードが保持するキー値の範囲を表します。あるキー値kを保持するノードの場合、その左側の子ノードにはkより小さい値のキーが、右側の子ノードにはkより大きい値のキーが保存されます。このルールはB-Tree全体において一貫して適用されています。

具体例として、ノードに[10, 20, 30]というキー値が保存されている場合、このノードは4つの子ノードを保持します。最左の子ノードには10未満のキー値、2番目の子ノードには10以上20未満のキー値、3番目の子ノードには20以上30未満のキー値、最右の子ノードには30以上のキー値が保存されます。

B-Treeのノードのデータ構造 B-Treeのノードのデータ構造

このように、B-Treeは厳密なルールに基づいて構造化されており、これによって効率的なデータ検索が実現されています。

B-Treeでのデータ検索は、ルートノードを起点として、目標とするデータが発見されるまで、あるいはデータの非存在が判明するまで、段階的に下層のノードへと移動していきます。この検索処理について、具体的な手順を説明します。

検索の最初のステップは、ルートノードでの照合です。ルートノードでは、検索対象のキー値と、ノード内に保存されているキー値を照合します。ノード内のキー値はソートされているため、二分探索を利用して効率的に照合を実施できます。

具体例として、従業員ID「22」を検索するケースを考えます。ルートノードに[10, 20, 30]というキー値が保存されているとすると、まず「22」と中央の値「20」を照合します。「22」は「20」より大きいため、次に「30」との照合を実施します。「22」は「30」より小さいため、3番目の子ノード(「20」と「30」の間に対応する子ノード)へ検索を進めます。

この処理は、子ノードにおいても同様に繰り返されます。各ノードでは、保存されているキー値との照合を実施し、適切な子ノードを選んで検索を継続します。例として、次のノードに[21, 22, 23]というキー値が保存されている場合、検索対象の「22」が発見されたため、検索はここで完了します。

B-Treeの検索処理

検索対象のキー値が存在しないケースでは、検索はリーフノードまで到達します。リーフノードでキー値が発見できない場合、そのキー値はB-Tree内に存在しないことが判明します。

B-Treeの重要な性質として、すべてのパス(ルートノードからリーフノードまでの経路)の長さが等しいことが挙げられます。これにより、どのデータを検索するケースでも、ルートノードからリーフノードまでの移動回数は同一になります。この性質により、検索時間の予測が容易になり、安定したパフォーマンスを提供できます。

B-Treeの検索パフォーマンスは、木の高さに大きく依存します。例として、1000万件のデータを保存するB-Treeであっても、1ノードあたり100個のキーを保存できるケースでは、高さは4程度に抑えられます。これは、各レベルでの検索に必要な照合回数が最大でも7回程度(100個のキーに対する二分探索)であることを意味します。

コラム:pageinspectでB-Treeの深さを確認する

PostgreSQLの拡張機能 pageinspect を使うと、実際のインデックスが何階層のB-Treeになっているかを確認できます。以下のSQLを順に実行してください(パフォーマンスの計測ではROLLBACKでインデックスが破棄されるため、ここで改めてインデックスを作成します)。

-- 1. インデックスを作成(未作成の場合)
CREATE INDEX IF NOT EXISTS idx_orders_customer_id ON orders(customer_id);
ANALYZE orders;

-- 2. 拡張機能を有効化
CREATE EXTENSION IF NOT EXISTS pageinspect;

-- 3. インデックスのメタデータを見て、木の深さ(level)を確認する
SELECT * FROM bt_metap('idx_orders_customer_id');

level 列に表示される値が、ルートからリーフまでの階層数(深さ)です。100万件の orders テーブルでは、おおよそ2〜3程度になることが多く、これが「数回の枝分かれで目的のデータに辿り着く」ことを数値で裏付けます。

B-Treeのノード分割とマージの処理

B-Treeでは、データの追加や削除に伴い、ノードの分割やマージが実施されます。これらの処理は、B-Treeのバランスを維持し、効率的な検索パフォーマンスを保つために重要な役割を担います。

ノードの分割は、新規データを追加する際にノードが最大容量に到達した場合に実施されます。例として、最大4つのキーを保存できるノードに、すでに4つのキー[10, 20, 30, 40]が保存されている状態で、新規キー「25」を追加するケースを考えます。このノードはすでに最大容量に到達しているため、分割が必要になります。

分割の処理は以下のように実施されます。まず、ノード内のキーを順序に従ってソートします。この例では[10, 20, 25, 30, 40]となります。次に、この5つのキーの中央値「25」を選択し、これを親ノードへ昇格させます。残りのキーのうち、「25」より小さいキー[10, 20]は元のノードに保持し、大きいキー[30, 40]は新しく生成したノードへ移動させます。

B-Treeのノードの分割

ノードのマージは、データの削除によりノードのキー数が最小容量を下回った場合に実施されます。例として、最小2つのキーが必要なノードで、キーを削除した結果、1つのキーしか残らなくなったケースです。

マージの処理では、まず隣接するノードを確認します。そのノードに十分な空き容量がある場合、両方のノードのキーを1つのノードに統合します。この際、親ノードから適切なキーを降格させ、マージ後のノードに含めます。

具体例として、2つの隣接するノードがあり、一方に[10]、もう一方に[30, 40]というキーが保存されており、親ノードに「20」というキーがあるケースでは、マージ後は1つのノードに[10, 20, 30, 40]というキーが保存されます。

B-Treeのノードのマージ B-Treeのノードのマージ

これらの分割とマージの処理は、必要に応じて親ノードにも伝播していきます。つまり、親ノードでも同様の分割やマージが実施される可能性があります。この伝播は、必要に応じてルートノードまで継続されます。

1.1 B+Treeの構造とメリット

これまで、B-Treeについての説明をしましたが、データベースにおいて主流なのは「B+Tree」というものです。B+TreeはB-Treeをさらにデータベースの検索(特に範囲検索)に特化させた進化形です。

B+Treeとは

B+Treeは、B-Treeの構造をベースにしつつ、「データの持ち方」と「ノード間のつながり」を改良したデータ構造です。現在のリレーショナルデータベース(MySQLのInnoDB、PostgreSQLなど)のインデックスにおいて、最も一般的に採用されています。

B-Treeとの違い

B-TreeとB+Treeの最大の違いは、「サテライトデータ(実際のレコードやポインタ)」をどこに配置するかにあります。

データベース設計におけるメリット

なぜB-TreeではなくB+Treeが選ばれるのか、その理由は運用の効率性にあります。

  1. 範囲検索(Range Scan)が圧倒的に速い
    B-Treeで「10から20までのデータを取得する」場合、何度もツリーを上下に移動(再帰的な探索)する必要があります。B+Treeなら、「10」を一度見つけたら、あとはリーフノードを横にたどるだけで「20」まで辿り着けます。これが SELECT * FROM table WHERE age BETWEEN 20 AND 30 といったクエリが高速な理由です。
  2. キャッシュ効率(入出力効率)が良い
    内部ノードにデータを持たないため、1つのノード(ディスクブロック)により多くの「キー」を詰め込むことができます。分岐数が増えることでツリーの高さが低くなり、1回のディスクI/Oで読み込めるインデックス情報が増えるため、検索性能が安定します。
  3. 全走査(Full Scan)の簡便さ
    全データを走査する場合、ツリー全体をなぞる必要はなく、リーフノードの端から端までをなぞるだけで済みます。

2. 複合インデックスの設計と最適化

2.1 複合インデックスを使用する理由

単一カラムインデックスの限界

データベースで検索を行う際、多くの場合、複数の条件を組み合わせて検索します。例えば、ECサイトで「東京都内で、配送済みの、2024年1月以降の注文」を検索する場合、region、status、order_dateという3つの条件が必要です。

もし各カラムに個別のインデックスがあったとしても、PostgreSQLは基本的に1つのクエリで1つのインデックスしか使用しません(Bitmap Index Scanを除く)。つまり、regionのインデックスだけを使って該当する行を絞り込んだ後、残りの条件は実際のテーブルデータを読み込んでフィルタリングする必要があります。

複合インデックスを使用することで、複数の条件を同時にインデックス内で処理でき、テーブル本体へのアクセスを最小限に抑えられます。

複合インデックスの内部構造

複合インデックスは、指定した順序でカラムを連結したキーとして構築されます。例えば (region, status, order_date) というインデックスは、内部的に以下のように整列されています:

('Fukuoka', 'cancelled', '2024-01-15')
('Fukuoka', 'cancelled', '2024-01-20')
('Fukuoka', 'delivered', '2024-01-10')
('Fukuoka', 'delivered', '2024-01-18')
('Fukuoka', 'pending', '2024-01-05')
('Osaka', 'cancelled', '2024-01-12')
('Osaka', 'delivered', '2024-01-08')
...
('Tokyo', 'shipped', '2024-01-25')
('Tokyo', 'shipped', '2024-01-30')

この構造から分かるように、第1カラムでまず大きくグループ化され、その中で第2カラムがソートされ、さらにその中で第3カラムがソートされるという階層的な構造になっています。

2.2 カラム順序の決定原則

複合インデックスで最も重要なのはカラムの順序です。順序を間違えると、インデックスが全く使われない、あるいは効率が大幅に低下する可能性があります。

原則1: 等価条件(=)を優先的に先頭に配置する

検索条件には「等価条件」(column = value)と「範囲条件」(column > value、BETWEEN、LIKE 'prefix%')があります。

等価条件は、インデックス内で該当する範囲を一点に絞り込むことができます。例えば region = 'Tokyo' という条件は、インデックス内の「Tokyoで始まる部分」だけを対象にします。
一方、範囲条件は、該当する範囲全体をスキャンする必要があります。order_date >= '2024-01-01' という条件は、該当日以降のすべての行を確認しなければなりません。

-- 良い例: 等価条件(status)を先頭に
CREATE INDEX idx_good ON orders(status, order_date);
ANALYZE orders;

-- このクエリは効率的
SELECT * FROM orders 
WHERE status = 'shipped'  -- 等価条件でまず絞り込み
  AND order_date >= '2024-01-01';  -- 残りの範囲を検索

「idx_good」を使用すると、インデックス内で「statusが'shipped'」の部分だけを特定し、その中でorder_dateの条件に合う行を探します。

-- 悪い例: 範囲条件(order_date)を先頭に
CREATE INDEX idx_bad ON orders(order_date, status);
ANALYZE orders;

-- 同じクエリでも非効率
SELECT * FROM orders 
WHERE status = 'shipped'
  AND order_date >= '2024-01-01';

「idx_bad」を使用すると、order_date >= '2024-01-01' の範囲全体をスキャンし、その後各行でstatus = 'shipped'かどうかを確認する必要があります。

📝 演習

等価条件を先頭にした場合と範囲条件を先頭にした場合の実行計画・実行時間を比較しましょう。

ステップ1:悪い順序のインデックスで計測

-- 既存インデックスを削除し、悪い順序で作成
DROP INDEX IF EXISTS idx_good;
DROP INDEX IF EXISTS idx_bad;
CREATE INDEX idx_bad ON orders(order_date, status);
ANALYZE orders;

-- 実行計画と実行時間を確認
EXPLAIN ANALYZE
SELECT * FROM orders WHERE status = 'shipped' AND order_date >= '2024-01-01';

ステップ2:良い順序のインデックスで計測

-- 悪いインデックスを削除し、良い順序で作成
DROP INDEX idx_bad;
CREATE INDEX idx_good ON orders(status, order_date);
ANALYZE orders;

-- 同じクエリで実行計画と実行時間を確認
EXPLAIN ANALYZE
SELECT * FROM orders WHERE status = 'shipped' AND order_date >= '2024-01-01';
確認ポイント

「Index Scan using idx_bad」ではスキャン行数が多くなり、実行時間も長くなります。「Index Scan using idx_good」では、status='shipped'の部分だけを効率的にスキャンするため、行数・実行時間ともに大幅に改善することを確認してください。

演習:カラム順序を答えよ

以下の各クエリに対して、原則1に基づき複合インデックスのカラム順序を考え、答えなさい。

問題1 クエリ: WHERE status = 'delivered' AND order_date >= '2024-06-01'

解答

CREATE INDEX idx ON orders(status, order_date);

等価条件の status を先頭に、範囲条件の order_date を後に配置する。

問題2 クエリ: WHERE region = 'Osaka' AND order_date BETWEEN '2024-01-01' AND '2024-12-31'

解答

CREATE INDEX idx ON orders(region, order_date);

等価条件の region を先頭に、範囲条件の order_date を後に配置する。

問題3 クエリ: WHERE customer_id = 1234 AND status = 'pending' AND order_date > '2024-01-01'

解答

CREATE INDEX idx ON orders(customer_id, status, order_date);

等価条件の customer_id, status を先頭に並べ、範囲条件の order_date を最後に配置する。

問題4 クエリ: WHERE total_amount >= 5000 AND region = 'Tokyo'

解答

CREATE INDEX idx ON orders(region, total_amount);

等価条件の region を先頭に、範囲条件の total_amount を後に配置する。

原則2: 選択性(多重度)が高いカラムを優先

選択性とは、「そのカラムの条件でどれだけデータを絞り込めるか」を示す指標です。選択性が高いほど、少ない行数に絞り込めます。

選択性 = 該当する行数 / 全体の行数
値が小さいほど選択性が高く、効率的に絞り込めます。

カーディナリティが高いカラムは、通常、選択性も高くなります。

例: customer_id: 10,000種類の値(カーディナリティ高)→ 各値で約100行に絞り込める
status: 5種類の値(カーディナリティ低)→ 各値で約20万行に絞り込める

100万行のテーブルで customer_id = 5000 を検索すると約100行に絞り込めますが、status = 'shipped' では約20万行が該当します。

-- カーディナリティの確認
SELECT 
    COUNT(DISTINCT customer_id) as customer_cardinality,
    COUNT(DISTINCT status) as status_cardinality,
    COUNT(DISTINCT region) as region_cardinality,
    COUNT(*) as total_rows
FROM orders;

適用例:

-- 選択性を考慮した順序
CREATE INDEX idx_selective ON orders(customer_id, status, order_date);
ANALYZE orders;
-- customer_id(高選択性) → status(低選択性) → order_date(範囲)

-- このクエリは効率的
SELECT * FROM orders
WHERE customer_id = 5000    -- まず100行程度に絞り込み
  AND status = 'shipped'    -- さらに20行程度に
  AND order_date >= '2024-01-01';  -- 最終的に数行に
📝 演習

選択性の高いカラム(customer_id)を先頭にした場合と、低いカラム(status)を先頭にした場合のスキャン行数を比較しましょう。

ステップ1:カーディナリティを確認

SELECT 
    COUNT(DISTINCT customer_id) as customer_cardinality,
    COUNT(DISTINCT status) as status_cardinality,
    COUNT(*) as total_rows
FROM orders;

ステップ2:選択性が低い順序(status, customer_id)で計測

-- statusを先頭にしたインデックス(選択性が低い)
DROP INDEX IF EXISTS idx_selective;
DROP INDEX IF EXISTS idx_low_selective;
CREATE INDEX idx_low_selective ON orders(status, customer_id);
ANALYZE orders;

EXPLAIN ANALYZE
SELECT * FROM orders 
WHERE customer_id = 5000 AND status = 'shipped' AND order_date >= '2024-01-01';

ステップ3:選択性が高い順序(customer_id, status, order_date)で計測

-- customer_idを先頭にしたインデックス(選択性が高い)
DROP INDEX idx_low_selective;
CREATE INDEX idx_selective ON orders(customer_id, status, order_date);
ANALYZE orders;

EXPLAIN ANALYZE
SELECT * FROM orders 
WHERE customer_id = 5000 AND status = 'shipped' AND order_date >= '2024-01-01';
確認ポイント

idx_low_selectiveでは、status='shipped'の約20万行をスキャンしてからcustomer_idで絞り込むため、処理行数が多くなります。idx_selectiveでは、まずcustomer_id=5000の約100行に絞り込み、その中でstatusとorder_dateを条件に使うため、スキャン行数が大幅に少なくなります。実行時間の差も確認してください。

演習:カラム順序を答えよ

以下の各クエリに対して、原則2に基づき複合インデックスのカラム順序を考え、答えなさい。カラムの選択性(カーディナリティ)を考慮すること。

問題1 クエリ: WHERE customer_id = 5000 AND status = 'shipped'

解答

CREATE INDEX idx ON orders(customer_id, status);

customer_id は約1万種で選択性が高く、status は5種で選択性が低い。高選択性の customer_id を先頭に配置する。

問題2 クエリ: WHERE region = 'Tokyo' AND status = 'delivered' AND order_date >= '2024-01-01'

解答

CREATE INDEX idx ON orders(region, status, order_date); または (status, region, order_date)

region と status はどちらも5種程度で選択性は同程度。order_date は範囲条件のため後方に配置。前半は等価条件を並べる。

問題3 クエリ: WHERE status = 'cancelled' AND customer_id = 8000 AND order_date BETWEEN '2024-01-01' AND '2024-12-31'

解答

CREATE INDEX idx ON orders(customer_id, status, order_date);

customer_id が最も選択性が高いため先頭。次に status、最後に範囲条件の order_date。

問題4 クエリ: WHERE region = 'Osaka' AND customer_id = 3000

解答

CREATE INDEX idx ON orders(customer_id, region);

customer_id(約1万種)は region(5種)より選択性が高いため、customer_id を先頭に配置する。

原則3: 使用頻度を考慮(最左プレフィックスルール)

すべてのクエリが全てのカラムを使用するとは限りません。インデックスの最左プレフィックスルールにより、インデックスの左側(先頭)のカラムだけでも利用できます。

最左プレフィックスルールとは: (A, B, C) という複合インデックスは、以下の検索条件で利用可能です:
A / A, B / A, B, C。
B、C、B, Cは利用できません(または部分的にしか利用できません)。

実用例:

CREATE INDEX idx_multi_use ON orders(customer_id, order_date, status);
ANALYZE orders;

-- ✓ インデックスを完全に利用
SELECT * FROM orders WHERE customer_id = 100 AND order_date >= '2024-01-01' AND status = 'shipped';

-- ✓ インデックスを部分的に利用(customer_id, order_date)
SELECT * FROM orders WHERE customer_id = 100 AND order_date >= '2024-01-01';

-- ✓ インデックスを部分的に利用(customer_idのみ)
SELECT * FROM orders WHERE customer_id = 100;

-- ✗ インデックスを利用できない
SELECT * FROM orders WHERE order_date >= '2024-01-01';

-- ✗ インデックスを利用できない
SELECT * FROM orders WHERE status = 'shipped';

したがって、最も頻繁に単独で検索されるカラムを先頭に配置すると、複数のクエリパターンに対応できます。

📝 演習

最左プレフィックスルールにより、先頭カラムを使うクエリはインデックスを利用し、先頭を使わないクエリはSeq Scanになることを確認しましょう。

ステップ1:複合インデックスを用意

DROP INDEX IF EXISTS idx_multi_use;
CREATE INDEX idx_multi_use ON orders(customer_id, order_date, status);
ANALYZE orders;

ステップ2:先頭カラム(customer_id)を使用するクエリ

EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 100;

ステップ3:先頭カラムを使わないクエリ(order_dateのみ

EXPLAIN ANALYZE SELECT * FROM orders WHERE order_date >= '2024-01-01';

ステップ4:先頭カラムを使わないクエリ(statusのみ

EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'shipped';
確認ポイント

ステップ2では「Index Scan using idx_multi_use」が表示され、少数行のスキャンで高速に完了します。ステップ3・4では「Seq Scan on orders」が表示され、約100万行をスキャンするため実行時間が長くなります。同じインデックスでも、検索条件に先頭カラムが含まれているかどうかで結果が大きく異なることを確認してください。

原則4: ソート(ORDER BY)との関連性

複合インデックスは、カラムの順序でソート済みのデータ構造です。この特性を活用すると、ORDER BY句の処理を高速化できます。

-- インデックス作成
CREATE INDEX idx_sort ON orders(customer_id, order_date DESC);
ANALYZE orders;

-- このクエリではソート処理が不要(インデックスがすでにソート済み)
SELECT * FROM orders
WHERE customer_id = 100
ORDER BY order_date DESC
LIMIT 10;

EXPLAINの結果に「Sort」ステップが現れなければ、インデックスのソート順序が活用されています。

注意点: PostgreSQLでは、インデックス作成時に各カラムのASC/DESCを指定できます。

CREATE INDEX idx_custom_sort ON orders(region ASC, order_date DESC, total_amount DESC);
ANALYZE orders;

これにより、ORDER BY region ASC, order_date DESC, total_amount DESC というソート順序に最適化されます。

📝 演習

インデックスのソート順序とORDER BYが一致する場合(Sortステップなし)と、一致しない場合(Sortステップあり)の実行計画を比較しましょう。

ステップ1:ORDER BYと整合したインデックスがない状態で計測(Sortが発生)

-- ORDER BYと整合していないインデックス、またはインデックスなしで計測
DROP INDEX IF EXISTS idx_sort;
CREATE INDEX idx_customer_only ON orders(customer_id);
ANALYZE orders;

EXPLAIN ANALYZE
SELECT * FROM orders
WHERE customer_id = 100
ORDER BY order_date DESC
LIMIT 10;

ステップ2:ORDER BYと整合したインデックスで計測(Sortが不要)

-- order_date DESCを含むインデックスでORDER BYが最適化される
DROP INDEX idx_customer_only;
CREATE INDEX idx_sort ON orders(customer_id, order_date DESC);
ANALYZE orders;

EXPLAIN ANALYZE
SELECT * FROM orders
WHERE customer_id = 100
ORDER BY order_date DESC
LIMIT 10;
確認ポイント

ステップ1では実行計画に「Sort」ステップが含まれ、メモリ上でソート処理が行われます。ステップ2では「Sort」が現れず、「Index Scan Backward using idx_sort」のように、インデックスがすでにorder_date DESCで並んでいるため、ソート処理がスキップされます。LIMIT 10の取得も効率化され、実行時間の短縮を確認できます。

2.3 複合インデックス設計の実践的アプローチ

📝 演習:複合インデックス設計の実践的アプローチ

ステップ1: クエリパターンの分析

まず、アプリケーションで実行される主要なクエリパターンを特定します。

-- よくあるクエリパターンをリストアップ
-- Q1: 顧客の注文履歴(頻度: 高)
WHERE customer_id = ? AND order_date >= ?

-- Q2: ステータス別の地域検索(頻度: 中)
WHERE region = ? AND status = ?

-- Q3: 期間集計(頻度: 低)
WHERE order_date BETWEEN ? AND ?
GROUP BY ...

ステップ2: カラムの特性を評価

各カラムのカーディナリティと選択性を確認します。

SELECT 
    COUNT(DISTINCT customer_id) as customer_distinct,
    COUNT(DISTINCT region) as region_distinct,
    COUNT(DISTINCT status) as status_distinct,
    COUNT(DISTINCT order_date) as date_distinct,
    COUNT(*) as total,
    -- 選択性の推定
    COUNT(*) / COUNT(DISTINCT customer_id)::float as avg_rows_per_customer,
    COUNT(*) / COUNT(DISTINCT region)::float as avg_rows_per_region,
    COUNT(*) / COUNT(DISTINCT status)::float as avg_rows_per_status
FROM orders;

ステップ3: 優先順位の決定

-- Q1向け: customer_idが高選択性で等価条件
CREATE INDEX idx_customer_history ON orders(customer_id, order_date DESC);

-- Q2向け: regionとstatusの両方が等価条件、regionの選択性がやや高い
CREATE INDEX idx_region_status ON orders(region, status);
ANALYZE orders;

ステップ4: 検証と調整

実際のデータでEXPLAIN ANALYZEを使用して検証します。

EXPLAIN (ANALYZE, BUFFERS) 
SELECT * FROM orders 
WHERE customer_id = 100 
  AND order_date >= '2024-01-01'
ORDER BY order_date DESC 
LIMIT 10;
確認ポイント
  • 想定したインデックスが使用されているか
  • Seq Scan(全表スキャン)になっていないか
  • Sortステップが発生していないか
  • 実行時間が要件を満たしているか

2.4 複合インデックスの注意点

注意点1: インデックスが多すぎるコスト

インデックスは検索を高速化しますが、以下のコストがかかります。

-- テーブルとインデックスのサイズ確認
SELECT 
    pg_size_pretty(pg_relation_size('orders')) as table_size,
    pg_size_pretty(pg_indexes_size('orders')) as indexes_size,
    pg_size_pretty(pg_total_relation_size('orders')) as total_size;

推奨: 1つのテーブルに対して5〜10個程度のインデックスが目安です。それ以上増やす場合は、本当に必要か慎重に検討してください。

注意点2: カラム順序の変更は別インデックス

(A, B) と (B, A) はまったく異なるインデックスです。両方作成すると、ストレージと更新コストが2倍になります。どちらの順序が必要かを明確にし、本当に両方必要な場合だけ作成してください。

注意点3: NULL値の扱い

PostgreSQLでは、NULLもインデックスに含まれます(他のRDBMSと異なる場合あり)。IS NULLやIS NOT NULLの検索にもインデックスが使えます。

-- NULLを含む検索でもインデックスが有効
SELECT * FROM orders WHERE cancelled_at IS NOT NULL;

ただし、ほとんどの値がNULLの場合は、部分インデックスの使用を検討してください。

-- NULL以外だけをインデックス化(サイズ削減)
CREATE INDEX idx_cancelled ON orders(cancelled_at)
WHERE cancelled_at IS NOT NULL;

2.5 演習問題集

📝 演習問題1: 最適な複合インデックスの設計

以下のクエリパターンに最適な複合インデックスを設計してください。

-- クエリA: 地域と期間での集計
SELECT region, COUNT(*), SUM(total_amount)
FROM orders
WHERE region = 'Tokyo' 
  AND order_date BETWEEN '2024-01-01' AND '2024-03-31'
GROUP BY region;

-- クエリB: 顧客の最近の注文検索
SELECT * FROM orders
WHERE customer_id = 1234 
  AND order_date >= CURRENT_DATE - INTERVAL '30 days'
ORDER BY order_date DESC
LIMIT 10;

検証

-- クエリAを実行
EXPLAIN ANALYZE
SELECT region, COUNT(*), SUM(total_amount)
FROM orders
WHERE region = 'Tokyo' 
  AND order_date BETWEEN '2024-01-01' AND '2024-03-31'
GROUP BY region;

-- クエリBを実行
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE customer_id = 1234 
  AND order_date >= CURRENT_DATE - INTERVAL '30 days'
ORDER BY order_date DESC
LIMIT 10;

確認事項

演習問題1の解答例
-- クエリA用(等価条件のregionを先頭に)
CREATE INDEX idx_a ON orders(region, order_date);

-- クエリB用(customer_idを先頭に、order_dateで範囲検索とソート)
CREATE INDEX idx_b ON orders(customer_id, order_date DESC);

解説: クエリAはregion = 'Tokyo'という等価条件でまず絞り込み、その中でorder_dateの範囲を検索します。クエリBのDESC指定により、ORDER BY order_date DESCが効率化されます。インデックスがすでに降順でソートされているため、追加のソート処理が不要です。

📝 演習問題2: 複雑なクエリへの対応

次のような複雑なクエリに対して、最適なインデックス戦略を考えてください。

-- クエリC: ステータスと金額による絞り込み
SELECT * FROM orders
WHERE status IN ('processing', 'shipped')
  AND total_amount >= 5000
  AND region = 'Tokyo'
ORDER BY order_date DESC
LIMIT 100;

検証と考察

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders
WHERE status IN ('processing', 'shipped')
  AND total_amount >= 5000
  AND region = 'Tokyo'
ORDER BY order_date DESC
LIMIT 100;

考察ポイント

演習問題2の解答例
CREATE INDEX idx_c ON orders(region, status, order_date DESC);

解説: regionとstatusは両方とも等価条件(IN句も等価として扱える)。regionはカーディナリティが低いですが、selectivityは高い(5つの値のうち1つ)。total_amountは範囲条件なので含めませんが、残りの条件で十分絞り込めます。order_date DESCでソートも最適化します。代替案として (status, region, order_date DESC) も有効です。実際のデータ分布とクエリ頻度で決定してください。

📝 演習問題3: Index Only Scan

インデックスは単に検索を速くするだけでなく、テーブル本体へのアクセスを省略する(Index Only Scan)ためにも利用できます。

課題

以下のクエリは、特定の顧客の注文ごとの合計金額を取得するものです。このクエリに対して、テーブル本体(ヒープ領域)へのアクセスを発生させず、インデックスだけで処理が完結するようなインデックスを作成してください。

-- 対象クエリ
SELECT order_id, total_amount 
FROM orders 
WHERE customer_id = 5000;

ヒント

EXPLAIN ANALYZE の出力に Heap Fetches: 0 または Index Only Scan が表示されることを目指します。インデックスには検索条件だけでなく、取得するカラムも含めることができます。

演習問題3の解答例
-- 解答: 検索条件(customer_id)に加え、取得列(order_id, total_amount)も含める
CREATE INDEX idx_covering ON orders(customer_id, order_id, total_amount);
ANALYZE orders;

解説: インデックスの中に SELECT 句で必要なデータがすべて含まれていれば、DBはわざわざテーブル本体を見に行く必要がなくなります。これを「カバリングインデックス」と呼びます。特に読み取りが多いクエリで劇的な効果を発揮します。

📝 演習問題4: 関数インデックス (Function-Based Index)

背景

ユーザーから「地域名(region)を小文字で検索してもヒットするようにしてほしい」という要望がありました。

課題

以下のクエリを高速化するインデックスを作成してください。通常のインデックスでは関数(LOWER)を通した検索条件には適用されません。解決策を見つけてください。

-- 対象クエリ
EXPLAIN ANALYZE
SELECT * FROM orders 
WHERE LOWER(region) = 'tokyo';
演習問題4の解答例
-- 解答: カラムそのものではなく、式に対してインデックスを貼る
CREATE INDEX idx_lower_region ON orders(LOWER(region));
ANALYZE orders;

解説: カラムの値そのものではなく、計算結果や関数適用後の値に対して作成するインデックスを「関数インデックス」と呼びます。検索条件とインデックス定義の式が完全に一致する場合にのみ使用されます。

📝 演習問題5: 部分インデックスによる効率化

背景

システム管理者から「キャンセルされた注文(status = 'cancelled')の調査を頻繁に行うが、キャンセル率は全体の1%程度しかない。検索は速くしたいが、全データにインデックスを貼って容量を圧迫するのは避けたい」という相談を受けました。

課題

以下のクエリを高速化しつつ、インデックスのサイズを必要最小限に抑える設計を行ってください。

-- 対象クエリ
SELECT * FROM orders 
WHERE status = 'cancelled' 
  AND total_amount >= 10000;

検証用SQL

通常の複合インデックスと、今回作成するインデックスのサイズを比較してみましょう。

-- 比較用:通常のインデックス
CREATE INDEX idx_full ON orders(status, total_amount);
ANALYZE orders;

-- ★ここに解答のインデックスを作成★

-- サイズ比較(解答のインデックス名に置き換えて実行)
SELECT 
    pg_size_pretty(pg_relation_size('idx_full')) as full_index_size,
    pg_size_pretty(pg_relation_size('【あなたの作成したインデックス名】')) as partial_index_size;
演習問題5の解答例
-- 解答: WHERE句付きのインデックスを作成する
CREATE INDEX idx_partial ON orders(total_amount) WHERE status = 'cancelled';
ANALYZE orders;

-- サイズ比較
SELECT 
    pg_size_pretty(pg_relation_size('idx_full')) as full_index_size,
    pg_size_pretty(pg_relation_size('idx_partial')) as partial_index_size;

解説: 「部分インデックス(Partial Index)」を使用すると、条件に合致する行だけをインデックスに格納できます。status='cancelled'の行だけを対象にするため、インデックスサイズが劇的に小さくなり、更新コストも低く抑えられます。

📝 演習問題6: JOINを含むクエリの最適化

準備

新しいテーブル order_items(注文明細)を作成し、データを投入します。以下のSQLを実行してください。

-- 注文明細テーブル(約300万件)の作成
DROP TABLE IF EXISTS order_items;
CREATE TABLE order_items (
    item_id SERIAL PRIMARY KEY,
    order_id INTEGER NOT NULL,
    product_name VARCHAR(100) NOT NULL,
    quantity INTEGER NOT NULL
);

INSERT INTO order_items (order_id, product_name, quantity)
SELECT 
    (random() * 1000000)::INTEGER + 1,
    'Product-' || (floor(random() * 100) + 1)::TEXT,
    (floor(random() * 10) + 1)::INTEGER
FROM generate_series(1, 3000000);
ANALYZE order_items;

課題

「特定の製品(Product-50)が含まれる注文の日付一覧」を取得したいです。現状では結合処理に時間がかかっています。適切なインデックスを作成して高速化してください。

※PostgreSQLでは、外部キー制約を定義しても自動的にインデックスは作成されません。

-- 対象クエリ
EXPLAIN ANALYZE
SELECT o.order_date, o.customer_id
FROM orders o
INNER JOIN order_items oi ON o.order_id = oi.order_id
WHERE oi.product_name = 'Product-50';
演習問題6の解答例
-- 解答: 結合キー(order_id)と検索条件(product_name)へのインデックス
-- order_items側: product_nameで絞り込み、order_idで結合する
CREATE INDEX idx_items_product_order ON order_items(product_name, order_id);
ANALYZE order_items;

-- orders側: order_idは主キーなので既にインデックスがあるため作成不要

解説: JOINを行う際、Nested Loop Joinなどが選択されると、内部表へのアクセス回数が膨大になります。結合条件となるカラム(order_id)と、絞り込み条件(product_name)をカバーするインデックスが重要です。

📝 演習問題7: INCLUDE句を用いた高度な設計

背景

演習問題3で「Index Only Scan」を行いましたが、インデックスのキー列(検索やソートに使われる列)に不要なカラムまで含めると、B-Treeの分岐効率が悪くなる可能性があります。

課題

以下のクエリに対して、「Index Only Scan」を実現しつつ、B-Treeの探索効率(ソート順序の維持)を妨げない最適なインデックスを設計してください。PostgreSQL独自の INCLUDE 句について調べてみましょう。

-- 対象クエリ
SELECT status, total_amount 
FROM orders 
WHERE customer_id = 1234 
ORDER BY order_date DESC;

条件

演習問題7の解答例
-- 解答: キー部には検索・ソート列のみを指定し、付加情報はINCLUDEに逃がす
CREATE INDEX idx_include ON orders(customer_id, order_date DESC)
INCLUDE (status, total_amount);
ANALYZE orders;

解説: INCLUDE 句を指定すると、そのカラムはB-Treeの「枝(Internal Node)」には含まれず、「葉(Leaf Node)」にだけペイロードとして格納されます。これにより、検索・ソートの効率を維持したまま、Index Only Scanの恩恵を受けることができます。

2.6 まとめ: 複合インデックス設計のチェックリスト

複合インデックスを設計する際は、以下の順序で検討してください。

この原則に従うことで、効率的で保守しやすい複合インデックスを設計できます。