記事の一覧

SQL チューニングの進め方 遅いクエリを見つけてから直すまで

SQL チューニングの手順を、遅いクエリの見つけ方、実行計画の読み方、インデックスの使い方、よくあるパターンの直し方の順に説明します。MySQL と PostgreSQL の両方で使える考え方と、それぞれの具体的なコマンドをまとめました。

遅いクエリを見つけて、実行計画を読んで、インデックスや SQL の書き方で速くする。やることはシンプルですが、順番を飛ばすと遠回りになります。「見つける → 実行計画を読む → 原因を絞る → 直す → 確かめる」の流れで進めるのが近道です。

MySQL 8.0 / 8.4 と PostgreSQL 14 以降を前提に、考え方は共通で示し、コマンドは両方載せています。テーブルは説明用のデモです。

チューニングの全体の流れ

手順は 5 つです。

  1. 遅いクエリを見つける。
  2. 実行計画を読む。
  3. 原因を絞る。
  4. 直す。
  5. 効果を確かめる。

順番を飛ばすと遠回りになります。「このクエリが遅そうだ」と勘で決めて直しても、実際に全体を遅くしていたのは別のクエリだった、というのはよくある話です。直したあとに計測しなければ、速くなったのか、キャッシュに乗っていただけなのかも分かりません。

以降、次のデモ用テーブルを使います。

CREATE TABLE customers (
  id         BIGINT PRIMARY KEY,
  name       VARCHAR(100) NOT NULL,
  region     VARCHAR(20)  NOT NULL,
  created_at TIMESTAMP    NOT NULL
);

CREATE TABLE orders (
  id          BIGINT PRIMARY KEY,
  customer_id BIGINT      NOT NULL,
  status      VARCHAR(20) NOT NULL,
  total       INTEGER     NOT NULL,
  note        TEXT,
  created_at  TIMESTAMP   NOT NULL
);

orders は数千万行、customers は数十万行あるものとします。

手順 1 遅いクエリを見つける

チューニングの対象は、体感で「遅い」と思ったクエリではなく、計測で見つけたクエリです。見る観点は 2 つ。

  • 1 回あたりの実行時間が長いクエリ
  • 1 回は速くても、実行回数が多くて合計の時間が大きいクエリ

MySQL ではスロークエリログと Performance Schema、PostgreSQL では pg_stat_statements 拡張が定番です。

MySQL PostgreSQL
1 回が遅いクエリを記録する スロークエリログ (slow_query_log、long_query_time) log_min_duration_statement
合計時間で集計する performance_schema.events_statements_summary_by_digest pg_stat_statements

設定やログの読み方は MySQL のスロークエリの記事 と PostgreSQL のスロークエリの記事 にまとめています。

合計時間の大きい順に並べて、上位から手をつけます。1 回 5 秒のクエリが 1 日に 3 回しか動かないより、1 回 50 ミリ秒のクエリが 1 日に 100 万回動くほうが、DB の負荷は重くなります。回数が多いクエリの代表が、アプリ側が原因の N+1 問題 です。

手順 2 実行計画を読む

対象が決まったら EXPLAIN で実行計画を出します。実行計画は、DB が「どのインデックスを使い、どの順にテーブルを読み、どう結合するか」を決めた段取りです。

-- MySQL / PostgreSQL 共通
EXPLAIN SELECT id, total FROM orders WHERE customer_id = 1234 AND status = 'paid';

見積もりだけでなく実測値も欲しいときは EXPLAIN ANALYZE を使います。SQL を本当に実行するので、UPDATE や DELETE にかけるときはトランザクション内で実行してロールバックするのが安全です。

-- MySQL 8.0.18 以降
EXPLAIN ANALYZE SELECT id, total FROM orders WHERE customer_id = 1234 AND status = 'paid';

-- PostgreSQL
EXPLAIN (ANALYZE, BUFFERS) SELECT id, total FROM orders WHERE customer_id = 1234 AND status = 'paid';

読み方の詳細は MySQL の EXPLAIN の記事 と PostgreSQL の EXPLAIN の記事 に譲り、ここでは要点だけ押さえます。

まず 3 つ見ます。

  • 大きなテーブルを全件読んでいないか。MySQL では type が ALL、PostgreSQL では Seq Scan
  • 読む行数の見積もりが、返す行数に比べて極端に大きくないか。MySQL では rows、PostgreSQL では rows と Rows Removed by Filter
  • 並べ替えや一時テーブルが大きくなっていないか。MySQL では Extra の Using filesort と Using temporary、PostgreSQL では Sort と Sort Method: external merge

EXPLAIN ANALYZE では見積もりの行数と実際の行数を比べます。大きくずれていれば、統計情報が古いか、データの偏りをうまく捉えられていない可能性があります。

手順 3 原因を特定する

実行計画で気になる点が見つかったら、どの型に当てはまるかを考えます。遅いクエリの原因は、ほとんどが次の型に収まります。

実行計画で見えること よくある原因 直し方の方向
大きなテーブルを全件読んでいる 条件の列にインデックスがない インデックスを追加する
インデックスはあるのに使われない 列に関数をかけている、型が合っていない、先頭の列が条件にない 条件の書き方を直す、インデックスの列順を見直す
インデックスを使うが、テーブル本体を何度も読みにいく 必要な列がインデックスに入っていない カバリングインデックスにする
読む行数が多く、転送量も大きい SELECT * で不要な列まで読んでいる 必要な列だけにする
サブクエリが行ごとに繰り返し動く 相関サブクエリ JOIN や EXISTS に書き換える
大量の行を並べ替えて、先頭だけ返す LIMIT がない、または OFFSET が大きい LIMIT とキーによる絞り込みを使う
結合の途中で行数が膨らむ 結合順がまずい、統計情報が古い 統計情報を更新する、先に絞り込む
同じ集計を何度も繰り返す 重い中間結果を毎回作っている 一時テーブルや CTE に切り出す

次の節から、型ごとに直し方を見ていきます。

手順 4 直す

インデックスを追加する

条件や結合に使う列にインデックスがなければ、まずはインデックスの追加です。

SELECT id, total FROM orders WHERE customer_id = 1234 AND status = 'paid';

customer_id にインデックスがなければ、orders を全件読むことになります。複合インデックスを作ると、条件に合う行だけを読めます。

-- MySQL
ALTER TABLE orders ADD INDEX idx_orders_customer_status (customer_id, status), ALGORITHM=INPLACE, LOCK=NONE;

-- PostgreSQL
CREATE INDEX CONCURRENTLY idx_orders_customer_status ON orders (customer_id, status);

どちらも書き込みを止めずにインデックスを作る書き方です。本番の大きなテーブルに足すときはこの指定が必須です。PostgreSQL の CONCURRENTLY はトランザクションブロックの中では使えません。

複合インデックスでは列の順番が重要です。

  • 等価条件 (=) の列を先に置き、範囲条件 (>、BETWEEN) の列を後ろに置く。
  • 範囲条件の列より後ろの列は、絞り込みには使いにくくなる。
  • 先頭の列が条件に入っていないクエリには、基本的に使えない。

インデックスは増やすほど良いわけではなく、書き込みのたびに更新が走り、ディスクも食います。使われていないインデックスは MySQL なら sys.schema_unused_indexes、PostgreSQL なら pg_stat_user_indexes の idx_scan が 0 のもので見つかります。

カバリングインデックスで本体を読まない

インデックスで行を絞っても、必要な列がインデックスに入っていなければテーブル本体を 1 行ずつ読みにいきます。必要な列をすべてインデックスに含めれば、本体を読まずに済みます。これがカバリングインデックスです。

-- MySQL: 列を末尾に足す
ALTER TABLE orders ADD INDEX idx_orders_cover (customer_id, status, total);

-- PostgreSQL 11 以降: INCLUDE で検索条件に使わない列を持たせる
CREATE INDEX CONCURRENTLY idx_orders_cover ON orders (customer_id, status) INCLUDE (total);

実行計画では、MySQL なら Extra に Using index、PostgreSQL なら Index Only Scan と出ます。

PostgreSQL の Index Only Scan は、可視性マップが更新されていないと本体を読みにいきます。更新の多いテーブルで VACUUM が追いついていないと起きがちです。実行計画の Heap Fetches が大きければ VACUUM の記事 も参考にしてみてください。

カバリングインデックスは列を足すぶんサイズが大きくなるので、よく動く重要なクエリに絞って使うのが現実的です。

不要な SELECT * をやめる

SELECT * は楽ですが、要らない列まで読んで転送します。orders.note のように長いテキスト列があると影響は大きくなります。

-- 直す前
SELECT * FROM orders WHERE customer_id = 1234;

-- 直した後
SELECT id, status, total, created_at FROM orders WHERE customer_id = 1234;

読んで運ぶデータ量が減るのに加え、カバリングインデックスが効くようになります。SELECT * のままではインデックスだけで答えることができません。

PostgreSQL では長い値が TOAST という別領域に保存されるため、SELECT * をやめるとこの領域の読み取りも省けることがあります。

サブクエリを書き換える

行ごとに繰り返し動く相関サブクエリは、データが増えると急に遅くなる典型です。

-- 直す前: 顧客ごとに注文の件数を数えている
SELECT c.id, c.name,
       (SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id AND o.status = 'paid') AS paid_count
FROM customers c
WHERE c.region = 'kanto';

オプティマイザが自動で書き換えてくれる場合もありますが、実行計画で確かめるまでは当てにしないほうが安全です。JOIN と GROUP BY に書き換えれば、注文を 1 回の走査でまとめて数えられます。

-- 直した後
SELECT c.id, c.name, COALESCE(p.paid_count, 0) AS paid_count
FROM customers c
LEFT JOIN (
  SELECT customer_id, COUNT(*) AS paid_count
  FROM orders
  WHERE status = 'paid'
  GROUP BY customer_id
) p ON p.customer_id = c.id
WHERE c.region = 'kanto';

存在の確認だけなら IN より EXISTS のほうが意図が明確です。NOT IN はサブクエリの結果に NULL が混ざると結果が空になるので、NOT EXISTS に書き換えると安全です。

書き換えたら結果が同じかどうかも必ず比べます。速くなっても結果が変わっては意味がありません。

LIMIT を活用する

画面に 20 件しか出さないのに、全件を取り出して並べ替えているクエリは意外に多いものです。

SELECT id, total FROM orders WHERE status = 'paid' ORDER BY created_at DESC LIMIT 20;

(status, created_at) にインデックスがあれば、並べ替えを省いて、先頭の 20 件を読んだところで止まれます。

ページ送りで OFFSET を大きくすると、読み飛ばす行も実際には読むため、後ろのページほど遅くなります。

-- 後ろのページほど遅くなる
SELECT id, total FROM orders WHERE status = 'paid' ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 100000;

-- 前のページの最後の値から続ける (キーセット方式)
SELECT id, total FROM orders
WHERE status = 'paid' AND (created_at, id) < ('2026-10-01 12:00:00', 987654)
ORDER BY created_at DESC, id DESC
LIMIT 20;

キーセット方式は、任意のページに飛べない代わりに、どのページでも一定の速さで動きます。「次へ」「前へ」だけの画面や、無限スクロールに向いています。

JOIN の順序と統計情報

JOIN の順序はオプティマイザが決めるので、人間が指定する必要はほとんどありません。それより次の 2 点を整えるほうが効きます。

  • 結合に使う列にインデックスがあること。結合の相手側 (読まれるほうのテーブル) の列に必要
  • 統計情報が新しいこと。統計が古いと、オプティマイザは行数を読み違えてまずい順序を選ぶ
-- MySQL
ANALYZE TABLE orders;

-- PostgreSQL
ANALYZE orders;

PostgreSQL は自動バキュームが ANALYZE も行いますが、大量のデータを入れた直後には間に合わないことがあります。

それでも順序が直らないときの最後の手段がヒントです。MySQL には STRAIGHT_JOIN や JOIN_ORDER などのオプティマイザヒントがあります。PostgreSQL の標準にはヒントがなく、join_collapse_limit を 1 にして FROM 句の順に結合させる方法があります。データが変わると逆効果になるので、強制した理由はコードに残しておきます。

結合の前に絞り込めるものは先に絞り込むのも有効です。サブクエリや CTE で先に行を減らしてから結合すれば、途中の行数が抑えられます。

一時テーブルや CTE に切り出す

重い集計を同じ処理の中で何度も使うなら、一度だけ計算して保存しておきます。

-- MySQL: 一時テーブル
CREATE TEMPORARY TABLE tmp_paid AS
SELECT customer_id, SUM(total) AS paid_total
FROM orders
WHERE status = 'paid' AND created_at >= '2026-10-01'
GROUP BY customer_id;

CREATE INDEX idx_tmp_paid ON tmp_paid (customer_id);

SELECT c.id, c.name, t.paid_total
FROM customers c
JOIN tmp_paid t ON t.customer_id = c.id;
-- PostgreSQL 12 以降: CTE を実体化する
WITH paid AS MATERIALIZED (
  SELECT customer_id, SUM(total) AS paid_total
  FROM orders
  WHERE status = 'paid' AND created_at >= '2026-10-01'
  GROUP BY customer_id
)
SELECT c.id, c.name, p.paid_total
FROM customers c
JOIN paid p ON p.customer_id = c.id;

PostgreSQL 12 以降は、CTE が 1 回しか参照されないとき、通常はクエリの中に展開されます。MATERIALIZED を付けると、必ず 1 回だけ計算して使い回します。逆に NOT MATERIALIZED を付けると、展開を強制できます。

MySQL 8.0 の CTE も複数回参照されると実体化されることがあります。MySQL の一時テーブルは接続ごとに作られて切断で消えるので、バッチ処理の途中でインデックスを付けたいときは一時テーブルのほうが扱いやすいです。

一時テーブルは書き込みを伴うので、軽いクエリには向きません。重い中間結果を何度も使う場合に限った手段です。

手順 5 効果を確かめる

直したら、直す前と同じ条件で比べます。

  • 実行計画が期待どおりに変わったか。インデックスが使われ、読む行数が減っているか
  • 実際の実行時間が縮んだか。1 回だけでなく数回測って、キャッシュの影響を除く
  • 結果が変わっていないか。件数と、できれば合計値を比べる
  • ほかのクエリに悪影響がないか。インデックスを足すと書き込みが少し遅くなる
  • 本番の統計でも改善しているか。手元の小さなデータでは実行計画が本番と違うことがある

PostgreSQL では pg_stat_statements の mean_exec_time と calls、MySQL では events_statements_summary_by_digest の AVG_TIMER_WAIT と COUNT_STAR を、直す前後で比べると全体への効果も見えます。

本番への反映はステージングなどで実行計画と所要時間を確かめてからが安全です。大きなテーブルへのインデックス追加は数十分かかることもあります。

チューニングのあとも続く監視

チューニングで速くしたクエリも、データ量の増加やスキーマの変更で再び遅くなることがあります。注文が 10 倍になれば実行計画が切り替わりますし、新しい機能で別のクエリが追加されれば、また上位に新顔が現れます。手作業のチューニングは 1 回で終わらず、定期的にスロークエリの上位を見直す作業になりがちです。

Scandock は、AI がデータベースを継続的に監視するサービスです。MySQL や PostgreSQL などの接続文字列を登録すると、AI がテーブルの構成と実データを読んで、監視する項目を決めます。注文数の急な減少やジョブの滞留のような、事業の数字の変化を、固定の閾値ではなく推移や過去の経緯を踏まえて判断し、異常があれば読み取り専用のクエリで原因を調べて、根拠と対処案をつけて通知します。チューニングの効果が時間とともに薄れていないかを、人が定期的に見に行かなくても気づける形にしたい方は、試してみてください。

参考