SQLパフォーマンスチューニング入門|遅いクエリを高速化する実践テクニック

kento_morota 20分で読めます

「本番環境でページの読み込みが遅い」「特定の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';  -- 先頭の列を使っていない

複合インデックスの列順を決めるルールは以下の通りです。

  1. 等値条件(=)に使われる列を先に
  2. 範囲条件(>、<、BETWEEN)に使われる列を後に
  3. 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を実行するところから始めてみてください。

#SQL#パフォーマンス#チューニング
共有:
無料メルマガ

週1回、最新の技術記事をお届け

AI・クラウド・開発の最新記事を毎週月曜にメールでお届けします。登録は無料、いつでも解除できます。

プライバシーポリシーに基づき管理します

起業準備に役立つ情報、もっとありますよ。

まずは話だけ聞いてもらう