クエリ実行計画入門 - EXPLAINの読み方とプランナがJOINアルゴリズムを選ぶ理由

クエリ実行計画入門 - EXPLAINの読み方とプランナがJOINアルゴリズムを選ぶ理由

作成日:
読了:35
更新日:
この記事を読む人におすすめPR / Amazonアソシエイト

当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。

「インデックスを張ったのに速くならない」「ステージングでは一瞬なのに本番だけ遅い」。この2つは、おそらく現場で最も繰り返されるパフォーマンス相談です。そして原因の大半は、SQLの書き方でもインデックスの有無でもなく、プランナ(オプティマイザ)が見積もりを外していることにあります。

データベースは、書かれたSQLをそのままの順序で実行しているわけではありません。「どのテーブルから読むか」「インデックスを使うか全件読むか」「JOINをどのアルゴリズムで処理するか」を、統計情報にもとづくコスト計算で毎回決めています。この決定を可視化するのが EXPLAIN です。

この記事では、EXPLAIN の出力に並ぶ数字が何を意味するのか、スキャンとJOINの方式がどういう理屈で選ばれるのか、そして見積もりが外れる典型パターンをどう見抜くかを追いかけます。

NOTE

記事中の実行計画は、すべて PostgreSQL 18.6(Homebrew、macOS arm64)で実際に実行した出力です。執筆時点の最新安定版は 18.6(2026年8月13日リリース)で、19系は Beta 3 の段階です。データは users 20万行・orders 100万行の検証用テーブルです。MySQLとの違いは後半でまとめます。

クエリが実行されるまでの4つの段階

まず全体像です。PostgreSQLはSQL文字列を受け取ってから、おおよそ次の4段階を通ります。

  1. パース - 構文解析して構文木にする
  2. 書き換え(rewrite) - ビューの展開やルールの適用
  3. プランニング - 実行方法の候補を列挙し、コストが最小のものを選ぶ
  4. 実行 - 選ばれたプランをノード単位で動かす

EXPLAIN は3を実行して結果を表示するだけのコマンドです。EXPLAIN ANALYZE は3に加えて4も実際に走らせ、推定と実測を並べて見せてくれます。

ここで重要なのは、3のプランニングは統計情報という「テーブルの要約」だけを見て行われるという点です。プランナは実データを覗きにいきません。したがって統計情報が実態とずれていれば、どれだけ賢いプランナでも間違った選択をします。「本番だけ遅い」の正体はたいていここにあります。

EXPLAINの4つの数字

もっとも単純な例から始めます。

EXPLAIN SELECT * FROM orders;
                           QUERY PLAN
-----------------------------------------------------------------
 Seq Scan on orders  (cost=0.00..20594.00 rows=1000000 width=34)

括弧の中に4つの情報が入っています。PostgreSQL公式ドキュメントの定義に沿って並べると次のとおりです。

表示意味
cost=0.00起動コスト。最初の1行を返せるようになるまでの推定コスト
..20594.00総コスト。全行を取り切るまでの推定コスト
rows=1000000このノードが出力すると推定される行数
width=34出力1行あたりの推定バイト数

よくある誤解を2つ潰しておきます。

コストの単位はミリ秒ではありません。公式ドキュメントは "The costs are measured in arbitrary units determined by the planner's cost parameters"(コストはプランナのコストパラメータで決まる任意単位で測られる)と明記しています。慣習として「シーケンシャルにディスクページを1つ読むコスト」を 1.0 とした相対値です。

rows はスキャンした行数ではなく、出力した行数です。ドキュメントも "it is not the number of rows processed or scanned by the plan node, but rather the number emitted by the node" と念を押しています。WHERE句で弾かれた分は含まれません。

コストは実際に手で計算できる

この 20594.00 という値は魔法の数字ではありません。コスト定数を確認してみます。

SELECT name, setting FROM pg_settings
WHERE name IN ('seq_page_cost','random_page_cost','cpu_tuple_cost','cpu_operator_cost');
         name         | setting
----------------------+---------
 cpu_operator_cost    | 0.0025
 cpu_tuple_cost       | 0.01
 random_page_cost     | 4
 seq_page_cost        | 1

シーケンシャルスキャンのコストは「読むページ数 × seq_page_cost + 処理する行数 × cpu_tuple_cost」です。テーブルの物理サイズを見ます。

SELECT relpages, reltuples FROM pg_class WHERE relname='orders';
 relpages | reltuples
----------+-----------
    10594 |     1e+06

計算すると 10594 × 1.0 + 1000000 × 0.01 = 10594 + 10000 = 20594 となり、表示と完全に一致します。

WHERE句を足すと、条件式の評価コストが上乗せされます。

EXPLAIN SELECT * FROM orders WHERE status = 'refunded';
                          QUERY PLAN
---------------------------------------------------------------
 Seq Scan on orders  (cost=0.00..23094.00 rows=99200 width=34)
   Filter: (status = 'refunded'::text)

10594 + 1000000 × 0.01 + 1000000 × 0.0025 = 23094。演算子1つあたり cpu_operator_cost が100万行分乗っているのが分かります。rows が 99200 に減っているのは、統計情報から「statusrefunded の行は約9.9%」と見積もったからです。

ここまで掴めば、コストは「よく分からない数字」ではなく、ページ数と行数と定数の掛け算だと分かります。そして掛け算である以上、行数の見積もりが外れれば結果は丸ごと外れます

EXPLAIN ANALYZE - 推定と実測を並べる

ANALYZE オプションを付けると、実際にクエリが実行され、推定の隣に実測値が並びます。

EXPLAIN (ANALYZE) SELECT count(*) FROM orders WHERE user_id BETWEEN 100 AND 200;
 Aggregate  (cost=20.51..20.52 rows=1 width=8) (actual time=0.051..0.051 rows=1.00 loops=1)
   Buffers: shared hit=8
   ->  Index Only Scan using idx_orders_user_id on orders  (cost=0.42..19.17 rows=537 width=0) (actual time=0.014..0.033 rows=505.00 loops=1)
         Index Cond: ((user_id >= 100) AND (user_id <= 200))
         Heap Fetches: 0
         Index Searches: 1
         Buffers: shared hit=8
 Planning Time: 0.304 ms
 Execution Time: 0.078 ms

actual time=0.014..0.033 は「最初の行が出るまで0.014ms、全行出し切って0.033ms」という実測値で、こちらはミリ秒です。推定 537行に対して実測 505行なので、この見積もりは良好です。

NOTE

PostgreSQL 18 で EXPLAIN ANALYZE の出力が変わっています。リリースノートによれば、(1) BUFFERS が自動で含まれるようになった("Automatically include BUFFERS output in EXPLAIN ANALYZE")、(2) 行数が小数で表示されるようになった("Modify EXPLAIN to output fractional row counts"、上の rows=505.00)、(3) インデックス検索回数が出るようになった("report the number of index lookups used per index scan node"、上の Index Searches: 1)の3点です。17系までの出力を前提にした解説記事と見た目が違うのはこのためです。

Buffers は読んだバッファ数で、hit が共有バッファから読めた数、read がその外から読んだ数です。実行時間はマシンの負荷で揺れますが、バッファ数は揺れません。改善の前後比較には実行時間よりバッファ数のほうが信頼できる指標になります。

Planning TimeExecution Time が分かれている点も見どころです。単純なクエリでプランニングのほうが長いことは珍しくなく、上の例では 0.304ms 対 0.078ms でプランニングのほうが4倍長くかかっています。

スキャンノードは選択率で切り替わる

同じ列・同じインデックスでも、何行取り出すかによって読み方が変わります。orders.created_at にインデックスがある状態で、範囲を広げていきます。

まず1日分(実測1667行、全体の0.17%)です。

 Bitmap Heap Scan on orders  (cost=25.30..4446.89 rows=1661 width=34) (actual time=0.214..4.808 rows=1667.00 loops=1)
   Recheck Cond: (created_at = '2025-03-01 00:00:00+09'::timestamptz)
   Heap Blocks: exact=1667
   ->  Bitmap Index Scan on idx_orders_created_at  (cost=0.00..24.88 rows=1661 width=0) (actual time=0.113..0.113 rows=1667.00 loops=1)

次に約20日分(実測33340行、3.3%)。まだ Bitmap Heap Scan です。

 Bitmap Heap Scan on orders  (cost=497.66..11585.18 rows=32901 width=34) (actual time=0.748..4.265 rows=33340.00 loops=1)

最後に約10か月分(実測511769行、51%)。

 Seq Scan on orders  (cost=0.00..25594.00 rows=512115 width=34) (actual time=4.634..57.201 rows=511769.00 loops=1)
   Filter: ((created_at >= '2025-03-01 00:00:00+09'::timestamptz) AND (created_at <= '2026-01-01 00:00:00+09'::timestamptz))
   Rows Removed by Filter: 488231

インデックスがあるのに全件走査に切り替わりました。これはプランナの誤りではなく正しい判断です。全体の半分を取るなら、インデックスを引いてからテーブルの飛び飛びの位置を読みにいく(ランダムアクセス、random_page_cost は 4.0)より、先頭から順に読み切ってしまう(seq_page_cost は 1.0)ほうが安いからです。

主なスキャンノードを整理します。

ノード動き向いている場面
Seq Scan先頭から全ページ読む取得割合が大きい、小さいテーブル
Index Scanインデックスを引いて都度テーブルを読む取得行数がごく少ない、ソート順が欲しい
Index Only Scan必要な列がインデックスに揃っておりテーブルを読まない対象列だけで完結する集計・存在確認
Bitmap Heap Scan該当位置をビットマップに溜めてからページ順にまとめ読み中間の選択率。ランダムアクセスを減らせる

Index Only ScanHeap Fetches: 0 は「テーブル本体を一度も読まなかった」という意味で、これが0でない場合は可視性マップが古い(VACUUM が追いついていない)サインです。

Index Cond と Filter の決定的な違い

実行計画を読むうえで、もっとも実務に効く区別がこれです。

  • Index Cond - インデックスの探索そのものに使われた条件。読む範囲を絞り込む
  • Filter - 読んだ後で捨てるための条件。読む量は減らない

「インデックスを張ったのに速くならない」の多くは、条件が Index Cond ではなく Filter に落ちています。典型例が、インデックス列に関数やキャストを掛けるケースです。

EXPLAIN (ANALYZE) SELECT count(*) FROM orders WHERE created_at::date = date '2025-03-01';
 ->  Parallel Index Only Scan using idx_orders_created_at on orders (actual time=8.821..21.817 rows=555.67 loops=3)
       Filter: ((created_at)::date = '2025-03-01'::date)

idx_orders_created_at という名前が出ているので一見インデックスが効いていますが、条件は Filter です。created_at::date という加工後の値はインデックスに入っていないため、範囲を絞れず全体をなめてから捨てています。実行時間は約25msでした。

同じ意味の条件を、列を加工しない範囲条件に書き換えます。

EXPLAIN (ANALYZE) SELECT count(*) FROM orders
WHERE created_at >= timestamptz '2025-03-01' AND created_at < timestamptz '2025-03-02';
 Aggregate  (cost=45.41..45.42 rows=1 width=8) (actual time=0.141..0.141 rows=1.00 loops=1)
   ->  Index Only Scan using idx_orders_created_at on orders  (cost=0.42..41.30 rows=1644 width=0) (actual time=0.038..0.093 rows=1667.00 loops=1)
         Index Cond: ((created_at >= '2025-03-01 00:00:00+09'::timestamptz) AND (created_at < '2025-03-02 00:00:00+09'::timestamptz))
         Heap Fetches: 0

今度は Index Cond に入り、約25ms から 0.141ms になりました。同じ結果を返す2つのSQLで、読む量が変わったということです。

同じ理屈で、WHERE lower(email) = ...WHERE email LIKE '%example.com'(前方が不定)、暗黙の型変換が起きる比較なども Filter に落ちます。列を裸で左辺に置く、というのが原則です。どうしても加工したい場合は式インデックス(CREATE INDEX ON orders ((created_at::date)))という手もあります。

インデックスそのものの構造についてはデータベースインデックス入門で扱っています。B-treeがなぜ範囲検索に強く、加工後の値では引けないのかはそちらが詳しいです。

3つのJOINアルゴリズム

JOINの書き方は1つでも、実行方式は3つあります。プランナはこの3つを比較して安いものを選びます。

Nested Loop - 外側の各行について内側を引く

外側のテーブルを1行読むごとに、内側を検索します。

EXPLAIN (ANALYZE) SELECT u.email, o.amount FROM users u
JOIN orders o ON o.user_id = u.id WHERE u.email = 'user1234@example.com';
 Nested Loop  (cost=4.88..32.69 rows=5 width=26) (actual time=0.594..0.606 rows=5.00 loops=1)
   ->  Index Scan using idx_users_email on users u  (cost=0.42..8.44 rows=1 width=30) (actual time=0.575..0.575 rows=1.00 loops=1)
         Index Cond: (email = 'user1234@example.com'::text)
   ->  Bitmap Heap Scan on orders o  (cost=4.46..24.20 rows=5 width=12) (actual time=0.018..0.029 rows=5.00 loops=1)
         Recheck Cond: (u.id = user_id)

外側が1行なので、内側を1回引くだけで終わります。外側が十分小さく、内側にインデックスがあるときに最速です。逆に外側が大きいと、内側の検索が外側の行数だけ繰り返され、破滅的に遅くなります。後述する事故のほとんどはこの形です。

Hash Join - 片方をハッシュ表にしてから照合

小さいほうを読み切ってメモリ上にハッシュ表を作り(build相)、大きいほうを流しながら照合します(probe相)。

 Hash Join  (cost=23597.99..29717.18 rows=40319 width=0) (actual time=36.520..57.694 rows=39800.00 loops=1)
   Hash Cond: (u.id = o.user_id)
   ->  Seq Scan on users u  (cost=0.00..3966.00 rows=200000 width=8) (actual time=0.010..9.726 rows=200000.00 loops=1)
   ->  Hash  (cost=23094.00..23094.00 rows=40319 width=8) (actual time=36.458..36.459 rows=39800.00 loops=1)
         Buckets: 65536  Batches: 1  Memory Usage: 2067kB
         ->  Seq Scan on orders o  (cost=0.00..23094.00 rows=40319 width=8) (actual time=4.472..34.127 rows=39800.00 loops=1)
               Filter: (amount > 4900)
               Rows Removed by Filter: 960200

Batches: 1 は、ハッシュ表がメモリに収まって1回で処理できたという意味です。等価条件(=)専用で、大量の行どうしを結合するときの標準手段になります。ハッシュ表の仕組みはハッシュテーブル入門を参照してください。

Merge Join - 両方をソートして突き合わせ

両側をJOINキー順に並べ、先頭から並走させて突き合わせます。

 Merge Join  (cost=26178.77..32487.44 rows=40319 width=0) (actual time=36.689..52.829 rows=39800.00 loops=1)
   Merge Cond: (u.id = o.user_id)
   ->  Index Only Scan using users_pkey on users u  (actual time=0.004..7.945 rows=199987.00 loops=1)
   ->  Sort  (cost=26178.24..26279.03 rows=40319 width=8) (actual time=36.677..37.553 rows=39800.00 loops=1)
         Sort Key: o.user_id
         Sort Method: quicksort  Memory: 1537kB

片側がインデックスで既に整列済みなら、ソートを省けます(上の users 側)。不等号を含む結合条件も扱えます。

まとめると次のようになります。

アルゴリズム計算量の目安前提得意苦手
Nested Loop外側 × 内側1回の検索内側に索引があると強い外側が小さい外側が大きい
Hash Join両側を1回ずつ読む等価条件のみ、メモリが要る大量どうしの等価結合メモリ不足時の分割
Merge Joinソート済みなら両側1回ずつ整列が必要既に整列済み、不等号も可ソートコストが乗る

ソートアルゴリズムそのものはソートアルゴリズム入門、計算量の見方は計算量とオーダー記法入門で扱っています。

loops の罠 - 表示は1回あたりの平均

EXPLAIN ANALYZE を読むときに最も誤読されやすいのがここです。

 ->  Nested Loop (actual time=0.506..179.434 rows=100000.00 loops=1)
       ->  Bitmap Heap Scan on users u (actual time=0.502..9.814 rows=20000.00 loops=1)
       ->  Index Only Scan using idx_orders_user_id on orders o (actual time=0.008..0.008 rows=5.00 loops=20000)

内側の Index Only Scanactual time=0.008 と表示されており、一見すると一瞬で終わっています。しかし loops=20000 です。公式ドキュメントが説明するとおり、表示されている時間と行数は1回の実行あたりの平均値なので、実際にかかった合計は 0.008 × 20000 = 160ms 程度になります。親の Nested Loop が 179ms かかっている理由がこれで説明できます。

rows=5.00 も同様で、合計は 5 × 20000 = 100000 行。親ノードの rows=100000.00 と一致します。loops が大きいノードを見つけたら、表示値に掛け算してから評価するのが鉄則です。

推定が外れる2大パターン

ここからが本題です。プランナが間違うのは、ほぼこの2つに集約されます。

パターン1 - 統計情報が古い

もっとも多く、もっとも被害が大きいパターンです。再現してみます。events テーブルを作り、最初は kindclick の行だけ50万件入れて ANALYZE します。

SELECT most_common_vals, most_common_freqs FROM pg_stats
WHERE tablename='events' AND attname='kind';
 most_common_vals | most_common_freqs
------------------+-------------------
 {click}          | {1}

統計上、kind は100%が click です。ここに新しい種別 purchase を30万件追加します。機能リリースで新しいステータス値が流れ始めた、という現実によくある状況です。ANALYZE はまだ実行しません。

EXPLAIN (ANALYZE) SELECT count(*) FROM events e
JOIN users u ON u.id = e.user_id WHERE e.kind = 'purchase';
 Aggregate  (cost=8.88..8.89 rows=1 width=8) (actual time=238.555..238.556 rows=1.00 loops=1)
   Buffers: shared hit=901902 read=545
   ->  Nested Loop  (cost=0.84..8.88 rows=1 width=0) (actual time=0.021..232.092 rows=300000.00 loops=1)
         ->  Index Scan using idx_events_kind on events e  (cost=0.42..4.44 rows=1 width=8) (actual time=0.014..19.066 rows=300000.00 loops=1)
               Index Cond: (kind = 'purchase'::text)
         ->  Index Only Scan using users_pkey on users u  (cost=0.42..4.44 rows=1 width=8) (actual time=0.001..0.001 rows=1.00 loops=300000)
               Index Searches: 300000

推定1行に対して実測30万行。30万倍の外しです。プランナは「1行しか出ないなら Nested Loop が最安」と判断しましたが、実際には内側を30万回引くことになり、バッファアクセスは901,902回に膨らみました。

ANALYZE を実行して統計を最新化します。

ANALYZE events;
 most_common_vals |    most_common_freqs
------------------+-------------------------
 {click,purchase} | {0.62326664,0.37673333}

同じクエリを再実行すると、プランが入れ替わります。

 Aggregate  (cost=20243.33..20243.34 rows=1 width=8) (actual time=96.678..96.680 rows=1.00 loops=1)
   Buffers: shared hit=4412, temp read=852 written=852
   ->  Hash Join  (cost=7248.43..19489.86 rows=301387 width=0) (actual time=22.527..90.190 rows=300000.00 loops=1)
         Hash Cond: (e.user_id = u.id)
         ->  Index Scan using idx_events_kind on events e  (cost=0.42..8312.70 rows=301387 width=8) (actual time=0.014..18.759 rows=300000.00 loops=1)
         ->  Hash  (cost=3966.00..3966.00 rows=200000 width=8) (actual time=22.243..22.243 rows=200000.00 loops=1)
               Buckets: 262144  Batches: 2  Memory Usage: 5966kB

推定301,387に対して実測300,000。誤差0.5%です。プランは Nested Loop から Hash Join に切り替わり、実行時間は 238ms から 96ms、バッファアクセスは901,902回から4,412回へ約204分の1になりました。SQLは1文字も変えていません。

この事故が起きやすいのは、大量の投入・削除の直後、新しい値が流れ始めた直後、そしてパーティション追加直後です。バルクロードのあとに ANALYZE を明示的に打つ、というのが定石になります。

なお、統計が完全に無い場合でもプランナは無防備ではありません。reltuples が0でも、テーブルの実ファイルのページ数から行数を推定し直す仕組みがあるため、「空だと思い込んで暴走する」ことは通常起きません。上の例が刺さったのは、行数ではなく値の分布kind='purchase' の頻度)が古かったからです。

パターン2 - 列どうしに相関がある

プランナは既定で、複数の条件は互いに独立と仮定して選択率を掛け算します。列に相関があるとこれが崩れます。

citycountry を持つテーブルで、paris は必ず FR という関係(関数従属)を作ります。それぞれ10万行ずつ、5都市で50万行です。

単独条件はどちらも正確です。

 Seq Scan on shipments  (cost=0.00..9450.00 rows=97667 width=17) (actual rows=100000.00 loops=1)   -- city='paris'
 Seq Scan on shipments  (cost=0.00..9450.00 rows=97667 width=17) (actual rows=100000.00 loops=1)   -- country='FR'

ところが両方をANDで繋ぐと崩れます。

 Gather  (cost=1000.00..9232.80 rows=19078 width=17) (actual rows=100000.00 loops=1)
   ->  Parallel Seq Scan on shipments  (cost=0.00..6325.00 rows=7949 width=17) (actual rows=33333.33 loops=3)

実際には city='paris'country='FR' は同じ行集合を指すので答えは10万行のままですが、プランナは 0.2 × 0.2 = 0.04 と掛け算して19,078行と見積もりました(実測の約5分の1)。しかも並列プランに切り替わっています。

この誤りは拡張統計で直せます。列の組み合わせに対する統計を明示的に作る機能です。

CREATE STATISTICS stx_ship (dependencies, mcv) ON city, country FROM shipments;
ANALYZE shipments;
 Seq Scan on shipments  (cost=0.00..10700.00 rows=99517 width=17) (actual rows=100000.00 loops=1)

推定99,517に対して実測100,000。ほぼ正確になり、無駄な並列化も消えました。プランナが何を学習したかも確認できます。

SELECT stxname, stxddependencies FROM pg_statistic_ext s
JOIN pg_statistic_ext_data d ON d.stxoid = s.oid;
 stxname  |             stxddependencies
----------+------------------------------------------
 stx_ship | {"2 => 3": 1.000000, "3 => 2": 0.603033}

"2 => 3": 1.000000 は「2番目の列(city)が決まれば3番目の列(country)は100%決まる」という意味です。逆方向(country から city)は 0.60 と、こちらは完全には決まらないことも正しく捉えています。

実務では「都道府県と市区町村」「カテゴリと小カテゴリ」「ステータスと種別」のように、相関する列を同時に絞り込む場面は頻出します。推定と実測が数倍ずれていて、かつ複数条件のANDなら、このパターンを疑ってください。

work_mem不足 - ソートとハッシュがディスクに落ちる

もう1つ、実測との乖離ではなく設定が原因で遅くなるパターンです。100万行をソートします。

 Sort  (cost=174942.84..177442.84 rows=1000000 width=34) (actual time=219.786..260.974 rows=1000000.00 loops=1)
   Sort Key: amount
   Sort Method: external merge  Disk: 49008kB
   Buffers: shared hit=9015 read=1582, temp read=12242 written=12261
 Execution Time: 306.804 ms

Sort Method: external merge Disk: 49008kB が出ています。作業メモリ(既定 work_mem は 4MB)に収まらず、一時ファイルに書き出しながらソートしたという意味です。temp read/written にその往復が現れています。

work_mem を増やして再実行します。

SET work_mem = '256MB';
 Sort  (cost=120251.84..122751.84 rows=1000000 width=34) (actual time=104.913..127.038 rows=1000000.00 loops=1)
   Sort Key: amount
   Sort Method: quicksort  Memory: 79264kB
 Execution Time: 148.176 ms

quicksort Memory: 79264kB に変わり、306ms から 148ms になりました。Hash Join 側では Batches: 1 が2以上になっているかが同じサインです(前掲の例では Batches: 2temp written=340 が出ていました)。

ただし work_mem接続ごと・ソートノードごとに確保され得る点に注意が必要です。同時接続100本のサーバで安易に256MBにすると、理屈上は数十GBを要求しかねません。全体設定を上げるのではなく、重いバッチの接続だけ SET LOCAL で引き上げるのが安全です。

MySQLでの読み方の違い

MySQLでも考え方は同じですが、出力形式と用語が異なります。

観点PostgreSQLMySQL 8.x
推定のみEXPLAINEXPLAIN
実測付きEXPLAIN ANALYZEEXPLAIN ANALYZE(8.0.18以降)
既定の出力ツリー形式表形式(FORMAT=TREE でツリー)
全件走査の表示Seq Scantype: ALL
絞り込みに使った索引Index Condkeykey_len
読んだ後の絞り込みFilterExtra: Using where
索引だけで完結Index Only ScanExtra: Using index
ディスクソートSort Method: external mergeExtra: Using filesort
統計の更新ANALYZEANALYZE TABLE

MySQLの Extra: Using index(索引のみで完結=速い)と Using index conditionUsing where(読んだ後に捨てる)は紛らわしいので、PostgreSQLの Index CondFilter の対比を頭に入れてから読むと整理しやすいはずです。

なおMySQLの EXPLAIN ANALYZE も実際にクエリを実行します。更新系に対して気軽に打たない、という注意は両者に共通です。

実務でのチェック手順

最後に、遅いクエリに出くわしたときの手順としてまとめます。

  1. EXPLAIN (ANALYZE, BUFFERS) を取る。PostgreSQL 18 以降は BUFFERS は自動で付きます
  2. 推定 rows と実測 rows の乖離が大きいノードを探す。一桁以上ずれているノードが元凶です。loops が大きいノードは掛け算してから比べます
  3. 乖離が見つかったら統計を疑う。まず ANALYZE、直らなければ複数条件の相関を疑って CREATE STATISTICS
  4. 条件が Index CondFilter かを見るFilter なら列を加工していないか確認する
  5. Sort MethodBatches を見るexternal mergeBatches: 2 以上ならメモリ不足
  6. Rows Removed by Filter を見る。読んだ大半を捨てているならインデックス設計を見直す

そして重要な前提を1つ。公式ドキュメントは "EXPLAIN results should not be extrapolated to situations much different from the one you are actually testing"(EXPLAINの結果を、実際にテストした状況と大きく異なる状況へ外挿してはならない)と警告しています。開発環境の1万行のテーブルで取ったプランは、本番の1億行では通用しません。プランは本番相当のデータ量と統計で確認するのが原則です。

実行計画は、データベースが「なぜそう動いたか」を説明してくれる唯一の窓口です。コストの数字が掛け算で組み立てられていること、そして掛け算の元になる行数の見積もりが統計情報から来ていることさえ押さえておけば、あとは推定と実測の差を追いかけるだけの作業になります。

参考リンク

データベース正規化 入門 - 第1〜第3正規形とBCNFを実例で理解する

データベース正規化 入門 - 第1〜第3正規形とBCNFを実例で理解する

20

データベースの正規化を実務目線で整理します。なぜ正規化が要るのか(更新・挿入・削除の異常)、関数従属・候補キー・部分従属・推移従属といった前提用語、第1正規形(1NF)・第2正規形(2NF)・第3正規形(3NF)・ボイス・コッド正規形(BCNF)の定義と具体例、そして非正規化のトレードオフまで、悪い設計テーブルから正規化後テーブルへの流れをSQLと表で示しながら、初中級の開発者が実務で判断できるようにまとめます。

トランザクションとACID・分離レベル入門 - dirty read / phantom と PostgreSQL・MySQLの違い

トランザクションとACID・分離レベル入門 - dirty read / phantom と PostgreSQL・MySQLの違い

13

データベースのトランザクションを実務目線で整理します。ACID(原子性・一貫性・分離性・永続性)の意味、BEGIN/COMMIT/ROLLBACKの基本、4つの分離レベル(READ UNCOMMITTED / READ COMMITTED / REPEATABLE READ / SERIALIZABLE)と、各レベルで起き得る異常(dirty read・non-repeatable read・phantom read)の対応関係を表で確認します。さらにPostgreSQLのデフォルトはREAD COMMITTED、MySQL InnoDBのデフォルトはREPEATABLE READという製品差や、PostgreSQLではREAD UNCOMMITTEDがREAD COMMITTED扱いになる点まで、PostgreSQL・MySQL公式を一次ソースにまとめます。