Skip to content
Published on

PostgreSQL高度インデックス完全ガイド:GIN・GiST・BRIN・Partial Index実践活用

Authors
  • Name
    Twitter
PostgreSQL Advanced Indexing

はじめに

PostgreSQLはB-tree、Hash、GIN、GiST、SP-GiST、BRINの6種類のインデックスタイプを提供している。多くのチュートリアルはB-treeで止まるが、実務ではJSONB検索、全文検索、空間クエリ、数十億行の時系列テーブルなど、B-treeだけでは解決困難な問題が頻繁に発生する。

本記事ではB-treeを超えて、GIN、GiST、BRIN、Partial Index、Expression Indexの内部構造を理解し、各シナリオでどのように適用すべきかをEXPLAIN ANALYZEベースの実際の性能データとともに解説する。

PostgreSQLインデックスタイプの概要

PostgreSQLが提供するインデックスタイプの一覧は以下の通りである。

インデックスタイプ内部構造最適な用途サイズ書き込みコスト
B-treeバランスツリー等値、範囲、ソート、UNIQUE
GIN転置インデックス(Posting List/Tree)JSONB、配列、tsvector
GiST汎用検索ツリー空間データ、範囲、近傍検索
BRINブロック範囲要約時系列、append-only大規模極小極低
Hashハッシュテーブル純粋な等値検索
SP-GiST空間分割ツリー電話番号、IP、非バランスツリー構造

B-treeの限界と高度インデックスの必要性

B-treeはスカラー値の等値比較と範囲検索に最適化されている。しかし、以下のシナリオではB-treeが非効率的もしくは使用不可能になる。

  • JSONB包含検索: WHERE metadata @> '...' のような演算子はB-treeではインデックス化できない。
  • 全文検索: to_tsvector() ベースの検索にはGINインデックスが必須である。
  • 空間クエリ: ST_DWithin()ST_Contains() などのPostGIS関数にはGiSTインデックスが必要である。
  • 数十億行の時系列テーブル: B-treeインデックス自体が数十GBに膨れ上がり、メモリ圧迫が深刻になる。

これらの問題を解決するために、PostgreSQLは特化型インデックスを提供している。

GINインデックス深層分析

内部構造

GIN(Generalized Inverted Index)は転置インデックス構造を使用する。内部的にはキー値に対するB-treeを構成し、各リーフノードにはそのキーを含む行のTID(Tuple Identifier)リストであるPosting ListまたはPosting Treeを格納する。

例えばJSONBカラムに "tags": ["python", "database"] という値がある場合、GINインデックスは「python」と「database」の各々をキーとして登録し、該当行のTIDをPosting Listに追加する。

JSONBインデックス

JSONBカラムにGINインデックスを作成すると、@>??|?& などの演算子をインデックススキャンで処理できる。

-- テーブル作成
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    metadata JSONB NOT NULL
);

-- 100万件のテストデータ投入
INSERT INTO products (name, metadata)
SELECT
    'product_' || i,
    jsonb_build_object(
        'category', (ARRAY['electronics', 'clothing', 'food', 'toys'])[1 + (i % 4)],
        'price', (random() * 1000)::int,
        'tags', jsonb_build_array(
            (ARRAY['sale', 'new', 'popular', 'limited'])[1 + (i % 4)],
            (ARRAY['premium', 'budget', 'mid-range'])[1 + (i % 3)]
        ),
        'in_stock', (i % 2 = 0)
    )
FROM generate_series(1, 1000000) AS i;

-- GINインデックス作成(デフォルトjsonb_ops)
CREATE INDEX idx_products_metadata_gin ON products USING gin (metadata);

-- jsonb_path_opsオペレータークラス(より小さいインデックス、@>専用)
CREATE INDEX idx_products_metadata_path ON products USING gin (metadata jsonb_path_ops);

-- 包含検索クエリとEXPLAIN ANALYZE
EXPLAIN ANALYZE
SELECT id, name
FROM products
WHERE metadata @> '{"category": "electronics", "in_stock": true}';

実行結果例:

Bitmap Heap Scan on products  (cost=52.01..2084.18 rows=500 width=20)
  (actual time=1.234..5.678 rows=125000 loops=1)
  Recheck Cond: (metadata @> '{"category": "electronics", "in_stock": true}'::jsonb)
  Heap Blocks: exact=8334
  ->  Bitmap Index Scan on idx_products_metadata_gin  (cost=0.00..51.88 rows=500 width=0)
      (actual time=0.891..0.891 rows=125000 loops=1)
        Index Cond: (metadata @> '{"category": "electronics", "in_stock": true}'::jsonb)
Planning Time: 0.152 ms
Execution Time: 12.345 ms

jsonb_ops vs jsonb_path_opsの比較:

特性jsonb_ops(デフォルト)jsonb_path_ops
サポート演算子@>, ?, ?|, ?&, @@, @?@>, @@, @?
インデックスサイズ大(キー+パス全体をインデックス)小(パスハッシュのみ格納)
キー存在確認可能不可能
包含検索速度高速より高速
-- 全文検索用テーブルとGINインデックス
CREATE TABLE articles (
    id SERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    body TEXT NOT NULL,
    tsv tsvector GENERATED ALWAYS AS (
        setweight(to_tsvector('english', title), 'A') ||
        setweight(to_tsvector('english', body), 'B')
    ) STORED
);

-- GINインデックス作成
CREATE INDEX idx_articles_tsv ON articles USING gin (tsv);

-- 全文検索クエリ
EXPLAIN ANALYZE
SELECT id, title, ts_rank(tsv, q) AS rank
FROM articles, to_tsquery('english', 'postgresql & indexing') AS q
WHERE tsv @@ q
ORDER BY rank DESC
LIMIT 10;

配列インデックス

-- タグ配列にGINインデックスを作成
CREATE TABLE posts (
    id SERIAL PRIMARY KEY,
    title TEXT,
    tags TEXT[]
);

CREATE INDEX idx_posts_tags_gin ON posts USING gin (tags);

-- 配列包含検索
EXPLAIN ANALYZE
SELECT * FROM posts WHERE tags @> ARRAY['postgresql', 'performance'];

-- 配列オーバーラップ検索
EXPLAIN ANALYZE
SELECT * FROM posts WHERE tags && ARRAY['database', 'backend'];

GIN Pending Listとfastupdate

GINインデックスはデフォルトで fastupdate=on に設定されている。新しい行が挿入される際、即座にインデックスを更新せずにPending Listに一時格納し、VACUUMやPending Listのサイズ超過時に一括マージする。

  • メリット: 書き込み性能向上(特に大量INSERT時)
  • デメリット: Pending Listマージ時のCPU/IOスパイク発生の可能性、検索時にPending Listの追加スキャンが必要

本番環境では gin_pending_list_limit パラメータを調整するか、ピーク時間前に手動で SELECT gin_clean_pending_list('idx_name') を呼び出す戦略が効果的である。

GiSTインデックス -- 空間データと範囲型

内部構造

GiST(Generalized Search Tree)は拡張可能なバランスツリーフレームワークである。B-treeとは異なり、GiSTはオペレータークラスに定義されたconsistent、union、penalty、picksplitなどのメソッドを通じて、多様なデータ型と検索戦略をサポートする。

内部的にはR-tree構造を使用し、空間データをバウンディングボックス(MBR: Minimum Bounding Rectangle)で囲んで階層的に構成する。

空間データのインデックス化(PostGIS)

-- PostGIS拡張のインストール
CREATE EXTENSION IF NOT EXISTS postgis;

-- 空間データテーブル
CREATE TABLE stores (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    location GEOMETRY(Point, 4326) NOT NULL
);

-- 100万件のテストデータ(ソウル周辺のランダム座標)
INSERT INTO stores (name, location)
SELECT
    'store_' || i,
    ST_SetSRID(ST_MakePoint(
        126.9 + random() * 0.2,   -- 経度
        37.4 + random() * 0.2     -- 緯度
    ), 4326)
FROM generate_series(1, 1000000) AS i;

-- GiSTインデックス作成
CREATE INDEX idx_stores_location_gist ON stores USING gist (location);

-- 半径1km以内の店舗検索
EXPLAIN ANALYZE
SELECT id, name,
       ST_Distance(location::geography,
                   ST_SetSRID(ST_MakePoint(127.0, 37.5), 4326)::geography) AS distance_m
FROM stores
WHERE ST_DWithin(location::geography,
                 ST_SetSRID(ST_MakePoint(127.0, 37.5), 4326)::geography,
                 1000)
ORDER BY distance_m
LIMIT 20;

実行結果例:

Limit  (cost=8.45..8.50 rows=20 width=44)
  (actual time=2.345..2.678 rows=20 loops=1)
  ->  Sort  (cost=8.45..8.52 rows=25 width=44)
      (actual time=2.340..2.350 rows=20 loops=1)
        Sort Key: (st_distance(...))
        Sort Method: top-N heapsort  Memory: 27kB
        ->  Index Scan using idx_stores_location_gist on stores
            (cost=0.42..7.89 rows=25 width=44)
            (actual time=0.234..2.123 rows=785 loops=1)
              Index Cond: (location && ...)
              Filter: st_dwithin(...)
Planning Time: 0.456 ms
Execution Time: 2.789 ms

GiSTインデックスがなければSequential Scanが発生し、100万件全体をスキャンする必要があるため数秒かかる。

範囲型のインデックス化

-- 予約システムでの時間範囲オーバーラップ検索
CREATE TABLE reservations (
    id SERIAL PRIMARY KEY,
    room_id INT NOT NULL,
    period TSTZRANGE NOT NULL,
    guest_name TEXT
);

-- GiSTインデックスで範囲オーバーラップ検索を最適化
CREATE INDEX idx_reservations_period_gist ON reservations USING gist (period);

-- 特定期間とオーバーラップする予約の検索
EXPLAIN ANALYZE
SELECT * FROM reservations
WHERE period && tstzrange('2026-03-10 14:00', '2026-03-10 18:00', '[)');

-- EXCLUDE制約でオーバーラップを防止(GiST必須)
ALTER TABLE reservations
ADD CONSTRAINT no_overlap
EXCLUDE USING gist (room_id WITH =, period WITH &&);

GiSTがサポートする範囲演算子: &&(オーバーラップ)、@>(包含)、<@(被包含)、<<(左側)、>>(右側)、-|-(隣接)

KNN(K-Nearest Neighbor)検索

GiSTインデックスは ORDER BY distance パターンをインデックスレベルで処理するKNN検索をサポートする。

-- 最も近い10店舗をインデックススキャンで検索
SELECT id, name, location <-> ST_SetSRID(ST_MakePoint(127.0, 37.5), 4326) AS dist
FROM stores
ORDER BY location <-> ST_SetSRID(ST_MakePoint(127.0, 37.5), 4326)
LIMIT 10;

<-> 演算子と ORDER BY ... LIMIT の組み合わせにより、GiSTインデックスを使用した効率的なKNN検索が行われる。テーブル全体をソートするのではなく、インデックスツリーを走査しながら近い順に結果を返す。

BRINインデックス -- 時系列と大規模テーブル

内部構造

BRIN(Block Range Index)はテーブルの物理的なブロック範囲(デフォルト128ページ)ごとに最小値と最大値の要約情報のみを格納する。インデックスサイズが極めて小さく、数十億行のテーブルでも数MB程度である。

核心的な前提条件: カラム値と物理的な格納順序の間に強い相関関係がなければならない。時系列データにおけるタイムスタンプカラムが代表的な例である。

時系列データの活用

-- イベントログテーブル(時系列)
CREATE TABLE events (
    id BIGSERIAL PRIMARY KEY,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    event_type TEXT NOT NULL,
    payload JSONB
);

-- 5000万件のテストデータ投入
INSERT INTO events (created_at, event_type, payload)
SELECT
    '2025-01-01'::timestamptz + (i || ' seconds')::interval,
    (ARRAY['click', 'view', 'purchase', 'signup'])[1 + (i % 4)],
    jsonb_build_object('user_id', (i % 100000), 'value', random() * 100)
FROM generate_series(1, 50000000) AS i;

-- B-treeインデックス作成(比較基準)
CREATE INDEX idx_events_created_btree ON events (created_at);

-- BRINインデックス作成
CREATE INDEX idx_events_created_brin ON events USING brin (created_at)
WITH (pages_per_range = 128);

-- インデックスサイズ比較
SELECT
    indexname,
    pg_size_pretty(pg_relation_size(indexname::regclass)) AS index_size
FROM pg_indexes
WHERE tablename = 'events' AND indexname LIKE 'idx_events_created%';

サイズ比較結果例:

       indexname              | index_size
------------------------------+------------
 idx_events_created_btree     | 1071 MB
 idx_events_created_brin      | 128 kB

B-treeと比較して約8,500倍小さいインデックスで同じ範囲検索を処理できる。

-- BRINインデックスを使用した範囲検索
EXPLAIN ANALYZE
SELECT count(*) FROM events
WHERE created_at BETWEEN '2025-06-01' AND '2025-06-30';

実行結果例:

Aggregate  (cost=456789.12..456789.13 rows=1 width=8)
  (actual time=234.567..234.568 rows=1 loops=1)
  ->  Bitmap Heap Scan on events  (cost=48.12..445678.90 rows=2592000 width=0)
      (actual time=12.345..198.765 rows=2592000 loops=1)
        Recheck Cond: (created_at >= ... AND created_at <= ...)
        Rows Removed by Recheck: 45678
        Heap Blocks: lossy=19200
        ->  Bitmap Index Scan on idx_events_created_brin  (cost=0.00..47.50 rows=2600000 width=0)
            (actual time=0.234..0.234 rows=192000 loops=1)
Planning Time: 0.123 ms
Execution Time: 256.789 ms

pages_per_rangeチューニング

pages_per_range の値は精度とインデックスサイズのトレードオフである。

pages_per_rangeインデックスサイズ精度最適な用途
32大きく増加小範囲クエリが頻繁な場合
128(デフォルト)汎用時系列データ
256以上極小大範囲クエリが中心の場合

BRINが適さない場合

  • データがランダムに挿入され、物理的順序と論理的順序が一致しない場合
  • UPDATEにより行が別のページに移動(HOT失敗)し、相関関係が崩れた場合
  • ポイントクエリ(単一行検索)が主なワークロードの場合

pg_stats ビューの correlation 値を確認するとBRINの適合性を判断できる。絶対値が1に近いほど適している。

SELECT tablename, attname, correlation
FROM pg_stats
WHERE tablename = 'events' AND attname = 'created_at';
-- correlation値が0.95以上であればBRIN使用に適している

Partial IndexとExpression Index

Partial Index(部分インデックス)

テーブルの一部の行にのみインデックスを作成して、インデックスサイズを削減し書き込み性能を改善する。

-- 注文テーブルでアクティブな注文のみインデックス化
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    customer_id INT NOT NULL,
    status TEXT NOT NULL DEFAULT 'pending',
    total_amount NUMERIC(10,2),
    created_at TIMESTAMPTZ DEFAULT now()
);

-- フルインデックス vs 部分インデックスのサイズ比較
CREATE INDEX idx_orders_status_full ON orders (status, created_at);
CREATE INDEX idx_orders_status_partial ON orders (created_at)
    WHERE status IN ('pending', 'processing');

-- 部分インデックスが使用されるクエリ
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE status = 'pending'
  AND created_at > now() - interval '7 days'
ORDER BY created_at DESC;

部分インデックスの核心は、クエリのWHERE句がインデックスのWHERE条件を含意(imply)しなければならないという点である。PostgreSQLオプティマイザは単純な等値関係と一部の含意関係を認識できるが、複雑な表現式は認識できない場合がある。

Unique Partial Indexによる条件付きユニーク制約

-- ユーザーごとにアクティブなメールは1つだけ許可
CREATE UNIQUE INDEX idx_unique_active_email
ON users (email) WHERE is_active = true;

-- is_active = falseの行は重複メールを許可
-- is_active = trueの行はメールのユニーク性を保証

Expression Index(式インデックス)

カラム値を変換した結果にインデックスを作成する。クエリで同一の式を使用しなければインデックスは活用されない。

-- 大文字小文字無視検索のための式インデックス
CREATE INDEX idx_users_email_lower ON users (lower(email));

-- このクエリはインデックスを使用
EXPLAIN ANALYZE
SELECT * FROM users WHERE lower(email) = 'user@example.com';

-- 日付抽出の式インデックス
CREATE INDEX idx_orders_created_date ON orders ((created_at::date));

-- 日付別集計に活用
EXPLAIN ANALYZE
SELECT created_at::date AS order_date, count(*)
FROM orders
WHERE created_at::date = '2026-03-10'
GROUP BY created_at::date;

-- JSONB特定キーに対する式インデックス(GINより小さいサイズ)
CREATE INDEX idx_products_category ON products ((metadata->>'category'));

-- B-treeベースなので等値/範囲検索が可能
EXPLAIN ANALYZE
SELECT * FROM products WHERE metadata->>'category' = 'electronics';

Partial + Expressionの組み合わせ

-- 最近7日間のアクティブユーザーのメール検索最適化
CREATE INDEX idx_recent_active_users_email
ON users (lower(email))
WHERE last_login_at > now() - interval '7 days'
  AND is_active = true;

この組み合わせはインデックスサイズを劇的に削減しながらも、特定のクエリパターンに対して最適な性能を提供する。

EXPLAIN ANALYZEを活用したインデックス性能分析

インデックスが実際に使用されているか確認する最も確実な方法はEXPLAIN ANALYZEである。

-- 基本的なEXPLAIN ANALYZE
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM products
WHERE metadata @> '{"category": "electronics"}';

-- 実行計画の確認ポイント:
-- 1. Scanタイプ: Index Scan vs Bitmap Index Scan vs Seq Scan
-- 2. actual time: 実際の所要時間
-- 3. rows: 予想行数 vs 実際行数の差異
-- 4. Buffers: shared hit(キャッシュ)vs shared read(ディスク)

インデックス未使用の原因診断

インデックスが存在するにもかかわらず使用されない主な原因:

  1. 統計情報の不正確さ: ANALYZE 未実行でオプティマイザが誤ったカーディナリティを推定
  2. 低い選択度: 結果がテーブルの10-15%以上の場合、Sequential Scanの方が効率的
  3. 型の不一致: クエリ条件のデータ型とインデックスカラムの型が異なる
  4. 式の不一致: Expression Indexとクエリの式が正確に一致しない
  5. enable_indexscan = off: セッションレベルでインデックススキャンが無効化されている
-- 使用されていないインデックスの検出
SELECT
    schemaname || '.' || relname AS table,
    indexrelname AS index,
    pg_size_pretty(pg_relation_size(i.indexrelid)) AS index_size,
    idx_scan AS times_used,
    idx_tup_read AS tuples_read
FROM pg_stat_user_indexes i
JOIN pg_index USING (indexrelid)
WHERE idx_scan = 0
  AND NOT indisunique
  AND NOT indisprimary
ORDER BY pg_relation_size(i.indexrelid) DESC;

インデックスブロート管理とREINDEX

インデックスブロートとは

PostgreSQLのMVCCアーキテクチャにより、UPDATEは内部的にDELETE + INSERTとして処理される。削除されたタプルのインデックスエントリはVACUUMが整理するまでスペースを占有する。これが蓄積するとインデックスブロートが発生する -- インデックスが異常に肥大化する現象である。

ブロートの測定

-- pgstattuple拡張によるブロート測定
CREATE EXTENSION IF NOT EXISTS pgstattuple;

SELECT
    avg_leaf_density,
    leaf_pages,
    empty_pages,
    deleted_pages,
    round(100 - avg_leaf_density, 1) AS bloat_pct
FROM pgstatindex('idx_orders_status_full');

-- bloat_pctが20%を超えたらREINDEXを検討

REINDEX CONCURRENTLY

本番環境ではテーブルロックなしでインデックスを再構築できる REINDEX CONCURRENTLY を使用する。

-- 特定インデックスの再構築(無停止)
REINDEX INDEX CONCURRENTLY idx_orders_status_full;

-- テーブルの全インデックス再構築
REINDEX TABLE CONCURRENTLY orders;

-- スキーマ全体のインデックス再構築
REINDEX SCHEMA CONCURRENTLY public;

注意事項:

  • REINDEX CONCURRENTLYが途中で失敗すると、_ccnew サフィックスが付いた無効なインデックスが残る。必ず確認して削除する必要がある。
  • REINDEX CONCURRENTLY実行中はxmin horizonを保持するため、長時間実行すると他のVACUUMのデッドタプル整理が遅延する可能性がある。
-- 無効なインデックスの確認
SELECT indexrelid::regclass AS index_name,
       indisvalid
FROM pg_index
WHERE NOT indisvalid;

-- 無効なインデックスの削除
-- DROP INDEX CONCURRENTLY idx_name_ccnew;

自動管理戦略

-- ブロート20%超過インデックスを検出するモニタリングクエリ
SELECT
    schemaname || '.' || tablename AS table,
    indexname,
    pg_size_pretty(pg_relation_size(indexname::regclass)) AS size,
    round(100 - (pgstatindex(indexname)).avg_leaf_density, 1) AS bloat_pct
FROM pg_indexes
WHERE schemaname = 'public'
ORDER BY bloat_pct DESC NULLS LAST;

インデックスタイプ別比較表

特性B-treeGINGiSTBRIN
最適なデータ型スカラー値JSONB、配列、tsvector空間、範囲時系列、順次
インデックスサイズ大(テーブルの60-80%)極小(KB単位)
書き込みオーバーヘッド極低
ポイントクエリ最適サポートサポート非効率
範囲クエリ最適非サポートサポート最適(高相関時)
全文検索非サポート最適非サポート非サポート
空間クエリ非サポート非サポート最適限定的
KNN検索非サポート非サポート最適非サポート
ユニーク制約サポート非サポート非サポート非サポート
インデックスのみスキャンサポート非サポート非サポート非サポート
CONCURRENTLY作成サポートサポートサポートサポート
物理ソート必須不要不要不要必須

運用チェックリスト

インデックス作成前チェックリスト

  • クエリパターン分析: pg_stat_statements で頻度の高いクエリを確認
  • 対象カラムのデータ型と演算子を確認(B-treeで十分かを判断)
  • EXPLAIN ANALYZE で現在の実行計画を確認
  • テーブルサイズと予想インデックスサイズの算定
  • CONCURRENTLYオプションによる無停止作成の計画策定

定期モニタリング項目

  • 使用されていないインデックス(pg_stat_user_indexes.idx_scan = 0
  • インデックスブロート比率(pgstatindex 関数)
  • テーブルサイズに対するインデックスサイズの比率
  • BRINインデックスの相関関係(pg_stats.correlation
  • GIN Pending Listのサイズ(pg_stat_all_indexes.idx_tup_insert

インデックスタイプ選択フロー

  1. 等値/範囲/ソートクエリ -> B-tree
  2. JSONB包含検索、配列、全文検索 -> GIN
  3. 空間データ、範囲オーバーラップ、KNN -> GiST
  4. 時系列/append-only大規模テーブルの範囲検索 -> BRIN
  5. 特定条件の行のみ頻繁に検索 -> Partial Index
  6. 関数/変換結果ベースの検索 -> Expression Index

まとめ

PostgreSQLの高度インデックスはそれぞれ明確な設計目的と最適シナリオを持っている。核心はデータの特性とクエリパターンに合ったインデックスタイプを選択することである。

  • JSONBや全文検索が多い場合はGINを検討するが、書き込みオーバーヘッドとインデックスサイズを考慮する。
  • 空間データや範囲オーバーラップクエリがある場合、GiSTは事実上必須である。
  • 数十億行の時系列テーブルであれば、BRINでインデックスサイズを99%以上削減できる。
  • Partial IndexとExpression Indexはどのインデックスタイプとも組み合わせ可能で、追加的な最適化を提供する。

最も重要なのは、EXPLAIN ANALYZEで実際の実行計画を検証し、本番環境でインデックスブロートを継続的にモニタリングすることである。インデックスは作って放置するものではなく、継続的に管理すべき運用対象である。