遅いクエリを見つけて、実行計画を読んで、インデックスや SQL の書き方で速くする。やることはシンプルですが、順番を飛ばすと遠回りになります。「見つける → 実行計画を読む → 原因を絞る → 直す → 確かめる」の流れで進めるのが近道です。
MySQL 8.0 / 8.4 と PostgreSQL 14 以降を前提に、考え方は共通で示し、コマンドは両方載せています。テーブルは説明用のデモです。
チューニングの全体の流れ
手順は 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 がテーブルの構成と実データを読んで、監視する項目を決めます。注文数の急な減少やジョブの滞留のような、事業の数字の変化を、固定の閾値ではなく推移や過去の経緯を踏まえて判断し、異常があれば読み取り専用のクエリで原因を調べて、根拠と対処案をつけて通知します。チューニングの効果が時間とともに薄れていないかを、人が定期的に見に行かなくても気づける形にしたい方は、試してみてください。
参考
- MySQL 8.4 Reference Manual: Optimization
- MySQL 8.4 Reference Manual: Optimization and Indexes
- MySQL 8.4 Reference Manual: Online DDL Operations
- PostgreSQL Documentation: Performance Tips
- PostgreSQL Documentation: Index-Only Scans and Covering Indexes
- PostgreSQL Documentation: WITH Queries (Common Table Expressions)
- PostgreSQL Documentation: pg_stat_statements