- Published on
DBインデックス完全ガイド 2025: B-Tree, Hash, GIN, BRIN, 複合インデックス, 実行計画解析
- Authors

- Name
- Youngju Kim
- @fjvbn20031
TL;DR
- B-Treeが90%: 多くのクエリに適合。ソート、範囲、完全一致を全て支援。
- GINは検索最強: 配列、JSON、全文検索。PostgreSQLの秘密兵器。
- 複合インデックスの順序が決定的:
WHERE a=? AND b=?とWHERE b=? AND a=?で使用可否が変わる。 - Covering Indexでテーブルアクセスを回避:
INCLUDE句を活用。 - インデックスが使われない10の理由を把握 — 統計、関数、キャスト、パターンなど。
1. なぜインデックスは速いのか
1.1 インデックスなし
SELECT * FROM users WHERE email = 'alice@example.com';
インデックスなしだと Sequential Scan — 全行を読み比較。1億行なら1億回比較。
1億行 x 100 nanosecond = 10秒
1.2 B-Treeあり
1億行 -> log2(10^8) ~= 27回比較
27 x 100 ns = 2.7 microsecond
約400万倍速い — これがインデックスの魔法です。
1.3 インデックスのコスト
無料ではない:
- ストレージ: ディスク容量を消費
- 書き込み遅延: INSERT/UPDATE/DELETE時にインデックスも更新
- 保守: VACUUM, REINDEX
ルール: 読み込みと書き込みの比率を考慮。100:1ならインデックス追加、1:100なら慎重に。
2. B-Tree内部構造
2.1 平衡木
[50]
/ \
[25] [75]
/ \ / \
[10] [40] [60] [90]
特性:
- 全リーフが同じ深さ(平衡)
- 各ノードに数百キーを格納
- ソート順を維持
2.2 ページ単位のストレージ
PostgreSQL B-Tree page (8KB)
+----------------------------------+
| Header |
+----------------------------------+
| Item Pointer 1 -> key1 + ctid |
| Item Pointer 2 -> key2 + ctid |
+----------------------------------+
| Free Space |
+----------------------------------+
| Items (low to high) |
+----------------------------------+
ctid はPostgreSQLの行位置(ページ番号、オフセット)。
2.3 木の深さ
8KBページに200キーを想定:
- Depth 1: 200 keys
- Depth 2: 40,000
- Depth 3: 8,000,000
- Depth 4: 1,600,000,000
1億行 = 深さ4 -> ディスク4回読み込みで検索完了。
3. インデックスの種類 (PostgreSQL)
3.1 B-Tree (デフォルト)
CREATE INDEX idx_users_email ON users(email);
適合: 完全一致、範囲、ORDER BY、前方一致 LIKE 'abc%'、IS NULL。
不適合: LIKE '%abc'、LIKE '%abc%'、LOWER(email) のような関数適用(Expression Indexが必要)。
3.2 Hash Index
CREATE INDEX idx_users_email_hash ON users USING hash(email);
完全一致のみ。大抵の場合B-Treeが優位。PG 10+でWAL対応してから時々有用(わずかに小さい)。
3.3 GIN (Generalized Inverted Index)
-- 配列
CREATE INDEX idx_tags ON posts USING gin(tags);
-- JSONB
CREATE INDEX idx_metadata ON products USING gin(metadata);
-- 全文検索
CREATE INDEX idx_content_fts ON articles USING gin(to_tsvector('english', content));
転置インデックス — 各キーが行リストにマッピング。
"redis" -> [row1, row5, row100, ...]
"kafka" -> [row3, row7, row50, ...]
"postgres" -> [row1, row2, row100, ...]
適合: 配列包含 (tags @> ARRAY['redis'])、JSONBキー/値、全文検索、多値カラム。
SELECT * FROM posts WHERE tags @> ARRAY['redis'];
SELECT * FROM posts WHERE tags @> ARRAY['redis', 'database'];
SELECT * FROM products WHERE metadata @> '{"category": "electronics"}';
欠点: ビルドが遅い。fastupdate=on でUPDATEコストを遅延化。
3.4 GiST (Generalized Search Tree)
CREATE INDEX idx_locations ON places USING gist(location);
CREATE INDEX idx_periods ON events USING gist(period);
地理データ、範囲型、最近傍探索、重なり判定。
3.5 BRIN (Block Range Index)
CREATE INDEX idx_logs_created ON logs USING brin(created_at);
ページ範囲ごとにmin/maxのみ格納。非常に小さい。時系列やログのように物理順が揃った巨大テーブルに最適。1億行のログでBRINは数MB、B-Treeは数GB。
3.6 SP-GiST
CREATE INDEX idx_ips ON connections USING spgist(ip_addr);
非平衡木。IP範囲や一部のPostGIS型。
3.7 比較表
| 種類 | 完全一致 | 範囲 | パターン | 配列/JSON | 地理 | サイズ | ビルド |
|---|---|---|---|---|---|---|---|
| B-Tree | 可 | 可 | 前方のみ | 不可 | 不可 | 中 | 速 |
| Hash | 可 | 不可 | 不可 | 不可 | 不可 | 小 | 速 |
| GIN | 可 | 不可 | 全文 | 可 | 不可 | 大 | 遅 |
| GiST | 可 | 可 | 不可 | 不可 | 可 | 中 | 中 |
| BRIN | 不可 | 可* | 不可 | 不可 | 不可 | 極小 | 速 |
| SP-GiST | 可 | 可 | 不可 | 不可 | 可 | 中 | 中 |
*BRINはデータが物理的にソートされている時のみ効果。
4. 複合インデックス
4.1 カラム順が決定的
CREATE INDEX idx_users_country_age ON users(country, age);
使用可能:
WHERE country = 'KR'
WHERE country = 'KR' AND age = 30
WHERE country = 'KR' AND age > 25
使用不可:
WHERE age = 30 -- countryが先頭
WHERE age > 25
ルール: leftmost prefix rule(左端プレフィックス)。
4.2 カラム順の決め方
優先順位:
- 頻繁に使われるカラムを左に
- 選択度の高いカラムを左に(多く絞り込む)
- 等号条件 > 範囲条件
例:
WHERE country = 'KR' AND age > 25 AND created_at > '2024-01-01'
選択度: country=30%、created_at=50%、age=70%。最適:
CREATE INDEX idx_users_search ON users(country, created_at, age);
4.3 等号+範囲
WHERE country = 'KR' AND age BETWEEN 25 AND 35
最適: (country, age) — 等号カラムを先に。
5. 特殊インデックスパターン
5.1 Partial Index
CREATE INDEX idx_active_users ON users(email)
WHERE status = 'active';
利点: インデックスサイズ縮小、高速ビルド/保守、キャッシュ効率向上。
5.2 Expression Index
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
SELECT * FROM users WHERE LOWER(email) = LOWER('Alice@example.com');
一般的: LOWER、UPPER、EXTRACT(year FROM ...)、JSON抽出 (metadata->>'category')。
5.3 Covering Index (INCLUDE)
-- PostgreSQL 11+
CREATE INDEX idx_users_country_email ON users(country) INCLUDE (email, name);
インデックスのみで結果を返す(Index Only Scan)。
SELECT email, name FROM users WHERE country = 'KR';
5.4 Unique Index
CREATE UNIQUE INDEX idx_users_email_unique ON users(email);
重複防止 + オプティマイザが一意性を活用。
6. EXPLAIN ANALYZE をマスター
6.1 基本
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM users WHERE country = 'KR' AND age > 25;
6.2 出力の読み方
Index Scan using idx_users_country_age on users
(cost=0.43..2547.39 rows=10000 width=120)
(actual time=0.025..15.234 rows=9847 loops=1)
Index Cond: ((country)::text = 'KR'::text)
Filter: (age > 25)
Rows Removed by Filter: 153
Buffers: shared hit=1234 read=56
Planning Time: 0.123 ms
Execution Time: 15.567 ms
cost: オプティマイザ推定rowsvsactual rows: 推定と実測Buffers: キャッシュhit/read
6.3 推定 vs 実測
1000倍の乖離は統計不正確の証。ANALYZEを実行し default_statistics_target を上げる。
6.4 主要ノード
| ノード | 意味 | 良い? |
|---|---|---|
Seq Scan | 全表スキャン | 小テーブルのみ |
Index Scan | インデックス+ヒープ参照 | 可 |
Index Only Scan | インデックスのみ | 最良 |
Bitmap Index Scan | ビットマップ合成 | 可 |
Nested Loop | 小ジョイン | 小さい時のみ |
Hash Join | 大ジョイン | 可 |
Merge Join | ソート済み結合 | 可 |
Sort | メモリ/ディスク | Diskに落ちたら注意 |
6.5 赤信号
Rows Removed by Filter: 100000+Disk: external mergeestimated/actual > 100- 大テーブルに
Seq Scan
7. インデックスが使われない10の理由
7.1 関数/式の適用
-- 悪い
SELECT * FROM users WHERE LOWER(email) = 'alice@example.com';
-- 良い
CREATE INDEX idx_email_lower ON users(LOWER(email));
7.2 型キャスト
-- 悪い: varchar列に整数
SELECT * FROM users WHERE phone = 12345678;
-- 良い
SELECT * FROM users WHERE phone = '12345678';
7.3 先頭ワイルドカード
-- 悪い
SELECT * FROM users WHERE name LIKE '%alice%';
-- 良い
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_users_name_trgm ON users USING gin(name gin_trgm_ops);
7.4 NULL比較
CREATE INDEX idx_active ON users(id) WHERE deleted_at IS NULL;
7.5 OR条件
OR より IN か UNION を優先。
7.6 統計不正確
ANALYZE users;
7.7 戻り行数が多すぎる
約10%超を返すとプランナーはSeq Scanを選択しがち。Partial Indexや別戦略を検討。
7.8 オプティマイザヒント不在
PostgreSQLは意図的に非対応。MySQLは USE INDEX が可能。pg_hint_plan で追加可。
7.9 先頭カラム欠落
CREATE INDEX idx_users_country_age ON users(country, age);
-- 悪い: country欠落
SELECT * FROM users WHERE age > 25;
7.10 暗黙変換
-- 一部DBで悪い
SELECT * FROM orders WHERE order_id = 12345;
-- 良い
SELECT * FROM orders WHERE order_id = 12345::bigint;
8. 実践チューニング事例
8.1 遅い検索
SELECT * FROM products
WHERE category = 'electronics'
AND price BETWEEN 100 AND 500
ORDER BY created_at DESC
LIMIT 20;
Before: Seq Scan + Sort (10秒)。修正:
CREATE INDEX idx_products_search ON products(category, price, created_at DESC);
After: Index Scan、5ms — 2000倍速い。
8.2 遅いJOIN
SELECT u.*, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.country = 'KR'
GROUP BY u.id;
修正:
CREATE INDEX idx_orders_user_id ON orders(user_id);
8.3 遅いJSONクエリ
CREATE INDEX idx_events_metadata ON events USING gin(metadata);
8.4 複合 vs 単一
WHERE a=? AND b=? AND c=? のとき、単一インデックス3個をBitmapAndするより (a, b, c) 複合1個が大抵速い。
9. インデックス保守
9.1 未使用インデックスの検出
SELECT
schemaname || '.' || relname AS table,
indexrelname AS unused_index,
pg_size_pretty(pg_relation_size(indexrelid)) AS size,
idx_scan AS index_scans
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;
9.2 インデックス膨張
REINDEX INDEX CONCURRENTLY idx_users_email;
9.3 冗長インデックス
idx_ab(a, b) があれば idx_a(a) は削除可(leftmost rule)。
9.4 監視
SELECT schemaname, relname, indexrelname, idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
ORDER BY idx_scan DESC;
10. MySQL vs PostgreSQL
| 特性 | MySQL (InnoDB) | PostgreSQL |
|---|---|---|
| 既定インデックス | B+Tree | B-Tree |
| クラスタインデックス | あり (PRIMARY KEY) | なし |
| Hash Index | Memoryエンジンのみ | あり |
| 全文検索 | FULLTEXT | tsvector + GIN |
| JSONインデックス | Generated columns | GIN on JSONB |
| Partial Index | 限定的 (8.0+) | あり |
| Expression Index | 8.0+ | あり |
| Covering Index | あり | あり (INCLUDE) |
| BRIN | なし | あり |
| GiST | なし | あり |
クイズ
1. 複合インデックス (a, b, c) で使えるクエリは?
答: leftmost prefix rule — 可: WHERE a=?、WHERE a=? AND b=?、WHERE a=? AND b=? AND c=?、部分的に WHERE a=? AND c=?。不可: WHERE b=?、WHERE c=?、WHERE b=? AND c=?。
2. GIN Indexが適する場面は?
答: 多値カラム — 配列 tags @> ARRAY['redis']、JSONB metadata @> '{"key": "value"}'、全文検索 tsvector @@ tsquery、pg_trgm による先頭ワイルドカードLIKE高速化。
3. 推定と実測で行数が1000倍ずれる場合は?
答: 統計が不正確。ANALYZE を実行し default_statistics_target を上げる(100 -> 1000)、列ごとに SET STATISTICS で調整。autovacuumの analyze_scale_factor を下げるのも有効。
4. Partial Indexの利点は?
答: インデックスサイズ縮小、高速ビルド/保守、キャッシュ効率向上。例: CREATE INDEX idx_active ON users(email) WHERE status='active' はアクティブユーザーのみ索引化。
5. Index Only Scanが通常のIndex Scanより速い理由は?
答: 通常のIndex Scanは ctid を取得してヒープを読むが、Index Only Scanは必要な列が全て索引に含まれる(INCLUDE 句)のでテーブルアクセス不要。visibility mapが必要なのでVACUUMが前提。