はじめに
データベースの検索速度は、アプリケーションのユーザー体験に直結する重要な要素です。数万件程度のデータでは問題にならなくても、数百万件、数千万件と増えていくと、検索に数秒、数十秒かかることも珍しくありません。
この問題を解決する最も効果的な手段が「インデックス」です。インデックスとは、書籍の索引のように、データベース内の特定のカラムに対して検索を高速化するためのデータ構造です。適切なインデックスを設計することで、検索速度を数百倍から数千倍に改善できることもあります。
本記事では、PostgreSQLにおけるインデックスの仕組みから実践的な活用方法までを解説します。B-Treeインデックスの構造、CREATE INDEX文による作成、単一カラムインデックスと複合インデックスの使い分け、インデックスが効く条件・効かない条件、そしてインデックスのトレードオフについて、EXPLAIN ANALYZEによる実際の検証を交えながら学んでいきます。
この記事を読むことで、以下のことができるようになります。
- インデックスの仕組み(特にB-Tree)を理解し、なぜ高速化できるのか説明できる
- CREATE INDEX文でインデックスを作成・削除できる
- 単一カラムインデックスと複合インデックスを適切に使い分けられる
- インデックスが効く条件・効かない条件を理解し、効果的なインデックスを設計できる
- インデックスのトレードオフを理解し、適切な判断ができる
前提条件
- PostgreSQL 14以降がインストールされていること
- psqlまたはGUIツール(pgAdmin、DBeaver等)でデータベースに接続できること
- SELECT文の基本的な構文を理解していること
サンプルデータの準備
本記事で使用するサンプルテーブルとデータを準備します。インデックスの効果を体感するため、10万件のデータを生成します。
|
|
データが正しく作成されたことを確認します。
|
|
インデックスとは何か
インデックスの基本概念
インデックス(索引)とは、テーブルの特定のカラムに対して作成する、検索を高速化するためのデータ構造です。書籍の巻末にある索引をイメージしてください。
書籍で特定の用語を探す場合、2つの方法があります。
- 全ページを順番に読む: 最初のページから最後まで順に見ていく(フルスキャン)
- 索引を使う: 巻末の索引で用語を探し、そこに記載されたページ番号に直接ジャンプする
明らかに後者の方が効率的です。データベースのインデックスも同じ原理で動作します。
flowchart LR
subgraph without["インデックスなし(Seq Scan)"]
A1[行1] --> A2[行2] --> A3[行3] --> A4[...] --> A5[行N]
end
subgraph with["インデックスあり(Index Scan)"]
B1[インデックス] --> B2[該当行へ直接アクセス]
endインデックスがない場合の検索
インデックスがない状態で、特定のメールアドレスを持つ従業員を検索してみましょう。
|
|
実行結果の例:
Seq Scan on employees (cost=0.00..2137.00 rows=1 width=76) (actual time=12.345..24.567 loops=1)
Filter: ((email)::text = 'employee50000@example.com'::text)
Rows Removed by Filter: 99999
Planning Time: 0.123 ms
Execution Time: 24.789 ms
「Seq Scan」と表示されています。これは「Sequential Scan(シーケンシャルスキャン)」の略で、テーブルの全行を順番に読み込んで条件に一致するか確認していることを意味します。10万件のデータをすべてチェックしているため、約25ミリ秒かかっています。
インデックスを作成した場合
emailカラムにインデックスを作成してみましょう。
|
|
実行結果の例:
Index Scan using idx_employees_email on employees (cost=0.42..8.44 rows=1 width=76) (actual time=0.045..0.047 loops=1)
Index Cond: ((email)::text = 'employee50000@example.com'::text)
Planning Time: 0.234 ms
Execution Time: 0.078 ms
「Index Scan」に変わり、実行時間が約0.08ミリ秒に短縮されました。約300倍の高速化です。
| 検索方法 | 実行時間 | 高速化率 |
|---|---|---|
| Seq Scan(インデックスなし) | 約25ms | - |
| Index Scan(インデックスあり) | 約0.08ms | 約300倍 |
B-Treeインデックスの仕組み
PostgreSQLのインデックス種類
PostgreSQLは複数のインデックス種類をサポートしていますが、デフォルトで最も広く使われるのがB-Treeインデックスです。
| インデックス種類 | 特徴 | 主な用途 |
|---|---|---|
| B-Tree | 等価・範囲検索に対応、デフォルト | 一般的な検索 |
| Hash | 等価検索のみ、高速 | 完全一致検索 |
| GiST | 空間データ、全文検索 | 地理情報、類似検索 |
| GIN | 配列、JSONB、全文検索 | 複数値カラム |
| BRIN | ブロック範囲サマリ | 大規模テーブル、時系列データ |
本記事では、最も汎用的なB-Treeインデックスに焦点を当てて解説します。
B-Treeとは
B-Tree(Balanced Tree)は、データを階層的に整理した木構造のデータ構造です。「Balanced」という名前の通り、木の各枝の深さが均等に保たれるため、どのデータを検索しても同程度の時間で到達できます。
B-Treeは以下の3つの層で構成されます。
- ルートノード: 木の最上位にある1つのノード
- 内部ノード: ルートとリーフの間にあるノード(ガイドポストの役割)
- リーフノード: 実際のデータへのポインタを持つ最下層のノード
flowchart TB
subgraph root["ルートノード"]
R["50 | 100"]
end
subgraph internal["内部ノード"]
I1["10 | 30"]
I2["60 | 80"]
I3["120 | 150"]
end
subgraph leaf["リーフノード"]
L1["1,5,8"]
L2["12,20,28"]
L3["35,42,48"]
L4["52,55,58"]
L5["65,70,78"]
L6["82,90,95"]
L7["105,110,118"]
L8["125,140,148"]
L9["160,170,180"]
end
R --> I1
R --> I2
R --> I3
I1 --> L1
I1 --> L2
I1 --> L3
I2 --> L4
I2 --> L5
I2 --> L6
I3 --> L7
I3 --> L8
I3 --> L9B-Treeの検索プロセス
例えば、値「70」を検索する場合の流れを追ってみましょう。
- ルートノード: 「50 | 100」を確認。70は50より大きく100より小さいので、中央の子ノードへ
- 内部ノード: 「60 | 80」を確認。70は60より大きく80より小さいので、中央のリーフへ
- リーフノード: 「65, 70, 78」の中から70を発見
このように、10万件のデータがあっても、B-Treeでは数回のノード探索で目的のデータに到達できます。計算量は $O(\log n)$ となり、データ量が増えても検索時間の増加は緩やかです。
| データ件数 | フルスキャン(O(n)) | B-Tree検索(O(log n)) |
|---|---|---|
| 1,000件 | 1,000回 | 約10回 |
| 100,000件 | 100,000回 | 約17回 |
| 10,000,000件 | 10,000,000回 | 約24回 |
B-Treeが効率的な演算子
B-Treeインデックスは以下の演算子で効率的に動作します。
|
|
CREATE INDEX文によるインデックス作成
基本構文
インデックスを作成するCREATE INDEX文の基本構文は以下の通りです。
|
|
インデックス名の命名規則
インデックス名には、一般的に以下の命名規則が使われます。
idx_テーブル名_カラム名
複合インデックスの場合は、カラム名をアンダースコアで繋げます。
idx_テーブル名_カラム1_カラム2
単一カラムインデックスの作成
特定の1つのカラムに対してインデックスを作成します。
|
|
ユニークインデックスの作成
重複を許さないインデックスを作成できます。これはデータの一意性制約も同時に保証します。
|
|
ユニークインデックスを作成すると、重複した値を挿入しようとするとエラーになります。
|
|
インデックスの確認
テーブルに作成されているインデックスを確認する方法です。
|
|
実行結果の例:
indexname | indexdef
----------------------------+--------------------------------------------------------------------------------
employees_pkey | CREATE UNIQUE INDEX employees_pkey ON public.employees USING btree (employee_id)
idx_employees_email | CREATE INDEX idx_employees_email ON public.employees USING btree (email)
idx_employees_department | CREATE INDEX idx_employees_department ON public.employees USING btree (department)
idx_employees_salary | CREATE INDEX idx_employees_salary ON public.employees USING btree (salary)
インデックスの削除
不要になったインデックスはDROP INDEX文で削除できます。
|
|
複合インデックス(マルチカラムインデックス)
複合インデックスとは
複合インデックス(Composite Index、Multicolumn Index)は、複数のカラムを組み合わせて作成するインデックスです。
|
|
複合インデックスが有効なケース
複合インデックスは、複数カラムを使った検索条件が頻繁に使われる場合に効果的です。
|
|
実行結果の例:
Index Scan using idx_employees_dept_hiredate on employees (cost=0.42..156.78 rows=890 width=76) (actual time=0.034..0.567 loops=1)
Index Cond: (((department)::text = '開発'::text) AND (hire_date >= '2023-01-01'::date))
Planning Time: 0.234 ms
Execution Time: 0.789 ms
カラム順序の重要性
複合インデックスでは、カラムの順序が非常に重要です。B-Treeインデックスは左端のカラム(先頭カラム)から順に使用されます。
|
|
このインデックスが効くパターンと効かないパターンを見てみましょう。
| クエリ条件 | インデックス利用 | 説明 |
|---|---|---|
WHERE department = '開発' |
利用される | 先頭カラムのみでも有効 |
WHERE department = '開発' AND hire_date >= '2023-01-01' |
利用される | 両方のカラムで有効 |
WHERE hire_date >= '2023-01-01' |
利用されにくい | 先頭カラムがないと効率が悪い |
WHERE department = '開発' OR hire_date >= '2023-01-01' |
利用されにくい | ORは複合インデックスと相性が悪い |
|
|
複合インデックス設計のベストプラクティス
複合インデックスを設計する際の指針を示します。
- 選択性の高いカラムを先頭に: カーディナリティ(値の種類)が多いカラムを先頭に配置
- 等価条件のカラムを先に: 範囲条件(
>,<,BETWEEN)より等価条件(=)のカラムを先に - よく使う検索条件を分析: アプリケーションの実際のクエリパターンを把握
|
|
インデックスが効く条件・効かない条件
インデックスが効く条件
以下の条件ではインデックスが効果的に使用されます。
|
|
インデックスが効かない条件
以下の条件ではインデックスが使用されない、または効果が低下します。
|
|
関数インデックス(式インデックス)
カラムに関数を適用した検索が頻繁に行われる場合、式インデックスを作成できます。
|
|
実行結果の例:
Index Scan using idx_employees_email_lower on employees (cost=0.42..8.44 rows=1 width=76) (actual time=0.056..0.058 loops=1)
Index Cond: (lower((email)::text) = 'employee100@example.com'::text)
Planning Time: 0.189 ms
Execution Time: 0.089 ms
部分インデックス
特定の条件を満たす行だけにインデックスを作成できます。これにより、インデックスのサイズを小さく保ちながら、必要な検索を高速化できます。
|
|
部分インデックスは以下のようなケースで特に有効です。
- 特定のステータス(有効/無効、公開/非公開など)のデータのみを頻繁に検索する場合
- 特定の期間のデータのみを頻繁に検索する場合
インデックスのトレードオフ
インデックスは検索を高速化しますが、デメリットもあります。適切なバランスを見極めることが重要です。
インデックスのメリットとデメリット
| 項目 | メリット | デメリット |
|---|---|---|
| 検索速度 | 大幅に高速化 | - |
| INSERT性能 | - | 低下(インデックス更新が必要) |
| UPDATE性能 | - | 低下(インデックス更新が必要) |
| DELETE性能 | - | 低下(インデックス更新が必要) |
| ストレージ | - | 追加容量が必要 |
| メンテナンス | - | VACUUM、REINDEXが必要 |
書き込み性能への影響
インデックスがあると、データの挿入・更新・削除時にインデックスも更新する必要があるため、書き込み性能が低下します。
|
|
ストレージへの影響
インデックスはテーブルとは別に保存され、追加のディスク容量を消費します。
|
|
実行結果の例:
name | total_size | table_size | indexes_size
-----------+------------+------------+--------------
employees | 22 MB | 12 MB | 10 MB
この例では、テーブル本体が12MBに対して、インデックスが10MBを占めています。インデックスを増やしすぎると、ストレージコストが大きくなります。
適切なインデックス設計の指針
以下の指針に従って、インデックスを適切に設計しましょう。
- 検索頻度の高いカラムに作成: 頻繁にWHERE句で使われるカラム
- カーディナリティを考慮: 値の種類が多いカラムほど効果的
- 書き込み頻度とのバランス: 頻繁に更新されるテーブルはインデックスを最小限に
- 実際のクエリを分析: EXPLAIN ANALYZEで効果を確認
- 使われていないインデックスは削除: 定期的に見直し
|
|
idx_scanが0またはごく少数のインデックスは、使われていない可能性が高いため、削除を検討しましょう。
本番環境でのインデックス作成
CONCURRENTLY オプション
本番環境で稼働中のテーブルにインデックスを作成する場合、通常のCREATE INDEX文はテーブルへの書き込みをロックします。これを回避するため、CONCURRENTLYオプションを使用できます。
|
|
CONCURRENTLYオプションの特徴は以下の通りです。
| 項目 | 通常のCREATE INDEX | CREATE INDEX CONCURRENTLY |
|---|---|---|
| 書き込みロック | あり | なし |
| 作成時間 | 短い | 長い(2回スキャン) |
| トランザクション内 | 実行可能 | 実行不可 |
| 失敗時 | 自動ロールバック | INVALIDインデックスが残る |
INVALIDインデックスの対処
CREATE INDEX CONCURRENTLYが途中で失敗すると、INVALIDステータスのインデックスが残ることがあります。
|
|
REINDEXによるインデックス再構築
インデックスは時間とともに断片化が進み、効率が低下することがあります。REINDEXで再構築できます。
|
|
EXPLAIN ANALYZEによるインデックス効果の確認
EXPLAINの基本
EXPLAIN文は、クエリの実行計画を表示します。EXPLAIN ANALYZEは実際にクエリを実行して、実測値も表示します。
|
|
実行計画の読み方
実行計画の主要な要素を解説します。
Index Scan using idx_employees_department on employees (cost=0.42..789.12 rows=20000 width=76) (actual time=0.034..5.678 loops=1)
Index Cond: ((department)::text = '開発'::text)
Planning Time: 0.234 ms
Execution Time: 8.901 ms
| 要素 | 説明 |
|---|---|
Index Scan |
使用されたスキャン方法(Index Scan, Seq Scan, Bitmap Index Scan等) |
using idx_employees_department |
使用されたインデックス名 |
cost=0.42..789.12 |
推定コスト(開始コスト..総コスト) |
rows=20000 |
推定行数 |
actual time=0.034..5.678 |
実際の実行時間(ミリ秒) |
loops=1 |
実行回数 |
Planning Time |
実行計画の作成時間 |
Execution Time |
クエリの実行時間 |
スキャン方法の種類
| スキャン方法 | 説明 | インデックス利用 |
|---|---|---|
| Seq Scan | テーブル全体を順次スキャン | なし |
| Index Scan | インデックスを使用してスキャン | あり |
| Index Only Scan | インデックスのみで完結(テーブルアクセスなし) | あり(最も効率的) |
| Bitmap Index Scan | 複数インデックスを組み合わせ | あり |
Index Only Scanの活用
必要なカラムがすべてインデックスに含まれている場合、テーブル本体へのアクセスを省略できます。
|
|
実行結果の例:
Index Only Scan using idx_employees_dept_salary_covering on employees (cost=0.42..523.12 rows=20000 width=18) (actual time=0.023..2.345 loops=1)
Index Cond: (department = '開発'::text)
Heap Fetches: 0
Planning Time: 0.156 ms
Execution Time: 3.456 ms
Heap Fetches: 0は、テーブル本体へのアクセスが0回だったことを示しています。
まとめ
本記事では、PostgreSQLのインデックスについて基礎から実践的な活用方法まで解説しました。
学んだ内容の振り返り
- インデックスの基本概念: 検索を高速化するためのデータ構造
- B-Treeの仕組み: 階層的な木構造で$O(\log n)$の計算量を実現
- CREATE INDEX文: 単一カラム、複合インデックス、ユニークインデックスの作成
- 複合インデックス: カラム順序の重要性と設計指針
- インデックスが効く/効かない条件: 関数適用、後方一致、否定条件などの注意点
- トレードオフ: 検索高速化 vs 書き込み性能・ストレージ
- 本番環境での運用: CONCURRENTLYオプション、REINDEX
インデックス設計のチェックリスト
インデックスを設計する際は、以下のチェックリストを活用してください。
- WHERE句で頻繁に使用されるカラムか
- カーディナリティ(値の種類)は十分に高いか
- 書き込み頻度とのバランスは適切か
- 複合インデックスのカラム順序は適切か
- EXPLAIN ANALYZEで効果を確認したか
- 使われていないインデックスはないか
適切なインデックスを設計することで、アプリケーションの検索性能を大幅に向上させることができます。まずは実際のクエリをEXPLAIN ANALYZEで分析し、ボトルネックとなっている箇所を特定することから始めましょう。