ぱと隊長日誌

ブログ運用もエンジニアとしての生き方も模索中

PostgreSQLの実行計画の実行順とコスト・実行時間の累積

はじめに

PostgreSQLの実行計画の読み解き方は公式マニュアルで説明されています。PostgreSQL 10 でのリンクを示します。
14.1. EXPLAINの利用

ですが、若干分かり辛い個所があるため、本エントリでは以下の観点に着目して補足することにします。

  • ノードの実行順
  • コストの累積
  • 実行時間の累積

本エントリの引用は特記無い場合、 PostgreSQL 10 マニュアル の上記ページから引用(一部追記)しています。

ノードの実行順

原則ルール

ノードの実行順は以下の原則に従って決まります。

  1. ネストのより深いノードを優先する(右→左)
  2. ネストが同じレベルであれば先のノードを優先する(上→下)

ただし、必ずしもこの実行順となるわけではありません。これを例で確認します。

EXPLAIN SELECT *
FROM tenk1 t1, tenk2 t2
WHERE t1.unique1 < 100 AND t1.unique2 = t2.unique2;

                                        QUERY PLAN
------------------------------------------------------------------------------------------
(5) Hash Join  (cost=230.47..713.98 rows=101 width=488)
       Hash Cond: (t2.unique2 = t1.unique2)
(1)   ->  Seq Scan on tenk2 t2  (cost=0.00..445.00 rows=10000 width=244)
(4)   ->  Hash  (cost=229.20..229.20 rows=101 width=244)
(3)         ->  Bitmap Heap Scan on tenk1 t1  (cost=5.07..229.20 rows=101 width=244)
                   Recheck Cond: (unique1 < 100)
(2)               ->  Bitmap Index Scan on tenk1_unique1  (cost=0.00..5.04 rows=101 width=0)
                         Index Cond: (unique1 < 100)

括弧付き数字は原則に従った場合の実行順を示しています。ですが、この実行計画に対するマニュアルの解説は原則と異なった実行順を説明しています。

片方のテーブルの行がメモリ内のハッシュテーブルに格納され、もう片方のテーブルがスキャンされた後、各行に対して一致するかどうかハッシュテーブルを探索します。 繰り返しますが、インデント付けにより計画の構造が表されます。 tenk1に対するビットマップスキャンはハッシュノードへの入力です。 外部の子計画から行を読み取り、各行に対してハッシュテーブルを検索します。

原則に従った実行順となっていることを理解するためのポイントとなるのが、各ノードは実行途中でも出力結果を上位ノードに渡し、上位ノードは可能であればそれまでに受け取った出力結果をもとに処理を進めるということです。

この実行計画が選択され、かつtenk2が0行であった場合、ハッシュテーブル作成処理(②~④)はスキップされます。PostgreSQL 10.3 で 同様の条件にして EXPLAIN ANALYZE を実行したところ、ハッシュテーブル作成処理が "never executed" になることを確認しました。このことから言えるのが、①でtenk2が1行以上あることを確認してから、ハッシュテーブル作成処理を行っているということです。

ここからは推測となりますが、上位ノードである Hash Join が下位ノードの①でtenk2から最初の1行を読み込んだ後、ハッシュテーブル作成処理(②~④)を行っていると思われます。マニュアルの解説は分かりやすさを優先したため、実際の挙動とは相違したものと思われます。

上位ノードが下位ノードの途中結果を受けて処理を行うことは別の例でも確認できます。

EXPLAIN ANALYZE SELECT * FROM tenk1 WHERE unique1 < 100 AND unique2 > 9000 LIMIT 2;

                                                          QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.29..14.71 rows=2 width=244) (actual time=0.177..0.249 rows=2 loops=1)
   ->  Index Scan using tenk1_unique2 on tenk1  (cost=0.29..72.42 rows=10 width=244) (actual time=0.174..0.244 rows=2 loops=1)
         Index Cond: (unique2 > 9000)
         Filter: (unique1 < 100)
         Rows Removed by Filter: 287
 Planning time: 0.096 ms
 Execution time: 0.336 ms

インデックススキャンノードの推定コストと行数が実行完了したかのように表示されます。 しかし現実では、Limitノードが2行を取り出した後に行の要求を停止します。

下位ノードの Index Scan の結果を受けた 上位ノードの Limit が処理を停止させました。つまり、下位ノードの処理完了を待たず上位ノードは処理可能であれば処理を進めるということです。

例外ルール

前節の冒頭でノードの実行順の原則を示しましたが、例外もあることに注意が必要です。その一例が SubPlan ノードです。

http://www.nminoru.jp/~nminoru/postgresql/pg-plan-tree.html
この記事内にある"sample-corelated-subquery.sql"を用いて検証します。PostgreSQL 10.3 でsqlファイル内の CREATE TABLE, INSERT を発行後、VACUUM ANALYZE を実行しました。そして、SELECT の実行計画を取得した結果を示します。

EXPLAIN SELECT ID, name FROM employee WHERE salary < (SELECT population FROM cities WHERE employee.city = cities.city);
                          QUERY PLAN
--------------------------------------------------------------
 Seq Scan on employee  (cost=0.00..10.47 rows=3 width=10)
   Filter: (salary < ((SubPlan 1))::double precision)
   SubPlan 1
     ->  Seq Scan on cities  (cost=0.00..1.04 rows=1 width=4)
           Filter: ((employee.city)::text = (city)::text)
(5 rows)

"Seq Scan on employee" よりも "Seq Scan on cities" のネストが深いため、"Seq Scan on cities" が先に実行されるように見えます。ですが、実際には "Seq Scan on employee" が先に実行されます。その理由を説明します。

"Seq Scan on cities" を含む SubPlan 1 は "Seq Scan on employee" のFilterで評価(実行)されています。Filter は "Seq Scan on employee" がスキャンした各行に対してその条件を検査することを意味していることから、"Seq Scan on employee" が先に実行されるとわかります。そして、SubPlan ノードは評価される度にサブプランを実行します。

PostgreSQL 内部の挙動の詳細は本節冒頭の記事内で解説されているため、ご参照ください。
http://www.nminoru.jp/~nminoru/postgresql/pg-plan-tree.html#Param

今回示したケースのように、SQLがどのような処理に分解され、それらがどのような順で実行されるかを理解していないと、実行計画を読み違えてしまうことがあります。より深く学ぶためには以下の本をお勧めします。

データベースパフォーマンスアップの教科書 基本原理編

データベースパフォーマンスアップの教科書 基本原理編

コストの累積

マニュアルに記載の以下の点に注意してください。

上位ノードのコストには、すべての子ノードのコストもその中に含まれていることを理解することは重要です。

これを実行計画の例から確認します。

EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 100 AND unique2 > 9000;

                                     QUERY PLAN
-------------------------------------------------------------------------------------
 Bitmap Heap Scan on tenk1  (cost=25.08..60.21 rows=10 width=244)
   Recheck Cond: ((unique1 < 100) AND (unique2 > 9000))
   ->  BitmapAnd  (cost=25.08..25.08 rows=10 width=0)
         ->  Bitmap Index Scan on tenk1_unique1  (cost=0.00..5.04 rows=101 width=0)
               Index Cond: (unique1 < 100)
         ->  Bitmap Index Scan on tenk1_unique2  (cost=0.00..19.78 rows=999 width=0)
               Index Cond: (unique2 > 9000)

BitmapAnd の下位ノードである2つの Bitmap Index Scan の総コストを合算します。
5.04 + 19.78 = 24.82

これと比較すると BitmapAnd の総コストはわずかに大きくなっています。これは下位ノードの Bitmap の結果を受けてAND処理したことによるコストが加算されたためと考えられます。

BitmapAnd 単体のコストは以下の通りです。
(BitmapAnd の cost) - (下位ノードの cost) = 25.08 - (5.04 + 19.78) = 0.26

このように、BitmapAnd 単体の総コストが25.08なのではなく、下位ノードを含めたコストであることを理解して読み解く必要があります

実行計画に Loop ノードが加わると、より複雑になります。

EXPLAIN SELECT *
FROM tenk1 t1, tenk2 t2
WHERE t1.unique1 < 10 AND t1.unique2 = t2.unique2;

                                      QUERY PLAN
--------------------------------------------------------------------------------------
 Nested Loop  (cost=4.65..118.62 rows=10 width=488)
   ->  Bitmap Heap Scan on tenk1 t1  (cost=4.36..39.47 rows=10 width=244)
         Recheck Cond: (unique1 < 10)
         ->  Bitmap Index Scan on tenk1_unique1  (cost=0.00..4.36 rows=10 width=0)
               Index Cond: (unique1 < 10)
   ->  Index Scan using tenk2_unique2 on tenk2 t2  (cost=0.29..7.91 rows=1 width=244)
         Index Cond: (unique2 = t1.unique2)

Nested Loop の下位ノードの総コストは
39.47 + 7.91 = 47.38
ではありません。

Index Scan ノードに示された総コストはノード実行1回分です。Nested Loop ノードは Bitmap Heap Scan の出力1行毎に Index Scan を1回実行します。Bitmap Heap Scan は rows=10 であることから、Index Scan は10回実行されることが分かり、そこから計算される Nested Loop の下位ノードの総コストは
39.47 + 7.91 * 10 = 118.57
となります。

また、EXPLAIN ANALYZE の結果に出力されるloops値は実際のノード実行回数であり、コストの計算に用いた実行回数とは異なる可能性があることに注意が必要です。コストの計算に用いる実行回数は実行計画から慎重に見極める必要があります。

実行時間の累積

実行時間についても上位ノードは下位ノードの実行時間を含むこと、またループ数の考慮も必要なことに注意が必要です。

EXPLAIN ANALYZE SELECT *
FROM tenk1 t1, tenk2 t2
WHERE t1.unique1 < 10 AND t1.unique2 = t2.unique2;

                                                           QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------
 Nested Loop  (cost=4.65..118.62 rows=10 width=488) (actual time=0.128..0.377 rows=10 loops=1)
   ->  Bitmap Heap Scan on tenk1 t1  (cost=4.36..39.47 rows=10 width=244) (actual time=0.057..0.121 rows=10 loops=1)
         Recheck Cond: (unique1 < 10)
         ->  Bitmap Index Scan on tenk1_unique1  (cost=0.00..4.36 rows=10 width=0) (actual time=0.024..0.024 rows=10 loops=1)
               Index Cond: (unique1 < 10)
   ->  Index Scan using tenk2_unique2 on tenk2 t2  (cost=0.29..7.91 rows=1 width=244) (actual time=0.021..0.022 rows=1 loops=10)
         Index Cond: (unique2 = t1.unique2)
 Planning time: 0.181 ms
 Execution time: 0.501 ms

この実行計画に対する以下の説明に注意を払ってください。

問い合わせ計画の中には、何回も副計画ノードを実行する可能性のあるものがあります。 例えば、上述のネステッドループの計画では、内部インデックススキャンは外部の行ごとに一度行われます。 このような場合、loops値はそのノードを実行する総回数を報告し、表示される実際の時間と行数は1実行当たりの平均です。 これで値を表示された推定コストと比較できるようになります。 loops値をかけることで、そのノードで実際に費やされた総時間を得ることができます。 上の例では、tenk2に対するインデックススキャンの実行のために合計0.220ミリ秒要しています。

つまり、Nested Loop の下位ノードの総実行時間は
0.121 * 1 + 0.022 * 10 = 0.341
となります。

Nested Loop 単体の実行時間は以下の通りです。
(Nested Loop の actual time * loops) - (下位ノードの総実行時間) = 0.377 * 1 - 0.341 = 0.036

パフォーマンス改善のために actual time の急増するノードを探すことがありますが、

  • 上位ノードは下位ノードの総実行時間を含むこと
  • 全体の総実行時間を知るにはループ数を考慮する必要があること

を意識しないと、読み間違えることになります。

各ノードでループ数を踏まえた総実行時間を計算し、かつノード単体でかかった実行時間を計算するために下位ノードの総実行時間を差し引き、時間のかかっているノードを見つけることが必要です。

おわりに

今回取り上げたテーマは公式マニュアルや様々な資料で繰り返し説明されています。それだけ重要なポイントですが、説明は軽く触れられるにとどまり、実際に実行計画を読み取る際に考慮から漏れがちです。

実行計画を読み解く際は今回のポイントを意識してみてください。SQL処理に新たなボトルネックの発見があるかもしれません。

参考

実行計画をさらに深く知るための参考資料集を以前にまとめました。併せてご参照ください。
PostgreSQLの実行計画を読み解くための参考資料集 - ぱと隊長日誌

更新情報

2018/05/12

ノード単体のコストや実行時間の計算を追記しました。

2018/05/20

「ノードの実行順 - 原則ルール」の記述を一部見直しました。
「ノードの実行順 - 例外ルール」を追加しました。

2018/05/22

「ノードの実行順 - 例外ルール」の記述を一部見直しました。

ネストしたサブクエリにOR条件を含むSQLのパフォーマンスをPostgreSQLで改善する

はじめに

ネストしたサブクエリ(副問合せ)はEXISTS条件でよく利用されます。ですが、このサブクエリのフィルタ条件次第ではパフォーマンス問題となることがあります。これを Oracle Database で改善する例がOracleの記事(以下、「記事」とだけ表記した場合はこの記事を指します)で取り上げられていました。
第52回 OR条件の続きとSQL計画ディレクティブの使用について

この記事で紹介された方法はPostgreSQLにも適用できます。PostgreSQLでの適用(SQLの書き換え)方法やパフォーマンス改善の仕組みを説明し、書き換えがどのようなケースでも有効かを検証します。

SQLの書き換え

Oracle Database

記事の例を整形して引用します。

【書き換え前】

SELECT * FROM tab1 A1 WHERE EXISTS
  (SELECT 0 FROM tab2 A2 WHERE
    A2.c1 = A1.c1 AND (A2.c2 = 500 OR A1.c2 = 400));

【書き換え後】

SELECT * FROM tab1 A1 WHERE EXISTS
  (SELECT 0 FROM tab2 A2 WHERE
    A2.c1 = A1.c1 AND A2.c2 = 500)
UNION ALL
SELECT * FROM tab1 A1 WHERE EXISTS
  (SELECT 0 FROM tab2 A2 WHERE
    A2.c1 = A1.c1 AND A1.c2 = 400 AND LNNVL(A2.c2 = 500));

PostgreSQL

PostgreSQLの場合はLNNVL関数がありません。そこで、COALESCE と NOT を組み合わせて表現します。
参考:Oracle Database の LNNVL を PostgreSQL で実現する - ぱと隊長日誌

※書き換え前はOracleと同様のため省略します。

【書き換え後】

SELECT * FROM tab1 A1 WHERE EXISTS
  (SELECT 0 FROM tab2 A2 WHERE
    A2.c1 = A1.c1 AND A2.c2 = 500)
UNION ALL
SELECT * FROM tab1 A1 WHERE EXISTS
  (SELECT 0 FROM tab2 A2 WHERE
    A2.c1 = A1.c1 AND A1.c2 = 400 AND COALESCE(NOT(A2.c2 = 500), TRUE));

パフォーマンス改善の仕組み

記事でも書き換えの狙いについて解説していますが、本エントリで補足します。

なお、この改善の仕組みを深く理解するためにお勧めの本があります。

データベースパフォーマンスアップの教科書 基本原理編

データベースパフォーマンスアップの教科書 基本原理編

以降の解説はこの本の「6.3.5 セミ(Semi)ジョイン」を参考にしています。ぜひ併せてご参照ください。

EXISTSは「成功か、失敗か」を判定(確認)するブーリアン(Boolean)関数です。すなわち、EXISTSの条件に指定された集合の全ては必要なく、そのうちの1行でも該当すれば「成功」と判定されます。

EXISTSのサブクエリにはメインクエリの列が存在するため、サブクエリ単体で実行することはできません。そこで、メインクエリを先に実行し、その中間結果を受けてサブクエリを実行(EXISTS条件の判定)することになります。
このため、メインクエリで集合を十分小さく絞り込めないと、サブクエリを呼ばれる回数が増大することになります。
関連記事:SQLのWHERE句で用いられる相関サブクエリを理解する - ぱと隊長日誌

サブクエリ単体で実行することはできませんが、その条件次第では事前にサブクエリの結果である集合を絞り込むことができます。事前に集合を絞り込むことができれば、メインクエリの中間結果をEXISTSのサブクエリで確認する処理が効率よく行えることになります。

書き換え前のサブクエリの条件節を確認します。

A2.c1 = A1.c1 AND (A2.c2 = 500 OR A1.c2 = 400)

"A2.c1 = A1.c1"はメインクエリとサブクエリの列の両方が含まれるため、事前の絞り込みには利用できません。
"(A2.c2 = 500 OR A1.c2 = 400)"もメインクエリとサブクエリの列がOR条件でつながっているため、事前の絞り込みには利用できません。例えば、"A2.c2 = 500"に該当しないレコードでも、"A1.c2 = 400"の成り立つ可能性があるため、事前に除外することができません。

次に書き換え後のサブクエリの条件節を確認します(Oracle Database の例を利用します)。

UNION ALL 前段のSQLのサブクエリを確認します。

A2.c1 = A1.c1 AND A2.c2 = 500

"A2.c2 = 500"はメインクエリの結果に依存しておらず、事前の絞り込みに利用できます。

UNION ALL 後段のSQLのサブクエリを確認します。

A2.c1 = A1.c1 AND A1.c2 = 400 AND LNNVL(A2.c2 = 500)

"A1.c2 = 400"はサブクエリ内に記述されていますが、メインクエリの条件として扱うことができます。つまり、メインクエリの集合(中間結果)を小さく絞り込むことができます。

このように、書き換え前にはできなかった事前の絞り込みが書き換え後は有効となることがわかります。ただし、記事にもある通り、書き換えによってセミジョインが2回実行されることになるため、条件によっては狙ったような効果を得られないこともあることに注意が必要です。

PostgreSQLでの実測

測定環境

Hyper-V上に検証マシンを構築しました。
仮想プロセッサ:2個
メモリ:8192MB

CentOS 7
PostgreSQL 10.3

PostgreSQLはコードからビルドし、設定はデフォルトです。

テストテーブル・データ

テストデータの条件を変えるたびに以下のSQLを実行しました。

DROP TABLE tab1;
DROP TABLE tab2;

CREATE TABLE tab1(
  c1 integer NOT NULL DEFAULT 0,
  c2 integer NOT NULL DEFAULT 0
);
CREATE TABLE tab2(
  c1 integer NOT NULL DEFAULT 0,
  c2 integer NOT NULL DEFAULT 0
);

-- データの件数は条件に応じて設定する。
INSERT INTO tab1(c1, c2)
  SELECT
    i, i
  FROM
    generate_series(1, 10000000) as i
;
INSERT INTO tab2(c1, c2)
  SELECT
    i, i
  FROM
    generate_series(1, 10000000) as i
;

VACUUM ANALYZE;

測定方法

tab1, tab2のデータ件数の組み合わせ毎に、書き換え前後のSQLを EXPLAIN ANALYZE で実行しました。

キャッシュ状況による実行時間への影響を抑えるため、同じSQLを連続して実行し、3~5回目の Execution time の平均を実行時間としました。

掲載する実行計画は3回目以降に取得したものです。

結果

実行時間
tab1[行数] tab2[行数] 書き換え前[ms] 書き換え後[ms]
10,000,000 10,000,000 99526.080 9412.461
10,000,000 1,000 4573.789 5391.029
1,000 10,000,000 10590.626 499.935
実行計画

(1)
tab1=10,000,000
tab2=10,000,000
書き換え前

Gather  (cost=309310.55..477777.76 rows=2 width=8) (actual time=90064.670..98008.299 rows=2 loops=1)
  Workers Planned: 2
  Workers Launched: 2
  ->  Hash Semi Join  (cost=308310.55..476777.56 rows=1 width=8) (actual time=83112.371..93349.189 rows=1 loops=3)
        Hash Cond: (a1.c1 = a2.c1)
        Join Filter: ((a2.c2 = 500) OR (a1.c2 = 400))
        Rows Removed by Join Filter: 3333333
        ->  Parallel Seq Scan on tab1 a1  (cost=0.00..85914.52 rows=4166652 width=8) (actual time=8.473..13931.876 rows=3333333 loops=3)
        ->  Hash  (cost=144247.80..144247.80 rows=9999980 width=8) (actual time=31733.013..31733.013 rows=10000000 loops=3)
              Buckets: 131072  Batches: 256  Memory Usage: 2553kB
              ->  Seq Scan on tab2 a2  (cost=0.00..144247.80 rows=9999980 width=8) (actual time=0.025..22586.940 rows=10000000 loops=3)

(2)
tab1=10,000,000
tab2=10,000,000
書き換え後

Append  (cost=97331.34..596907.79 rows=2 width=8) (actual time=504.257..9245.416 rows=2 loops=1)
  ->  Hash Semi Join  (cost=97331.34..267828.90 rows=1 width=8) (actual time=504.256..8841.177 rows=1 loops=1)
        Hash Cond: (a1.c1 = a2.c1)
        ->  Seq Scan on tab1 a1  (cost=0.00..144247.64 rows=9999964 width=8) (actual time=0.009..4121.556 rows=10000000 loops=1)
        ->  Hash  (cost=97331.33..97331.33 rows=1 width=4) (actual time=503.765..503.765 rows=1 loops=1)
              Buckets: 1024  Batches: 1  Memory Usage: 9kB
              ->  Gather  (cost=1000.00..97331.33 rows=1 width=4) (actual time=0.360..503.761 rows=1 loops=1)
                    Workers Planned: 2
                    Workers Launched: 2
                    ->  Parallel Seq Scan on tab2 a2  (cost=0.00..96331.23 rows=1 width=4) (actual time=328.976..496.680 rows=0 loops=3)
                          Filter: (c2 = 500)
                          Rows Removed by Filter: 3333333
  ->  Nested Loop Semi Join  (cost=1000.00..329078.87 rows=1 width=8) (actual time=0.705..404.234 rows=1 loops=1)
        Join Filter: (a1_1.c1 = a2_1.c1)
        Rows Removed by Join Filter: 399
        ->  Gather  (cost=1000.00..97331.25 rows=1 width=8) (actual time=0.330..403.858 rows=1 loops=1)
              Workers Planned: 2
              Workers Launched: 2
              ->  Parallel Seq Scan on tab1 a1_1  (cost=0.00..96331.15 rows=1 width=8) (actual time=261.607..395.839 rows=0 loops=3)
                    Filter: (c2 = 400)
                    Rows Removed by Filter: 3333333
        ->  Seq Scan on tab2 a2_1  (cost=0.00..169247.75 rows=4999990 width=4) (actual time=0.005..0.196 rows=400 loops=1)
              Filter: COALESCE((c2 <> 500), true)

(3)
tab1=10,000,000
tab2=1,000
書き換え前

Gather  (cost=1027.50..97879.58 rows=1 width=8) (actual time=1.550..4486.569 rows=2 loops=1)
  Workers Planned: 2
  Workers Launched: 2
  ->  Hash Semi Join  (cost=27.50..96879.48 rows=1 width=8) (actual time=2981.615..4476.620 rows=1 loops=3)
        Hash Cond: (a1.c1 = a2.c1)
        Join Filter: ((a2.c2 = 500) OR (a1.c2 = 400))
        Rows Removed by Join Filter: 333
        ->  Parallel Seq Scan on tab1 a1  (cost=0.00..85914.52 rows=4166652 width=8) (actual time=0.024..2202.727 rows=3333333 loops=3)
        ->  Hash  (cost=15.00..15.00 rows=1000 width=8) (actual time=0.863..0.863 rows=1000 loops=3)
              Buckets: 1024  Batches: 1  Memory Usage: 48kB
              ->  Seq Scan on tab2 a2  (cost=0.00..15.00 rows=1000 width=8) (actual time=0.007..0.402 rows=1000 loops=3)

(4)
tab1=10,000,000
tab2=1,000
書き換え後

Append  (cost=1017.51..195224.61 rows=2 width=8) (actual time=1.591..5790.684 rows=2 loops=1)
  ->  Gather  (cost=1017.51..97869.59 rows=1 width=8) (actual time=1.590..5214.321 rows=1 loops=1)
        Workers Planned: 2
        Workers Launched: 2
        ->  Hash Semi Join  (cost=17.51..96869.49 rows=1 width=8) (actual time=3469.855..5206.497 rows=0 loops=3)
              Hash Cond: (a1.c1 = a2.c1)
              ->  Parallel Seq Scan on tab1 a1  (cost=0.00..85914.52 rows=4166652 width=8) (actual time=0.246..2550.067 rows=3333333 loops=3)
              ->  Hash  (cost=17.50..17.50 rows=1 width=4) (actual time=0.097..0.097 rows=1 loops=3)
                    Buckets: 1024  Batches: 1  Memory Usage: 9kB
                    ->  Seq Scan on tab2 a2  (cost=0.00..17.50 rows=1 width=4) (actual time=0.052..0.091 rows=1 loops=3)
                          Filter: (c2 = 500)
                          Rows Removed by Filter: 999
  ->  Nested Loop Semi Join  (cost=1000.00..97355.00 rows=1 width=8) (actual time=1.847..576.357 rows=1 loops=1)
        Join Filter: (a1_1.c1 = a2_1.c1)
        Rows Removed by Join Filter: 399
        ->  Gather  (cost=1000.00..97331.25 rows=1 width=8) (actual time=1.306..575.814 rows=1 loops=1)
              Workers Planned: 2
              Workers Launched: 2
              ->  Parallel Seq Scan on tab1 a1_1  (cost=0.00..96331.15 rows=1 width=8) (actual time=377.590..569.091 rows=0 loops=3)
                    Filter: (c2 = 400)
                    Rows Removed by Filter: 3333333
        ->  Seq Scan on tab2 a2_1  (cost=0.00..17.50 rows=500 width=4) (actual time=0.007..0.313 rows=400 loops=1)
              Filter: COALESCE((c2 <> 500), true)

(5)
tab1=1,000
tab2=10,000,000
書き換え前

Hash Semi Join  (cost=308310.66..347399.31 rows=1 width=8) (actual time=9539.860..10277.544 rows=2 loops=1)
  Hash Cond: (a1.c1 = a2.c1)
  Join Filter: ((a2.c2 = 500) OR (a1.c2 = 400))
  Rows Removed by Join Filter: 998
  ->  Seq Scan on tab1 a1  (cost=0.00..15.00 rows=1000 width=8) (actual time=0.008..0.434 rows=1000 loops=1)
  ->  Hash  (cost=144247.85..144247.85 rows=9999985 width=8) (actual time=9170.601..9170.601 rows=10000000 loops=1)
        Buckets: 131072  Batches: 256  Memory Usage: 2553kB
        ->  Seq Scan on tab2 a2  (cost=0.00..144247.85 rows=9999985 width=8) (actual time=0.018..4096.851 rows=10000000 loops=1)

(6)
tab1=1,000
tab2=10,000,000
書き換え後

Append  (cost=1000.00..329126.59 rows=2 width=8) (actual time=496.239..498.212 rows=2 loops=1)
  ->  Nested Loop Semi Join  (cost=1000.00..97361.36 rows=1 width=8) (actual time=496.237..497.723 rows=1 loops=1)
        Join Filter: (a1.c1 = a2.c1)
        Rows Removed by Join Filter: 999
        ->  Seq Scan on tab1 a1  (cost=0.00..15.00 rows=1000 width=8) (actual time=0.008..0.572 rows=1000 loops=1)
        ->  Materialize  (cost=1000.00..97331.36 rows=1 width=4) (actual time=0.001..0.495 rows=1 loops=1000)
              ->  Gather  (cost=1000.00..97331.36 rows=1 width=4) (actual time=0.349..494.411 rows=1 loops=1)
                    Workers Planned: 2
                    Workers Launched: 2
                    ->  Parallel Seq Scan on tab2 a2  (cost=0.00..96331.26 rows=1 width=4) (actual time=322.373..487.059 rows=0 loops=3)
                          Filter: (c2 = 500)
                          Rows Removed by Filter: 3333333
  ->  Nested Loop Semi Join  (cost=0.00..231765.21 rows=1 width=8) (actual time=0.432..0.483 rows=1 loops=1)
        Join Filter: (a1_1.c1 = a2_1.c1)
        Rows Removed by Join Filter: 399
        ->  Seq Scan on tab1 a1_1  (cost=0.00..17.50 rows=1 width=8) (actual time=0.047..0.098 rows=1 loops=1)
              Filter: (c2 = 400)
              Rows Removed by Filter: 999
        ->  Seq Scan on tab2 a2_1  (cost=0.00..169247.81 rows=4999992 width=4) (actual time=0.006..0.200 rows=400 loops=1)
              Filter: COALESCE((c2 <> 500), true)

実行結果の分析

実行時間の表をみると、条件(テーブルの行数)による効果の違いがわかります。書き換え後のほうが遅くなるケースもあり、書き換えを適用する際には注意が必要です。

実行計画(2)の例を確認します。
tab1=10,000,000
tab2=10,000,000
書き換え後

まず、UNION ALL 前段のSQLに相当する実行計画を確認します。

->  Hash Semi Join  (cost=97331.34..267828.90 rows=1 width=8) (actual time=504.256..8841.177 rows=1 loops=1)
         Hash Cond: (a1.c1 = a2.c1)
      ->  Seq Scan on tab1 a1  (cost=0.00..144247.64 rows=9999964 width=8) (actual time=0.009..4121.556 rows=10000000 loops=1)
      ->  Hash  (cost=97331.33..97331.33 rows=1 width=4) (actual time=503.765..503.765 rows=1 loops=1)
               Buckets: 1024  Batches: 1  Memory Usage: 9kB
            ->  Gather  (cost=1000.00..97331.33 rows=1 width=4) (actual time=0.360..503.761 rows=1 loops=1)
                     Workers Planned: 2
                     Workers Launched: 2
                  ->  Parallel Seq Scan on tab2 a2  (cost=0.00..96331.23 rows=1 width=4) (actual time=328.976..496.680 rows=0 loops=3)
                           Filter: (c2 = 500)
                           Rows Removed by Filter: 3333333

"A2.c2 = 500"を条件にサブクエリの集合を事前に十分絞り込んだことで、その後のメインクエリとの処理が効率よく行われています。

次に UNION ALL 後段のSQLに相当する実行計画を確認します。

->  Nested Loop Semi Join  (cost=1000.00..329078.87 rows=1 width=8) (actual time=0.705..404.234 rows=1 loops=1)
         Join Filter: (a1_1.c1 = a2_1.c1)
         Rows Removed by Join Filter: 399
      ->  Gather  (cost=1000.00..97331.25 rows=1 width=8) (actual time=0.330..403.858 rows=1 loops=1)
               Workers Planned: 2
               Workers Launched: 2
            ->  Parallel Seq Scan on tab1 a1_1  (cost=0.00..96331.15 rows=1 width=8) (actual time=261.607..395.839 rows=0 loops=3)
                     Filter: (c2 = 400)
                     Rows Removed by Filter: 3333333
      ->  Seq Scan on tab2 a2_1  (cost=0.00..169247.75 rows=4999990 width=4) (actual time=0.005..0.196 rows=400 loops=1)
               Filter: COALESCE((c2 <> 500), true)

"A1.c2 = 400"を条件にメインクエリの集合が絞り込まれています。これにより、サブクエリとの処理件数を減らすことができました。

なお、tab2のシーケンシャルスキャンが rows=400 となっています。これは今回のテーブルのデータの特性によるものです。
今回のテーブルのデータは1始まりの連番かつ、tab1, tab2ともにc1=c2の関係にあります。
"A1.c2 = 400"かつ"A2.c1 = A1.c1"ということはtab2の先頭から400行目に該当レコードがあります。EXISTSは1行でも該当すれば「成功」と判定できるため、該当行を見つけた時点で処理を中断しました。これが rows=400 となった理由です。

おわりに

今回のSQLの書き換えは一見冗長に見えますが、データベースの理論に裏打ちされたものでした。

条件に該当するSQLかつデータ量の増大に伴ってパフォーマンスが急激に劣化した場合、書き換えを検討する価値があります。ただし、効果の有無はデータの状況に依るため、慎重な見極めが必要となります。

Oracle Database の LNNVL を PostgreSQL で実現する

Oracle Database には LNNVL というファンクションがあります。

LNNVL(condition)

LNNVL の説明を Oracle Database 12c R2 マニュアルから引用します。

LNNVLは、条件のオペランドの1つまたは両方がNULLの可能性がある場合にその条件を簡単に評価する方法を提供します。このファンクションは問合せのWHERE句で、またはWHEN条件として検索CASE式で使用できます。このファンクションは、引数として条件を取り、その条件がFALSEまたはUNKNOWNの場合はTRUEを戻し、TRUEの場合はFALSEを戻します。LNNVLは、スカラー式を指定できる場所であればどこでも指定でき、有効ではないが、発生する可能性があるNULLを評価するためにIS [NOT] NULL、ANDまたはOR条件が必要であるようなコンテキストでも指定できます。

(中略)

a = 2およびb=NULLの場合のLNNVLからの戻り値を次の表に示します。

条件 条件の真偽 LNNVLの戻り値
a = 1 FALSE TRUE
a = 2 TRUE FALSE
a IS NULL FALSE TRUE
b = 1 UNKNOWN TRUE
b IS NULL TRUE FALSE
a = b UNKNOWN TRUE
LNNVL

LNNVL の条件(condition)と戻り値を真理値表で整理します。

条件 戻り値
FALSE TRUE
TRUE FALSE
UNKNOWN TRUE

PostgreSQL には LNNVL に相当する関数が用意されていませんが、COALESCE と NOT を組み合わせることで、同じことを表現することができます。

COALESCE(NOT(condition), TRUE)

これが Oracle のマニュアルに記載の LNNVL の振る舞いと同等であることを PostgreSQL 10.3 で確認しました。