PostgreSQL: PostgreSQL インデックスの内部構造と最適化
最終更新:2026-08-26
1. 学習内容
- PostgreSQLの6種類のインデックス: B-Tree / Hash / GIN / GiST / BRIN / SP-GiST
- CREATE INDEX / UNIQUE INDEX / CONCURRENTLY
- 部分インデックス(WHERE条件)— PostgreSQLの特長機能
- 式インデックス
- 複合インデックスと左端プレフィックスルール
- EXPLAIN / EXPLAIN ANALYZE による実行計画の読み方
- REINDEX によるインデックスメンテナンス
- インデックスが使われない一般的なケース
2. ストーリー
BobはEコマースプラットフォームのDBAです。商品検索ページがどんどん遅くなり、JSONBカラムのフルスキャンでクエリに3秒もかかるようになったとユーザーから報告がありました。
BobがJSONBカラムにGINインデックスを追加すると、検索は300ミリ秒まで短縮されました。さらに、アクティブユーザー向けのクエリも遅いことに気づきましたが、90%のユーザーは既に非アクティブ化されており、テーブル全体にインデックスを作ると容量を無駄にします。そこでstatus = 'active'の行だけを対象とする部分インデックスを使用し、インデックスサイズを80%削減してクエリを高速化しました。
3. 概念: インデックス種類の概要
(1) 6種類のインデックス
| インデックス種類 | 正式名称 | 適したデータ型 | 代表的なユースケース |
|---|---|---|---|
| B-Tree | バランス木 | ソート可能な全型 | 等価検索、範囲検索、ソート、前方一致LIKE |
| Hash | ハッシュテーブル | 全型 | 単純な等価検索 |
| GIN | 汎用転置インデックス | 配列、JSONB、全文検索 | 包含検索、全文検索 |
| GiST | 汎用検索木 | ジオメトリ、範囲、全文 | 空間検索、最近傍検索 |
| BRIN | ブロック範囲インデックス | 物理順序のある大規模テーブル | 時系列データ、時間範囲スキャン |
| SP-GiST | 空間分割GiST | 不均衡な分割構造 | 電話番号、ルーティング |
▶ サンプル: テーブルの既存インデックスを確認する
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'orders';
indexname | indexdef
------------------------+--------------------------------------------------
orders_pkey | CREATE UNIQUE INDEX ... ON orders USING btree (order_id)
idx_orders_customer_id | CREATE INDEX ... ON orders USING btree (customer_id)
(2) インデックス種類の選択判断
flowchart TD
A{クエリの種類は?} -->|等価/範囲/ソート| B[B-Tree]
A -->|単純な等価| C[Hash]
A -->|配列/JSONBの包含| D[GIN]
A -->|空間/ジオメトリ/最近傍| E[GiST]
A -->|大規模テーブルの逐次スキャン| F[BRIN]
A -->|不均衡な分割構造| G[SP-GiST]
B --> B1["デフォルトのインデックス種類<br/>90%のケースで最適"]
C --> C1["使用頻度は低い<br/>通常はB-Treeの方が優れる"]
D --> D1["JSONB @> ?<br/>配列 @> <br/>tsvector @@ "]
E --> E1["PostGIS<br/>範囲の重なり"]
F --> F1["1000万行以上の時系列<br/>サイズが非常に小さい"]
G --> G1["電話番号プレフィックス<br/>IPルーティング"]
style A fill:#e1f5fe
style B fill:#c8e6c9
style D fill:#fff9c4
style F fill:#fff9c4
4. 概念: B-Treeインデックス
(1) B-Treeの特徴とユースケース
B-TreeはPostgreSQLのデフォルトインデックス種類で、等価検索、範囲検索、ソート、IS NULL、前方一致LIKE検索をサポートします。
| サポートされる演算子 | 例 |
|---|---|
| 等価 | WHERE col = 100 |
| 範囲 | WHERE col > 100 AND col < 200 |
| ソート | ORDER BY col |
| IS NULL | WHERE col IS NULL |
| 前方一致LIKE | WHERE col LIKE 'abc%' |
| BETWEEN | WHERE col BETWEEN 1 AND 10 |
| サポートされない演算子 | 理由 |
|---|---|
LIKE '%abc' |
先頭ワイルドカードはB-Treeの順序を利用できない |
col::text = '100' |
型の不一致。式インデックスが必要 |
LOWER(col) = 'abc' |
関数の結果はインデックス化されない。式インデッ��スが必要 |
▶ サンプル: B-Treeインデックスを作成する
CREATE INDEX idx_orders_amount ON orders (amount);
CREATE INDEX idx_orders_date_amount ON orders (order_date, amount DESC);
Output:
CREATE TABLE
▶ サンプル: UNIQUEインデックス
CREATE UNIQUE INDEX idx_users_email ON users (email);
Output:
CREATE TABLE
UNIQUEインデックスはデータの一意性保証とクエリパフォーマンスの両方を提供します。
5. 概念: GINインデックス
(1) GINインデックスの原理
GIN(汎用転置インデックス)は転置インデックスで、要素からそれを含む行へのマッピングです。「含む」系の検索に適しています。
| 型 | 演算子 | 例 |
|---|---|---|
| JSONB | @> ? `? |
?&` |
| 配列 | @> <@ && |
WHERE tags @> ARRAY['sale'] |
| tsvector | @@ |
WHERE body @@ to_tsquery('postgres') |
▶ サンプル: JSONBカラムへのGINインデックス
CREATE INDEX idx_products_attrs ON products USING GIN (attrs);
SELECT product_id, name
FROM products
WHERE attrs @> '{"category": "electronics"}';
product_id | name
------------+------------
101 | Laptop Pro
205 | Smart Watch
▶ サンプル: 配列カラムへのGINインデックス
CREATE INDEX idx_products_tags ON products USING GIN (tags);
SELECT product_id, name
FROM products
WHERE tags @> ARRAY['summer', 'sale'];
Output:
result
----------
42.50
(1 row)
▶ サンプル: 全文検索用のGINインデック��
CREATE INDEX idx_articles_body ON articles USING GIN (to_tsvector('english', body));
SELECT id, title
FROM articles
WHERE to_tsvector('english', body) @@ to_tsquery('postgresql & index');
Output:
CREATE TABLE
(2) GINとB-Treeの比較
| 観点 | B-Tree | GIN |
|---|---|---|
| 検索種類 | 等価 / 範囲 | 包含 / 検索 |
| 書き込み速度 | 速い | 遅い(転置リストの更新が必要) |
| インデックスサイズ | 中程度 | 大きめ |
| 適した型 | スカラー | 配列 / JSONB / 全文 |
| ソート対応 | あり | なし |
6. 概念: GiST / BRIN / SP-GiST / Hash
(1) GiSTインデックス
GiSTは汎用検索木フレームワークで、カスタム分割戦略をサポートします。代表的な用途は空間データです。
| ユースケース | 演算子 | 拡張機能 |
|---|---|---|
| ジオメトリデータ | && @ <@ |
PostGIS |
| 範囲型 | && @> <@ |
組み込み |
| 全文検索 | @@ |
組み込み |
▶ サンプル: 範囲型へのGiSTインデックス
CREATE INDEX idx_events_time_range ON events USING GiST (time_range);
SELECT event_id, title
FROM events
WHERE time_range && daterange('2025-01-01', '2025-03-01');
Output:
CREATE TABLE
(2) BRINインデックス
BRIN(ブロック範囲インデックス)は各データブロ��クのサマリー情報(最小値/最大値)を保存します。非常にコンパクトで、物理的に順序付けられた大規模テーブルに適しています。
| 観点 | B-Tree | BRIN |
|---|---|---|
| インデックスサイズ | 大きい | 非常に小さい(約1/1000) |
| 精度 | 正確 | 近似(余分なブロックをスキャンする可能性あり) |
| メンテナンスコスト | 高い | 非常に低い |
| ユースケース | ランダム検索 | 時系列の範囲スキャン |
▶ サンプル: 時系列テーブルへのBRINインデックス
CREATE INDEX idx_logs_created_at ON logs USING BRIN (created_at)
WITH (pages_per_range = 32);
SELECT count(*) FROM logs
WHERE created_at BETWEEN '2025-06-01' AND '2025-06-30';
Output:
count
-------
5
(1 row)
(3) SP-GiSTインデックス
SP-GiSTは電話番号プレフィックスやIPルーティングのような不均衡な分割構造に適しています。
▶ サンプル: 電話番号プレフィックスへのSP-GiSTインデックス
-- 最初にbtree_gist拡張機能を有効化: CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE INDEX idx_customers_phone ON customers USING SP-GiST (phone prefix_range);
Output:
CREATE TABLE
(4) Hashインデックス
Hashインデックスは単純な等価検索のみをサポートし、範囲検索やソートはサポートしません。PostgreSQL 10以前はHashインデックスにWALの問題がありましたが(現在は修正済み)、通常はB-Treeの方が良い選択です。
| 観点 | B-Tree | Hash |
|---|---|---|
| 等価検索 | 速い | 速い |
| 範囲検索 | サポート | 非サポート |
| ソート | サポート | 非サポート |
| WAL | 完全 | PostgreSQL 10以降完全 |
| 推奨 | デフォルト選択 | 使用頻度は低い |
7. 概念: 高度なインデックス機能
(1) 部分インデックス
部分インデックスはWHERE条件を満たす行のみを含み、インデックスサイズとメンテナンスコストを削減します。これはPostgreSQLの特長機能です。
| 観点 | 完全インデックス | 部分インデックス |
|---|---|---|
| 含まれる行 | 全行 | 条件に合致する行のみ |
| インデックスサイズ | 大きい | 小さい |
| メンテナンスコスト | 書き込みごとに更新 | 該当行のみ更新 |
| ユースケース | 一般的な検索 | サブセットのみを対象とする検索 |
▶ サンプル: アクティブユーザーのみにインデックス
CREATE INDEX idx_users_active_email ON users (email)
WHERE status = 'active';
Output:
CREATE TABLE
SELECT email FROM users WHERE status = 'active' AND email = 'alice@example.com';
このクエリは部分インデックスを使用します。status = 'active'条件がないクエリは使用しません。
▶ サンプル: 未発送の注文のみにインデックス
CREATE INDEX idx_orders_pending ON orders (created_at)
WHERE shipped = false;
Output:
CREATE TABLE
(2) 式インデックス
クエリ条件がカラムを関数や計算でラップしている場合、通常のインデックスは使用できません。式インデックスは計算結果に対してインデックスを構築します。
▶ サンプル: 大文字小文字を区別しない検索
CREATE INDEX idx_users_email_lower ON users (LOWER(email));
SELECT * FROM users WHERE LOWER(email) = 'alice@example.com';
Output:
CREATE TABLE
▶ サンプル: 日付切り捨て検索
CREATE INDEX idx_orders_date_trunc ON orders (DATE_TRUNC('day', created_at));
SELECT COUNT(*) FROM orders
WHERE DATE_TRUNC('day', created_at) = '2025-06-15'::date;
Output:
count
-------
5
(1 row)
(3) 複合インデックスと左端プレフィックス
複合インデックス(a, b, c)が対応できるクエリパターン:
| 検索条件 | 使用されるか | 理由 |
|---|---|---|
WHERE a = 1 |
はい | 左端プレフィックス |
WHERE a = 1 AND b = 2 |
はい | 左端プレフィックス |
WHERE a = 1 AND b = 2 AND c = 3 |
はい | 完全一致 |
WHERE b = 2 |
いいえ | 左端カラムがない |
WHERE b = 2 AND c = 3 |
いいえ | 左端カラムがない |
WHERE a = 1 AND c = 3 |
部分的 | カラムaのみ使用。cは連続していない |
▶ サンプル: 複合インデックスを作成する
CREATE INDEX idx_orders_customer_date
ON orders (customer_id, order_date DESC);
Output:
CREATE TABLE
(4) CONCURRENTLYによるオンラインインデックス構築
デフォルトではインデックス構築時に排他ロックを取得し、書き込みをブロックします。CONCURRENTLYは書き込みをブロックしませんが、構築が遅くなります。
| 方式 | ロック | 書き込みブロック | 速度 | トランザクション内可? |
|---|---|---|---|---|
| CREATE INDEX | 排他ロック | ブロックする | 速い | はい |
| CREATE INDEX CONCURRENTLY | 共有ロック | ブロックしない | 遅い | いいえ |
▶ サンプル: オンラインでインデックスを構築する
CREATE INDEX CONCURRENTLY idx_orders_region
ON orders (region);
Output:
CREATE TABLE
8. 概念: EXPLAIN実行計画
(1) EXPLAINの基本
| コマンド | 説明 | クエリ実行? |
|---|---|---|
| EXPLAIN | 実行計画を表示 | いいえ |
| EXPLAIN ANALYZE | 実行して実測値を表示 | はい |
| EXPLAIN BUFFERS | バッファヒットを表示 | はい |
| EXPLAIN (FORMAT JSON) | JSON形式で出力 | いいえ |
▶ サンプル: 実行計画を表示する
EXPLAIN
SELECT * FROM orders WHERE customer_id = 1;
QUERY PLAN
----------------------------------------------------------------------
Index Scan using idx_orders_customer_id on orders (cost=0.29..8.31 rows=1 width=72)
Index Cond: (customer_id = 1)
▶ サンプル: EXPLAIN ANALYZE
EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 1;
QUERY PLAN
----------------------------------------------------------------------
Index Scan using idx_orders_customer_id on orders
(cost=0.29..8.31 rows=1 width=72) (actual time=0.015..0.016 rows=2 loops=1)
Index Cond: (customer_id = 1)
Planning Time: 0.085 ms
Execution Time: 0.032 ms
(2) 主要なスキャン種類
| スキャン種類 | 意味 | インデックス使用? |
|---|---|---|
| Seq Scan | テーブル全体の逐次スキャン | いいえ |
| Index Scan | インデック��スキャン(テーブル行を取得) | はい |
| Index Only Scan | インデックスのみスキャン(テーブル参照な��) | はい(カバリングインデックス) |
| Bitmap Scan | ビットマップスキャン(大量バッチ) | 部分的 |
| Parallel Seq Scan | 並列テーブル全体スキャン | いいえ |
▶ サンプル: Index Only Scan
EXPLAIN
SELECT customer_id FROM orders WHERE customer_id = 1;
Index Only Scan using idx_orders_customer_id on orders
Index Cond: (customer_id = 1)
インデックスのみを読み取り、データ行のテーブル参照が不要なため、最高のパフォーマンスが得られます。
9. インデックスのメンテナンスと失敗
(1) REINDEXによる再構築
多数の挿入/削除を繰り返すと、インデックスが肥大化することがあります。REINDEXで再構築して容量を回収します。
| 方式 | 説明 | ロック |
|---|---|---|
| REINDEX INDEX idx | 単一インデックスを再構築 | 排他ロック |
| REINDEX TABLE tbl | テーブル上の全インデックスを再構築 | 排他ロック |
| REINDEX INDEX CONCURRENTLY idx | オンライン再構築(PG 12+) | ブロックしない |
▶ サンプル: インデックスを再構築する
REINDEX INDEX idx_orders_customer_id;
REINDEX INDEX CONCURRENTLY idx_orders_customer_id;
Output:
-- SQL statement executed successfully
(2) インデックスが使われない一般的なケース
| ケース | 例 | 修正方法 |
|---|---|---|
| カラムを関数でラップ | WHERE LOWER(col) = 'x' |
式インデックス |
| 暗黙的な型変換 | WHERE varchar_col = 123 |
型を統一する |
| 先頭ワイルドカード | WHERE col LIKE '%abc' |
GIN / pg_trgm |
| OR条件 | WHERE a=1 OR b=2 |
個別インデックスまたはUNION |
| 統計情報が古い | 大量データ変更後 | ANALYZE |
| 左端プレフィックス違反 | 複合インデックス(a,b)をbで検索 | インデックスまたはクエリを調整 |
| 部分インデックス条件不一致 | WHERE status='active'で全件検索 |
WHERE削除または完全インデックス構築 |
▶ サンプル: 暗黙的な型不一致でインデックスが失敗する
SELECT * FROM users WHERE phone = 13800138000;
SELECT * FROM users WHERE phone = '13800138000';
Output:
id | name | value
----+----------+-------
1 | example | 42
(1 row)
phoneはVARCHARです。1行目の暗黙的な変換によりインデックスが使われず、2行目はインデックスを使用します。
10. 総合サンプル
Bobのインデックス最適化計画 — JSONB商品検索 + アクティブユーザー部分インデックス + 複合インデックス + BRIN時系列インデックス:
CREATE INDEX CONCURRENTLY idx_products_attrs_gin
ON products USING GIN (attrs);
CREATE INDEX CONCURRENTLY idx_users_active_email
ON users (email, last_login_at)
WHERE status = 'active';
CREATE INDEX CONCURRENTLY idx_orders_customer_date
ON orders (customer_id, order_date DESC);
CREATE INDEX CONCURRENTLY idx_audit_log_created_brin
ON audit_log USING BRIN (created_at)
WITH (pages_per_range = 32);
EXPLAIN ANALYZE
SELECT p.product_id, p.name, p.attrs
FROM products p
WHERE p.attrs @> '{"category": "electronics", "in_stock": true}';
EXPLAIN ANALYZE
SELECT user_id, email
FROM users
WHERE status = 'active'
AND email LIKE 'alice%'
ORDER BY last_login_at DESC
LIMIT 10;
11. 実行フロー
インデックス種類の選択と最適化フロー:
flowchart TD
A["遅いクエリを発見"] --> B["EXPLAIN ANALYZE"]
B --> C{"スキャン種類は?"}
C -->|"Seq Scan"| D{"適切なインデックスがあるか?"}
C -->|"Index Scan"| E["インデックス使用中<br/>クエリ/インデックスを最適化"]
D -->|"なし"| F{"データ型は?"}
D -->|"あり但しミス"| G["インデックス無効化を確認"]
F -->|"スカラー/ソート"| H["B-Tree作成"]
F -->|"JSONB/配列"| I["GIN作成"]
F -->|"ジオメトリ/範囲"| J["GiST作成"]
F -->|"大規模テーブル逐次"| K["BRIN作成"]
F -->|"サブセットのみ"| L["部分インデックス作成"]
F -->|"関数/計算"| M["式インデックス作成"]
H --> N["CONCURRENTLYでデプロイ"]
I --> N
J --> N
K --> N
L --> N
M --> N
style B fill:#e1f5fe
style G fill:#ffccbc
style N fill:#c8e6c9
❓ よくある質問
SELECT indexname FROM pg_indexes WHERE indexdef LIKE '%INVALID%'または\d+ tblで見つけ、DROP INDEXで削除してください。LIKE 'abc%'はB-Treeを使用できますが、LIKE '%abc'のような先頭ワイルドカードは使用できません。GINとpg_trgm拡張機能が必要です。WHERE a=1 ORDER BY bをサポートしますか?aでソート後bでソートされているため、WHEREフィルタとORDER BYの両方を満たし、余分なソートを回避できます。📖 まとめ
- PostgreSQLは6種類のインデックスをサポート。B-Treeが約90%のケースに適合
- GINインデックスはJSONB / 配列 / 全文検索の包含検索に適する
- BRINインデックスは非常にコンパクトで、物理順序のある大規模テーブルの範囲スキャンに適する
- 部分インデックスは条件に合致する行のみインデックス化し、サイズとメンテナンスコストを削減
- 式インデックスは関数/計算結果をインデックス化し、カラムの関数ラップによるインデックス失敗を修正
- 複合インデックスは左端プレフィックスルールに従う。カラム順序がクエリのヒットに影響
- CONCURRENTLYは書き込みをブロッ��せずオンラインでインデックス構築。ただしトランザクション内では実行不可
- EXPLAIN ANALYZEはスロークエリ診断の基本ツール
- インデックス失敗の一般的な原因: 関数ラップ、型不一致、古い統計情報
📝 練習問題
- ⭐
ordersテーブルのcustomer_idカラムにB-Treeインデックスを作成し、EXPLAINでクエリがインデックスを使用することを確認してください。 - ⭐
productsテーブルのJSONBカラムにGINインデックスを作成し、@>クエリの実行計画の変化を観察してください。 - ⭐⭐ 部分インデックスを作成:
status = 'pending'の注文のcreated_atカラムのみにインデックスを作成し、完全インデックスとのサイズ差を比較してください。 - ⭐⭐
LOWER(email)に式インデックスを作成し、大文字小文字を区別しない検索がインデックスを使用することを確認。次に複合インデックス(region, created_at DESC)を作成し、地域と時間でソートされたクエリをサポートしてください。 - ⭐⭐⭐ 数千万行のログテーブルに対して、BRINインデックス + B-Tree複合インデックス + 部分インデックスの組み合わせ案を設計し、EXPLAIN ANALYZEで最適化前後のクエリ時間を比較してください。