「本番環境でページの読み込みが遅い」「特定のAPIエンドポイントだけタイムアウトする」「データ量が増えるにつれてどんどん遅くなる」――こうした問題の多くは、SQLクエリのパフォーマンスに起因しています。
本記事では、SQLパフォーマンスチューニングの基本から実践まで、PostgreSQLを例にしながら具体的なテクニックを解説します。EXPLAIN ANALYZEの読み方、効果的なインデックス設計、N+1問題の解決方法など、明日からすぐに使える知識を身につけましょう。
パフォーマンス問題の原因を特定する
チューニングの第一歩は、「何が遅いのか」を正確に把握することです。闇雲にインデックスを追加しても効果がないどころか、逆にパフォーマンスが悪化する場合もあります。
スロークエリログの有効化
まず、PostgreSQLのスロークエリログを有効にして、遅いクエリを自動記録しましょう。
-- postgresql.confの設定
-- 200ms以上かかるクエリをログに記録
log_min_duration_statement = 200
-- すべてのクエリの統計を記録(pg_stat_statementsを有効化)
shared_preload_libraries = 'pg_stat_statements'
-- pg_stat_statementsで遅いクエリのランキングを取得
SELECT
query,
calls,
round(total_exec_time::numeric, 2) AS total_time_ms,
round(mean_exec_time::numeric, 2) AS mean_time_ms,
rows
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20;
この結果から、実行回数が多くて平均実行時間が長いクエリを優先的にチューニングすると、全体的なパフォーマンスが大きく改善します。
EXPLAIN ANALYZEの基本的な読み方
EXPLAIN ANALYZEはクエリの実行計画を表示する最も重要なツールです。
EXPLAIN ANALYZE
SELECT p.*, u.display_name
FROM posts p
JOIN users u ON p.author_id = u.id
WHERE p.status = 'published'
ORDER BY p.published_at DESC
LIMIT 10;
出力例を見てみましょう。
Limit (cost=0.56..12.34 rows=10 width=520) (actual time=0.089..0.142 rows=10 loops=1)
-> Nested Loop (cost=0.56..1234.56 rows=500 width=520) (actual time=0.088..0.139 rows=10 loops=1)
-> Index Scan Backward using idx_posts_published on posts p
(cost=0.28..800.00 rows=500 width=480) (actual time=0.065..0.085 rows=10 loops=1)
Filter: ((status)::text = 'published'::text)
-> Index Scan using users_pkey on users u
(cost=0.28..0.35 rows=1 width=40) (actual time=0.004..0.004 rows=1 loops=10)
Index Cond: (id = p.author_id)
Planning Time: 0.256 ms
Execution Time: 0.178 ms
重要な読み方のポイントは以下の通りです。
- Seq Scan:テーブルの全行を順番にスキャン(大きなテーブルでは遅い)
- Index Scan:インデックスを使った効率的なスキャン(高速)
- Index Only Scan:インデックスだけでデータを返す(最速)
- actual time:実際にかかった時間(ミリ秒)
- rows:実際に読み取った行数
- loops:ループ回数(Nested Loopの内側で重要)
インデックス設計の基本と実践
インデックスはSQLパフォーマンスチューニングの最も基本的かつ効果的な手段です。
インデックスが効くケースと効かないケース
-- インデックスが効くケース
SELECT * FROM users WHERE email = 'test@example.com'; -- 等値検索
SELECT * FROM posts WHERE published_at > '2026-01-01'; -- 範囲検索
SELECT * FROM posts WHERE status = 'published'
ORDER BY published_at DESC; -- ソート
SELECT * FROM products WHERE name LIKE 'iPhone%'; -- 前方一致
-- インデックスが効かないケース(注意!)
SELECT * FROM products WHERE name LIKE '%Phone%'; -- 中間一致
SELECT * FROM users WHERE LOWER(email) = 'test@example.com'; -- 関数の適用
SELECT * FROM orders WHERE EXTRACT(YEAR FROM ordered_at) = 2026; -- 関数の適用
SELECT * FROM products WHERE price + 100 > 1000; -- 演算
関数を使った検索でインデックスを効かせるには、式インデックスを作成します。
-- 関数インデックスの作成
CREATE INDEX idx_users_email_lower ON users (LOWER(email));
-- これでインデックスが効くようになる
SELECT * FROM users WHERE LOWER(email) = 'test@example.com';
複合インデックスの設計
複合インデックスの列の順序は非常に重要です。
-- 複合インデックスの作成
CREATE INDEX idx_posts_status_published ON posts(status, published_at DESC);
-- このインデックスが効くクエリ
SELECT * FROM posts WHERE status = 'published'
ORDER BY published_at DESC; -- 両方の列を使用
SELECT * FROM posts WHERE status = 'draft'; -- 先頭の列のみ使用(OK)
-- このインデックスが効かないクエリ
SELECT * FROM posts WHERE published_at > '2026-01-01'; -- 先頭の列を使っていない
複合インデックスの列順を決めるルールは以下の通りです。
- 等値条件(=)に使われる列を先に
- 範囲条件(>、<、BETWEEN)に使われる列を後に
- ORDER BYで使われる列を最後に
部分インデックスで効率化
-- 全体のインデックス(大きい)
CREATE INDEX idx_posts_published_at ON posts(published_at DESC);
-- 部分インデックス(公開済みの記事だけ、小さくて高速)
CREATE INDEX idx_posts_published_only ON posts(published_at DESC)
WHERE status = 'published';
部分インデックスは、特定の条件でしか検索しない場合に非常に効果的です。インデックスサイズが小さくなるため、メモリ効率も向上します。
カバリングインデックス
クエリで必要なすべてのカラムがインデックスに含まれていれば、テーブルへのアクセスが不要になり、Index Only Scanが実現します。
-- カバリングインデックス
CREATE INDEX idx_posts_list ON posts(status, published_at DESC)
INCLUDE (title, slug, excerpt);
-- このクエリはIndex Only Scanで実行される
SELECT title, slug, excerpt, published_at
FROM posts
WHERE status = 'published'
ORDER BY published_at DESC
LIMIT 10;
遅いクエリパターンと改善方法
よくある遅いクエリのパターンと、その改善方法を紹介します。
N+1問題
N+1問題は、ORM(Object-Relational Mapping)を使ったアプリケーションで最も頻出するパフォーマンス問題です。
-- N+1問題の例(ORMが生成しがちなクエリ)
-- 1回目:記事一覧を取得
SELECT * FROM posts WHERE status = 'published' LIMIT 10;
-- 2〜11回目:各記事の著者情報を個別に取得(N回)
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
...
-- 合計11回のクエリが実行される!
-- 改善方法1:JOINで1回のクエリにまとめる
SELECT p.*, u.display_name, u.avatar_url
FROM posts p
JOIN users u ON p.author_id = u.id
WHERE p.status = 'published'
ORDER BY p.published_at DESC
LIMIT 10;
-- 改善方法2:IN句でまとめて取得(ORMのEager Loading)
SELECT * FROM posts WHERE status = 'published' LIMIT 10;
SELECT * FROM users WHERE id IN (1, 2, 3, 5, 8, 13, ...);
-- 合計2回のクエリで済む
SELECT * の回避
-- 悪い例:不要なカラムも全て取得
SELECT * FROM posts WHERE status = 'published';
-- 良い例:必要なカラムだけ取得
SELECT id, title, slug, excerpt, published_at
FROM posts
WHERE status = 'published'
ORDER BY published_at DESC;
SELECT *は、TEXTやJSONBなど大きなカラムを含む場合に特に遅くなります。必要なカラムだけを明示的に指定しましょう。
OFFSETの代わりにカーソルベースのページネーション
-- 遅い:OFFSETベースのページネーション(ページが深くなるほど遅くなる)
SELECT * FROM posts
WHERE status = 'published'
ORDER BY published_at DESC
LIMIT 10 OFFSET 10000; -- 10000行スキップしてから10行取得
-- 速い:カーソルベースのページネーション
SELECT * FROM posts
WHERE status = 'published'
AND published_at < '2026-02-15T10:30:00Z' -- 前ページの最後の記事のpublished_at
ORDER BY published_at DESC
LIMIT 10;
OFFSETベースのページネーションは、内部的にOFFSET+LIMIT行を読み取ってからOFFSET行を捨てるため、ページが深くなるほど遅くなります。カーソルベースなら常に一定の速度です。
サブクエリよりもJOINやCTEを使う
-- 遅くなりがちなサブクエリ
SELECT * FROM products
WHERE id IN (
SELECT product_id FROM order_items
WHERE order_id IN (
SELECT id FROM orders WHERE user_id = 1
)
);
-- CTEで整理(読みやすく、最適化もされやすい)
WITH user_orders AS (
SELECT id FROM orders WHERE user_id = 1
),
ordered_products AS (
SELECT DISTINCT product_id
FROM order_items
WHERE order_id IN (SELECT id FROM user_orders)
)
SELECT p.*
FROM products p
JOIN ordered_products op ON p.id = op.product_id;
-- JOINで書き換え(最も効率的なケースが多い)
SELECT DISTINCT p.*
FROM products p
JOIN order_items oi ON p.id = oi.product_id
JOIN orders o ON oi.order_id = o.id
WHERE o.user_id = 1;
集計クエリの最適化
集計処理はデータ量が増えるほど遅くなりやすい部分です。効率的な方法を知っておきましょう。
COUNTの最適化
-- 遅い:正確な件数が不要なのにCOUNTする
SELECT COUNT(*) FROM posts WHERE status = 'published';
-- 速い:概算でよい場合はpg_class.reltuplesを使う
SELECT reltuples::bigint AS estimated_count
FROM pg_class
WHERE relname = 'posts';
-- 「件数が多いかどうか」だけ知りたい場合
SELECT EXISTS (
SELECT 1 FROM posts WHERE status = 'published' LIMIT 1
);
マテリアライズドビューで集計結果をキャッシュ
-- マテリアライズドビューの作成
CREATE MATERIALIZED VIEW mv_category_stats AS
SELECT
c.id AS category_id,
c.name AS category_name,
COUNT(p.id) AS post_count,
MAX(p.published_at) AS latest_published_at
FROM categories c
LEFT JOIN posts p ON c.id = p.category_id AND p.status = 'published'
GROUP BY c.id, c.name;
-- インデックスの作成
CREATE UNIQUE INDEX idx_mv_category_stats ON mv_category_stats(category_id);
-- クエリ(事前計算された結果を即座に返す)
SELECT * FROM mv_category_stats ORDER BY post_count DESC;
-- データ更新時にリフレッシュ
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_category_stats;
CONCURRENTLYオプションを付けると、リフレッシュ中もビューへの読み取りが可能です。ただし、ユニークインデックスが必要です。
テーブル設計レベルの最適化
クエリのチューニングだけでなく、テーブル設計の改善がパフォーマンスに劇的な効果をもたらすことがあります。
適切なデータ型の選択
-- 悪い例:不適切なデータ型
CREATE TABLE events (
id VARCHAR(36), -- UUIDを文字列で保存(比較が遅い)
event_date VARCHAR(10), -- 日付を文字列で保存(ソートが遅い)
is_active VARCHAR(5), -- 真偽値を文字列で保存
price FLOAT -- 金額に浮動小数点(丸め誤差)
);
-- 良い例:適切なデータ型
CREATE TABLE events (
id UUID DEFAULT gen_random_uuid(), -- ネイティブUUID型
event_date DATE, -- DATE型
is_active BOOLEAN DEFAULT true, -- BOOLEAN型
price NUMERIC(10, 2) -- 正確な小数型
);
パーティショニングによる大規模テーブルの分割
-- レンジパーティショニング(日付ベース)
CREATE TABLE access_logs (
id BIGSERIAL,
user_id INTEGER,
path VARCHAR(500),
status INTEGER,
created_at TIMESTAMPTZ NOT NULL
) PARTITION BY RANGE (created_at);
-- 月ごとのパーティション作成
CREATE TABLE access_logs_2026_01 PARTITION OF access_logs
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE access_logs_2026_02 PARTITION OF access_logs
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE TABLE access_logs_2026_03 PARTITION OF access_logs
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
-- 特定月のデータ検索はその月のパーティションだけスキャンする
SELECT * FROM access_logs
WHERE created_at >= '2026-03-01' AND created_at < '2026-04-01';
パーティショニングは、数百万行以上のテーブルで特に効果的です。古いデータの削除もパーティション単位で高速に行えます。
VACUUM と ANALYZE
-- テーブルの統計情報を更新(クエリプランナーの精度向上)
ANALYZE posts;
-- 不要な領域を回収
VACUUM posts;
-- VACUUM + ANALYZE を同時実行
VACUUM ANALYZE posts;
-- テーブルの肥大化を確認
SELECT
schemaname,
tablename,
n_dead_tup,
n_live_tup,
round(n_dead_tup::numeric / NULLIF(n_live_tup, 0) * 100, 2) AS dead_ratio_pct
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;
PostgreSQLのAutoVacuumはデフォルトで有効ですが、大量の更新・削除が発生するテーブルでは手動でVACUUMが必要な場合があります。
アプリケーション側の最適化
データベースだけでなく、アプリケーション側でも対策が必要です。
コネクションプーリング
-- PgBouncerの設定例(pgbouncer.ini)
[databases]
mydb = host=localhost port=5432 dbname=mydb
[pgbouncer]
listen_port = 6432
listen_addr = 0.0.0.0
auth_type = md5
pool_mode = transaction -- トランザクションモード(推奨)
max_client_conn = 1000 -- 最大クライアント接続数
default_pool_size = 25 -- プールサイズ
クエリの結果をキャッシュする
// Node.js + Redis でのキャッシュ例
import Redis from 'ioredis';
const redis = new Redis();
async function getPublishedPosts(page: number) {
const cacheKey = `posts:published:page:${page}`;
// キャッシュから取得を試みる
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// キャッシュがなければDBから取得
const posts = await db.query(`
SELECT id, title, slug, excerpt, published_at
FROM posts
WHERE status = 'published'
ORDER BY published_at DESC
LIMIT 10 OFFSET $1
`, [(page - 1) * 10]);
// キャッシュに保存(5分間)
await redis.set(cacheKey, JSON.stringify(posts), 'EX', 300);
return posts;
}
まとめ:パフォーマンスチューニングのチェックリスト
SQLパフォーマンスチューニングのポイントを整理します。
原因特定
- スロークエリログを有効にして遅いクエリを特定する
- EXPLAIN ANALYZEで実行計画を確認する
- Seq Scanが大きなテーブルで発生していないかチェック
インデックス設計
- WHERE句、JOIN条件、ORDER BY句で使われるカラムにインデックスを作成
- 複合インデックスの列順序は等値条件→範囲条件→ソート条件
- 部分インデックス、カバリングインデックスを活用
クエリの最適化
- N+1問題をJOINやEager Loadingで解消
- SELECT *を避け、必要なカラムだけ取得
- OFFSETの代わりにカーソルベースのページネーションを使う
テーブル設計
- 適切なデータ型を選択
- 大規模テーブルはパーティショニングを検討
- 集計結果はマテリアライズドビューでキャッシュ
パフォーマンスチューニングは計測 → 分析 → 改善 → 計測のサイクルが基本です。推測ではなくデータに基づいて判断し、改善の効果を数値で確認しましょう。まずはEXPLAIN ANALYZEを実行するところから始めてみてください。
関連記事
AIエージェント開発入門|自律型AIの仕組みと構築方法を解説【2026年版】
AI駆動コーディングワークフロー|Claude Code・Cursor・Copilotの実践的使い分け
プロンプトエンジニアリング上級編|Chain-of-Thought・Few-Shot・ReActの実践
APIレート制限の設計と実装|トークンバケット・スライディングウィンドウ解説
APIバージョニング戦略|URL・ヘッダー・クエリパラメータの使い分け
BIツール入門|Metabase・Redash・Looker Studioでデータ可視化する方法
チャットボット開発入門|LINE Bot・Slack Botの構築方法と活用事例
CI/CDパイプラインの基礎|継続的インテグレーション・デリバリーの全体像