この記事は基本的にPostgreSQLでのSELECT文のパフォーマンスチューニングを前提としています。
実行計画ってなに?
SQLのパフォーマンスを改善するには、「実行計画(プラン)」を確認することが大切です。
と、いきなり言われてもよく分からないと思うので、まずは「実行計画とは何か?」の話をざっくりと。
SQLを実行する際、DBは大まかに下記のステップを踏みます。
- 構文解析 - SQLの文法が正しいかなどをチェックする
- 最適化 - どうやったら効率的に実行できるか考える
- 実行計画の作成 - 最適化した方法を具体的な手順に落とし込む
- 実行 - 実行計画に基づいて実際に実行
この3ステップ目で作成されるのが「実行計画」です。
実行計画を見ることで、実際にSQLがどのように実行されたのかを具体的かつ詳細に確認できます。
実行計画ってどうやったら見られるの?
EXPLAIN
対象のSQLにEXPLAIN
というコマンドを付けて実行すると、実行計画を取得できます。
ただしここで取得できる実行計画は「推測値」になります。
EXPLAIN
SELECT o.customer_name, o.order_date, o.total_amount, c.email
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
WHERE o.customer_id = 123
AND o.order_date >= '2024-01-01'
AND o.status = 'completed';
Hash Join (cost=15.00..89.45 rows=2 width=128)
Hash Cond: (o.customer_id = c.customer_id)
-> Seq Scan on orders o (cost=0.00..72.50 rows=5 width=96)
Filter: ((customer_id = 123) AND (order_date >= '2024-01-01'::date) AND (status = 'completed'::text))
-> Hash (cost=12.50..12.50 rows=1 width=64)
-> Seq Scan on customers c (cost=0.00..12.50 rows=1 width=64)
Filter: (customer_id = 123)
実行計画に出てくる代表的な項目について、少し解説します
cost
- 処理にどれくらい時間がかかりそうかの推定値。単位なし
- あくまで相対的な値だが、1つの目安として1,000を超えていたら要注意
-
cost=15.00..89.45
の場合、処理を始めるまでの推定コストが15.00、すべての行を処理し終わるまでの推定コストが89.45という意味 - 実行前の推測値なので、実際の時間とは必ずしも関係しない
rows
- その処理(検索条件など)で取得されるレコード数の推定値
- テーブル全体の件数ではなく、フィルター条件などで絞り込まれた後の件数
EXPLAIN ANALYZE
ANALYZE
オプションを追加すると、実際にSQLを実行した場合の実測値も取得できます。
EXPLAIN ANALYZE
SELECT o.customer_name, o.order_date, o.total_amount, c.email
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
WHERE o.customer_id = 123
AND o.order_date >= '2024-01-01'
AND o.status = 'completed';
Hash Join (cost=15.00..89.45 rows=2 width=128) (actual time=1.234..45.678 rows=3 loops=1)
Hash Cond: (o.customer_id = c.customer_id)
-> Seq Scan on orders o (cost=0.00..72.50 rows=5 width=96) (actual time=0.123..42.345 rows=8 loops=1)
Filter: ((customer_id = 123) AND (order_date >= '2024-01-01'::date) AND (status = 'completed'::text))
-> Hash (cost=12.50..12.50 rows=1 width=64) (actual time=0.045..0.045 rows=1 loops=1)
-> Seq Scan on customers c (cost=0.00..12.50 rows=1 width=64) (actual time=0.023..0.044 rows=1 loops=1)
Filter: (customer_id = 123)
Planning Time: 0.123 ms
Execution Time: 45.789 ms
ANALYZE
オプションを付与する前と比べてactual time
などが増えています。
また、Execution Time
(実行時間)なども表示されます。
増えた項目について、簡単に解説します。
actual time
- 実際の処理にかかった時間。単位はミリ秒
-
actual time=1.234..45.678
の場合、最初のレコードを取得するまでに1.234ms、すべてのレコードを取得し終えるまでに45.678msかかったという意味
rows
- その処理(検索条件など)で取得されたレコード件数
- テーブル全体の件数ではなく、フィルター条件などで絞り込まれた件数の実測値
- 推測値と実測値が乖離している場合は要注意。詳しくは「推測値と実測値は乖離していないか?」にて説明
loops
- その処理が何回実行されたかを示す値
- 基本的には
loops=1
だが、JOIN処理などで同じ処理が複数回実行される場合は2以上になる -
loops
が大きい場合、actual time
やrows
は1回あたりの平均値として表示される
参考資料
より詳しい内容は下記のPostgreSQL文書で確認してください。
PostgreSQL 15.4文書 SQLコマンド EXPLAIN
PostgreSQL 15.4文書 性能に関するヒント EXPLAINの利用
どっちを使う?
基本的には実測値を取得できるEXPLAIN ANALYZE
が推奨です。
ただし「実際にSQLを実行する」ため、下記の点には注意しましょう。特に本番環境のパフォーマンス計測を行っている際にはユーザーに影響が出かねないので、慎重に実行する必要があります。
- 処理に時間がかかるSQLを実行してしまうと、同じDBを使用する他ユーザーのパフォーマンスにも悪影響を与えてしまう場合がある
-
INSERT
/UPDATE
/DELETE
で使う場合、レコードが変更される
実行計画ってどう読むの?どうやってパフォーマンスを改善するの?
ここからがこの記事のメインですね。
実行計画の取得はできたとして、最初は何を見たらいいのかサッパリだと思います。
この記事では基礎的なポイントを3つ紹介します。
1. インデックススキャンが使われているか?
スキャン方式
テーブルのスキャン(検索)方法として、主に下記の2つがあります。
- インデックススキャン(
Index Scan
)- 特定カラムの検索にインデックスを使って高速化
- 特に下記のケースで力を発揮する
- 対象となるレコード数が多い
- データの種類が多い(日時情報、名前など)
- シーケンシャルスキャン(
Seq Scan
)- データを1行ずつ読み込む方式
- 最も基本的な検索方法であり、データ数が少なければ速いが、大量になると重くなる
スキャン方式には向き不向きがあるので、最適化の際にそれぞれの状況に応じた適切なスキャン方式が選ばれます。
ただ、SQLのパフォーマンスが悪い場合、データ量が多いなどシーケンシャルスキャンが苦手な状況なのにシーケンシャルスキャンが使われている、ということがあります。
シーケンシャルスキャンは何が苦手なのか
「シーケンシャルスキャンには不向き」な状況の典型例は、実行計画で言うと「cost
が大きい」ものです。実測値として「actual time
も長い」のであれば、インデックススキャンに切り替えることでパフォーマンスを改善できる見込みは大きいです。
Seq Scan on orders (cost=0.00..4425.00 rows=1 width=64) (actual time=0.156..89.234 rows=5 loops=1)
Filter: (customer_id = 123)
この例ではcost
が4425.00と大きく、すべての行を処理し終わるまでのコストが大きい(= 対象のレコード数が多い)ことが分かります。actual time
も89.234msと長いです。
シーケンシャルスキャンでは、条件に合うレコードを見つけるためにテーブルの先頭から順番にすべてのレコードを読み込みます。今回の場合、大量のデータをスキャンしても条件に合致するレコードが5件とごく少数しかないため、読み込み処理の大部分が無駄になっています。この読み込み効率の悪さが、高いcost
と長い実行時間の原因です。
改善策: インデックスを作成しよう
シーケンシャルスキャンでパフォーマンスが悪い場合、実行計画のFilterに記載されている条件のカラムに対してインデックスを作成することで改善できる場合があります。
先述の例ではFilter: (customer_id = 123)
となっているため、customer_id
カラムにインデックスを作成します。
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
Filter
に複数の条件がある場合は、複数カラムの複合インデックスを作成します。
インデックスを作成した後、改めて実行計画を取得し、インデックススキャンが選択されてパフォーマンスが改善されたことを確認してください。
2. 早い段階でレコード数を絞り込めているか?
絞り込みの順番
例えば100万件のレコードがあるテーブルを検索する場合、「100件まで絞り込める(1万分の一にできる)条件」と「50万件まで絞り込める(半分に絞り込める)条件」があるなら、前者で先に絞り込んだ方が効率的なのは明白です。
話を具体的にするため、下記のシンプルな例で考えてみます。
- 毎日、数十件から数百件のレコードが登録される「注文(orders)」テーブル
- 10年以上のデータが蓄積されており、総レコード数は100万件を超える
- 「金額」「商品id」「ステータス」などの情報が含まれる
- 商品の種類はおよそ30程度
- ステータスは「受注」「正常終了」「キャンセル」「エラー」の4種類
このテーブルに対して「特定の日の、特定の商品の、正常終了している注文一覧を取得したい」場合、下記の順番で絞っていくのが効率的なはずです。
- まず日付で絞る。数十件から数百件まで絞れる
- 次に商品で絞る。商品の種類が30なので、単純計算だと30分の1に絞れる
- 最後にステータスで絞る。種類が4つしかなく、おそらく正常終了が最も多いのであまり絞れない
このように、レコードを絞っていく際には効率的な順番があります。実際のSQL実行でも、最適化の際に、より早く数を絞れるように実行計画が作成されます。
しかしパフォーマンスの悪いSQLの場合、非効率な順番で絞り込みを行っている場合があります。
絞り込みの順番を実行計画でどう確認するのか?
実行計画は入れ子構造になっており、内側(インデントが深い部分)から外側に向かって実行されます。各段階でのレコード数(rows
)を確認し、早い段階で大幅に絞り込めているかをチェックしましょう。
Hash Join (cost=25.00..12440.00 rows=3 width=96) (actual time=450.234..450.789 rows=3 loops=1)
Hash Cond: (o.order_date = d.date_value)
-> Hash Join (cost=15.00..12400.00 rows=50 width=80) (actual time=380.012..449.234 rows=50 loops=1)
Hash Cond: (o.product_id = p.product_id)
-> Seq Scan on orders o (cost=0.00..12000.00 rows=800000 width=64) (actual time=0.012..320.234 rows=800000 loops=1)
Filter: (status = 'completed'::text)
Rows Removed by Filter: 200000
-> Hash (cost=10.00..10.00 rows=1 width=32) (actual time=0.045..0.045 rows=1 loops=1)
-> Seq Scan on products p (cost=0.00..10.00 rows=1 width=32) (actual time=0.023..0.044 rows=1 loops=1)
Filter: (product_id = 5)
-> Hash (cost=5.00..5.00 rows=1 width=16) (actual time=0.025..0.025 rows=1 loops=1)
-> Seq Scan on date_dim d (cost=0.00..5.00 rows=1 width=16) (actual time=0.012..0.024 rows=1 loops=1)
Filter: (date_value = '2024-01-01'::date)
ネストの深いところ(5~6行目)から見ていくと、最初のFilter: (status = 'completed'::text)
で80万件にしか絞れておらず(rows=800000
)、その大量の件数を次の処理(3~4行目)に渡してしまっています。
もっと効率的な日付の条件(Filter: (date_value = '2024-01-01'::date)
)を使って早い段階で件数を減らした方が効率的なのですが、そうなっていません。
改善策: SQLの構造を見直そう
早い段階で件数を減らせるように、SQLの構造を見直しましょう。
WHERE句内の順番を入れ替える
単純に、より多くのレコードを絞り込める条件を先に書くだけで、絞り込みの順序が改善されることもあります。
SELECT * FROM orders o
JOIN products p ON o.product_id = p.product_id
WHERE o.status = 'completed' -- 全体の80%
AND p.product_id = 5 -- 30分の1
AND o.order_date = '2024-01-01'; -- 数百分の1
SELECT * FROM orders o
JOIN products p ON o.product_id = p.product_id
WHERE o.order_date = '2024-01-01' -- 数百分の1
AND p.product_id = 5 -- 30分の1
AND o.status = 'completed'; -- 全体の80%
本来は最適化のタイミングで、WHERE句内の順番に影響されず、後述する統計情報をもとに最も効率的な順序が選択されるはずです。
しかし、特に複雑なクエリでは最適な順序が見つけられない場合もあります。そのためSQLの作成者が絞り込みのヒントを与える(早い段階で実行してほしい条件を先に書く)ことで意図が伝わり、最適解の発見に繋がりやすくなる場合があります。
CTEを使う
WHERE句内の順番入れ替えでは効果がない場合や、クエリが複雑に入り組んでいる場合、CTE(Common Table Expression)を使って段階的に絞り込むことで、効率的な実行計画を作りやすくなります。
WITH daily_orders AS (
SELECT * FROM orders
WHERE order_date = '2024-01-01' -- 最も効果的な条件で先に絞り込み
),
target_product_orders AS (
SELECT * FROM daily_orders
WHERE product_id = 5 -- 次に効果的な条件で絞り込み
)
SELECT o.*, p.product_name
FROM target_product_orders o
JOIN products p ON o.product_id = p.product_id
WHERE o.status = 'completed'; -- 最後に残りの条件を適用
あまりにCTEを多用すると、SQLの可読性が悪くなったり最適化が効きづらくなったりする1デメリットもあるので、何でもかんでもCTEを使えば良いわけではありません。
ただ、どうしても非効率な検索が行われてしまう場合の改善策としては有効な手段の1つです。
見直した後のSQLでパフォーマンスが改善しているかについては、改めて実行計画を取得して確認してください。
3. 推測値と実測値は乖離していないか?
rows
を見比べる
推測値と実測値のrows
を見比べて、大きな乖離がないか確認してみましょう。
たとえば推測値のrows
は数千件程度なのに、実測値のrows
を見ると数百万件以上ある、ということはないか?という観点です。逆に実測値が少なすぎる場合もあります。
Seq Scan on orders (cost=0.00..4425.00 rows=1000 width=64) (actual time=0.156..890.234 rows=2000000 loops=1)
Filter: (status = 'complete')
統計情報
推測値は、DBの持つ統計情報から算出されます。
統計情報とは、各テーブルやカラムのデータ分布、レコード数、値の出現頻度などの情報で構成されるもので、クエリの最適化を行う際の重要な判断材料となります。
基本的には、統計情報は自動で定期的に更新されているはずですが、短時間で大量のデータが追加・更新された場合などに更新が追いつかず、古いままになってしまうことがあります。
推測値と実測値が乖離している場合、古い統計情報をもとに判断されている恐れがあります。
カーナビで例えるなら、地図データが古いから最適なルート案内ができない、みたいな感じです。
統計情報の話は、ここまでに話してきた「インデックススキャン」や「データの絞り込み順序」の話とも関連しています。
「データ件数が多くインデックスも作成されているのに、シーケンシャルスキャンが選択される」「いくらSQLを変更しても、データの絞り込み順序が非効率なまま変わらない」ような場合、根本的な原因は統計情報の古さにある可能性があります。
個人的な経験でいうと、統計情報が古いままになってしまう問題はそれほど頻繁には発生しません。
ですが、もし起こってしまうと他のいかなる対応も効果が出ないような悪影響を及ぼしかねないので、パフォーマンス改善の際は念頭に置くべき観点の1つだと思います。
改善策: 統計情報を更新しよう
統計情報が古いのであれば、手動で更新しましょう。
ANALYZE
コマンドを使うと統計情報の更新が行えます。
-- 特定テーブルの統計情報を更新
ANALYZE orders;
-- 特定のカラムのみ更新
ANALYZE orders (order_date, product_id);
ただし、統計情報の更新を行うと(レコード数にもよりますが、)DBにある程度の負荷がかかります。
一時的にDBのパフォーマンスを悪化させる可能性もあるので、他の人も利用するDBで行う際には注意が必要です。
ちなみにテーブル名を指定しないと全テーブルの統計情報が更新されますが、かなり大きな負荷がかかることになるので、自分しか使っていないローカルDBなどでない限り、基本的にやらない方がいいです。
ANALYZE;
参考資料
ANALYZE
コマンドの詳細が確認したい場合、以下のPostgreSQL文書をご確認ください。
PostgreSQL 15.4文書 SQLコマンド ANALYZE
プラスα AIに聞いてみよう
最後にぶん投げた感じはありますが、少なくともこの記事を書いている2025年7月現在において、生成AIは非常に強い味方です。特にSQLは昔からあまり構文が変化していないこともあり、比較的ハルシネーションが起こりにくい、生成AIの得意分野であると感じます。
そのため、下記の情報を渡してパフォーマンス改善を行いたい旨を伝えれば、有効なアドバイスをもらえることは多いはずです。
【必須】
- 今実行しているSQL
- その実行計画
- データベースの種類
- できればバージョンも
【あるとより良い】
- 対象テーブルの構造やレコード数
- 構造はCREATE文などで示す
- 対象テーブルの既存インデックス
- 現在の実行時間と目標時間
- 「現在の実行時間」は実行計画に記載されているので無くてもいいですが、実行ごとにバラつきがある場合(例えば1回目は600ms、2回目は200ms、3回目は420ms)などは、その旨を明記した方が良いです
- クエリやテーブルの特性
- どれくらいの頻度で実行されるクエリか
- どれくらいの頻度で更新されるテーブルか
- テーブルが持つデータなどの特性
- 大部分のレコードが特定の値に集中している
- イミュータブルなデータである
- など
※ 生成AIのアドバイスは鵜呑みにせず、必ず正当性を自分で確認する必要があることは、老婆心で申し添えておきます。その正当性を判断する際の基準としても、本記事で説明してきたことを参考にしてもらえると幸いです。
おわりに
実行計画はSQLのパフォーマンスチューニングを行う上で非常に重要な情報ですが、最初はなかなか取っ付きにくいと思います。
この記事で解説してきたことが、その取っつきにくい実行計画を見始める際の、なにかのお手伝いになっていれば嬉しく思います。
-
PostgreSQL 12以降でCTEの最適化は大幅に改善されたようですが、複雑なCTEの組み合わせでは、依然として最適化が効きづらい場合があります ↩