PostgreSQLのインデックスでクエリを100倍高速化!作成から運用まで完全マスター

データベース・SQL

「SELECTが遅すぎて、レポート生成に10分もかかる…」
「WHERE句を使った検索が、なぜこんなに時間がかかるの?」
「インデックスを作りたいけど、どこに作ればいいか分からない」

こんな悩み、インデックスを正しく使えば一瞬で解決します!

実際、適切なインデックスを作るだけで、クエリの実行時間が10秒から0.1秒になることも珍しくありません。まさにデータベースの魔法です。

この記事を読めば、インデックスの作り方から最適化まで、すべてマスターできます!


スポンサーリンク

インデックスを超簡単に理解する

本の索引に例えると

インデックスは、まさに本の巻末にある「索引」と同じです。

本で「PostgreSQL」という単語を探す時:

  • 索引なし:1ページ目から全部読む(遅い)
  • 索引あり:索引で「PostgreSQL…234ページ」を見て直行(速い)

データベースも同じ:

  • インデックスなし:全データをスキャン(フルスキャン)
  • インデックスあり:目的のデータに直接アクセス

インデックスの効果を数字で実感

-- 100万件のユーザーテーブル
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255),
    name VARCHAR(100),
    created_at TIMESTAMP
);

-- インデックスなしで検索
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com';
-- 実行時間: 523.456 ms

-- インデックスを作成
CREATE INDEX idx_users_email ON users(email);

-- インデックスありで検索
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com';
-- 実行時間: 0.456 ms (1000倍速い!)

基本編:インデックスの作成方法

基本構文

CREATE INDEX インデックス名 ON テーブル名 (カラム名);

実践例1:単一カラムインデックス

-- 商品テーブル
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(200),
    category VARCHAR(50),
    price DECIMAL(10,2),
    stock INTEGER,
    created_at TIMESTAMP
);

-- よく検索されるカラムにインデックスを作成
CREATE INDEX idx_products_name ON products(name);
CREATE INDEX idx_products_category ON products(category);
CREATE INDEX idx_products_price ON products(price);

実践例2:複合インデックス(複数カラム)

-- 複数カラムを組み合わせた検索が多い場合
CREATE INDEX idx_products_category_price ON products(category, price);

-- この順番が重要!
-- WHERE category = 'electronics' AND price < 10000  -- 速い
-- WHERE price < 10000  -- このインデックスは使われない
-- WHERE category = 'electronics'  -- 速い(最初のカラムだけでもOK)

実践例3:部分インデックス(条件付き)

-- アクティブなユーザーだけにインデックス
CREATE INDEX idx_active_users ON users(email) 
WHERE is_active = true;

-- 在庫がある商品だけにインデックス
CREATE INDEX idx_available_products ON products(name) 
WHERE stock > 0;

インデックスの種類と使い分け

1. B-treeインデックス(デフォルト)

最も一般的で万能なインデックス:

-- 明示的にB-treeを指定(省略してもB-treeになる)
CREATE INDEX idx_btree ON users USING btree(email);

得意な検索:

  • 等価検索(=)
  • 範囲検索(<、>、BETWEEN)
  • ソート(ORDER BY)
  • NULL検索

使用例:

-- 日付の範囲検索
CREATE INDEX idx_orders_date ON orders(order_date);
SELECT * FROM orders 
WHERE order_date BETWEEN '2024-01-01' AND '2024-12-31';

2. Hashインデックス

等価検索に特化

CREATE INDEX idx_hash ON users USING hash(user_id);

特徴:

  • 等価検索(=)のみ対応
  • B-treeより少しだけ速い
  • でも制限が多いので、通常はB-treeで十分

3. GINインデックス(全文検索)

配列や全文検索に最適

-- タグの配列にインデックス
CREATE INDEX idx_tags ON articles USING gin(tags);

-- 全文検索用
CREATE INDEX idx_search ON articles USING gin(to_tsvector('japanese', content));

-- 使用例
SELECT * FROM articles WHERE tags @> ARRAY['PostgreSQL'];
SELECT * FROM articles WHERE to_tsvector('japanese', content) @@ to_tsquery('japanese', 'データベース');

4. GiSTインデックス(地理空間)

地理データや範囲型に最適

-- 位置情報
CREATE INDEX idx_location ON stores USING gist(location);

-- 使用例:1km以内の店舗を検索
SELECT * FROM stores 
WHERE location <-> point(35.6812, 139.7671) < 1;

5. BRINインデックス(巨大テーブル)

時系列データなど、物理的に順序があるデータ

-- ログテーブル(日付順に挿入される)
CREATE INDEX idx_logs_brin ON access_logs USING brin(created_at);

メリット:

  • インデックスサイズが極小(B-treeの1/100以下)
  • 巨大テーブルでも高速

いつインデックスを作るべき?作らないべき?

インデックスを作るべき場合 ✅

-- 1. WHERE句でよく使うカラム
SELECT * FROM users WHERE email = 'user@example.com';
→ CREATE INDEX idx_users_email ON users(email);

-- 2. JOIN条件に使うカラム
SELECT * FROM orders o 
JOIN users u ON o.user_id = u.id;
→ CREATE INDEX idx_orders_user_id ON orders(user_id);

-- 3. ORDER BYで使うカラム
SELECT * FROM products ORDER BY price DESC;
→ CREATE INDEX idx_products_price ON products(price DESC);

-- 4. GROUP BYで使うカラム
SELECT category, COUNT(*) FROM products GROUP BY category;
→ CREATE INDEX idx_products_category ON products(category);

インデックスを作らない方がいい場合 ❌

-- 1. 更新が頻繁なテーブル(INSERT/UPDATE/DELETEが多い)
-- インデックスの更新コストが高い

-- 2. カラムの値の種類が少ない(カーディナリティが低い)
-- 例:性別(男/女)、有効フラグ(true/false)
-- → 部分インデックスを検討

-- 3. テーブルが小さい(1000件以下)
-- フルスキャンの方が速い場合も

-- 4. ほとんど検索しないカラム
-- 作っても無駄

パフォーマンス改善の実例

ケース1:遅いクエリを100倍高速化

-- 注文履歴テーブル(500万件)
CREATE TABLE order_history (
    id SERIAL PRIMARY KEY,
    user_id INTEGER,
    product_id INTEGER,
    order_date DATE,
    amount DECIMAL(10,2),
    status VARCHAR(20)
);

-- 問題のクエリ(10秒かかる)
EXPLAIN ANALYZE
SELECT * FROM order_history 
WHERE user_id = 12345 
  AND order_date >= '2024-01-01'
ORDER BY order_date DESC;

-- 実行計画を見ると...
-- Seq Scan on order_history (cost=... rows=500000)
-- 全件スキャンしてる!

-- 解決策:複合インデックスを作成
CREATE INDEX idx_order_history_user_date 
ON order_history(user_id, order_date DESC);

-- 結果:0.1秒に短縮!(100倍高速化)

ケース2:LIKE検索の最適化

-- 商品名の前方一致検索が遅い
SELECT * FROM products WHERE name LIKE 'iPhone%';

-- text_patternインデックスで高速化
CREATE INDEX idx_products_name_pattern 
ON products(name text_pattern_ops);

-- 部分一致検索(%iPhone%)の場合
-- pg_trgmエクステンションを使用
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_products_name_trgm 
ON products USING gin(name gin_trgm_ops);

インデックスの運用・管理

インデックスの使用状況を確認

-- どのインデックスが使われているか
SELECT 
    schemaname,
    tablename,
    indexname,
    idx_scan AS 使用回数,
    idx_tup_read AS 読み取り行数,
    idx_tup_fetch AS 取得行数,
    pg_size_pretty(pg_relation_size(indexrelid)) AS サイズ
FROM pg_stat_user_indexes
ORDER BY idx_scan DESC;

不要なインデックスを見つける

-- 全く使われていないインデックス
SELECT 
    schemaname || '.' || tablename AS テーブル,
    indexname AS インデックス名,
    pg_size_pretty(pg_relation_size(indexrelid)) AS サイズ
FROM pg_stat_user_indexes
WHERE idx_scan = 0  -- 一度も使われていない
  AND indexrelname NOT LIKE '%_pkey'  -- 主キー以外
ORDER BY pg_relation_size(indexrelid) DESC;

インデックスの再構築(断片化解消)

-- オンラインで再構築(REINDEXより安全)
CREATE INDEX CONCURRENTLY idx_new ON products(name);
DROP INDEX idx_old;
ALTER INDEX idx_new RENAME TO idx_products_name;

-- または、REINDEX(テーブルロックに注意)
REINDEX INDEX idx_products_name;

インデックスの肥大化チェック

-- インデックスの肥大化率を確認
SELECT 
    schemaname || '.' || tablename AS table_name,
    indexrelname AS index_name,
    pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
    ROUND(100 * (1 - (pg_stat_get_live_tuples(indexrelid)::FLOAT / 
        NULLIF(pg_stat_get_tuples_inserted(indexrelid), 0)))) AS bloat_percent
FROM pg_stat_user_indexes
WHERE pg_relation_size(indexrelid) > 1000000  -- 1MB以上
ORDER BY pg_relation_size(indexrelid) DESC;

よくある失敗と対策

失敗1:インデックスが使われない

-- 型の不一致
CREATE INDEX idx_user_id ON orders(user_id);  -- integerカラム
SELECT * FROM orders WHERE user_id = '123';   -- 文字列で検索
-- 解決:型を合わせる
SELECT * FROM orders WHERE user_id = 123;

-- 関数を使った検索
CREATE INDEX idx_email ON users(email);
SELECT * FROM users WHERE LOWER(email) = 'test@example.com';
-- 解決:関数インデックスを作成
CREATE INDEX idx_email_lower ON users(LOWER(email));

失敗2:インデックスの作りすぎ

-- 悪い例:似たようなインデックスが複数
CREATE INDEX idx1 ON products(category);
CREATE INDEX idx2 ON products(category, price);
CREATE INDEX idx3 ON products(category, name);

-- 良い例:必要最小限に絞る
CREATE INDEX idx_products_category_price ON products(category, price);
-- categoryだけの検索も、category+priceの検索も、これ1つでカバー

失敗3:CONCURRENTLYを忘れる

-- 危険:本番環境でテーブルロック!
CREATE INDEX idx_large_table ON large_table(column);

-- 安全:ロックを最小限に
CREATE INDEX CONCURRENTLY idx_large_table ON large_table(column);
-- 時間はかかるが、サービスを止めない

実用的なインデックス戦略

Eコマースサイトの例

-- 商品検索の最適化
CREATE INDEX idx_products_search 
ON products(category, is_active, price)
WHERE is_active = true;

-- 注文検索の最適化
CREATE INDEX idx_orders_user_date 
ON orders(user_id, created_at DESC);

-- 在庫管理の最適化
CREATE INDEX idx_inventory_low_stock 
ON products(stock, name)
WHERE stock < 10;

ログ分析システムの例

-- 時系列データにBRINインデックス
CREATE INDEX idx_logs_time_brin 
ON access_logs USING brin(timestamp);

-- IPアドレス検索
CREATE INDEX idx_logs_ip 
ON access_logs USING hash(ip_address);

-- エラーログだけに部分インデックス
CREATE INDEX idx_error_logs 
ON access_logs(timestamp, message)
WHERE log_level = 'ERROR';

まとめ:インデックスマスターへの道

今日学んだ重要ポイント:

✅ インデックスは「本の索引」と同じ仕組み
✅ 適切なインデックスで100倍高速化も可能
B-treeが万能、特殊用途にGIN/GiST/BRIN
✅ WHERE/JOIN/ORDER BY/GROUP BYに作成すべき
複合インデックスは順番が重要
CONCURRENTLYで本番環境でも安全作成
✅ 使われないインデックスは定期的に削除

インデックスは「諸刃の剣」です。

適切に使えばクエリが劇的に速くなりますが、作りすぎると更新が遅くなります。大切なのは、実行計画を見て、本当に必要な場所に作ること。

今日から、遅いクエリを見つけたら、まずインデックスを疑ってください。

きっと、あなたもインデックスの魔法に魅了されるはずです!

Happy Indexing!


次のステップにおすすめ:

  • PostgreSQLの実行計画(EXPLAIN)完全解説
  • パーティショニングとインデックスの組み合わせ
  • pg_stat_statementsでスロークエリ分析

コメント

タイトルとURLをコピーしました