ぱと隊長日誌

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

PostgreSQL のボトルネック調査に待機イベントを活用する

概要

PostgreSQL 9.6 以降では pg_stat_activity ビューに wait_event_type, wait_event が導入されました。これらの列ではバックエンドが待機しているイベントの情報を確認できます。この待機イベントの情報を収集することで、PostgreSQLボトルネック調査に役立つかもしれません。

事例

pgbench でパフォーマンスの検証を行っていたところ、以下の事象が見つかりました。

  • クライアント数に対して TPS が直線的に増加しない
  • クライアント数に対して latency が直線的に増加する
  • CPU は余力 (vmstat の id) がある
  • CPU の I/O 待ち (vmstat の wa) は 10% 以下
  • ディスクにも余力がありそう (iostat の %util は 100% 近いが他の指標より判断した)

TPS, latency, CPU (us, sy, id, wa) のグラフを示します。

pgbenchの通常モードでのTPS
pgbenchの通常モードでのlatency
pgbenchの通常モードでのCPU使用率

また、以下の条件では CPU の性能限界まで TPS が直線的な増加をしました。

  • pgbench の --unlogged-tables オプションを指定する
  • pgbench の --select-only オプションを指定する
  • postgresql.conf で synchronous_commit = off を指定する

unlogged-tables の例で先ほどと同様のグラフを示します。

pgbenchのunloggedモードでのTPS
pgbenchのunloggedモードでのlatency
pgbenchのunloggedモードでのCPU使用率

この結果を踏まえると、WAL 書き込みがボトルネックになっていると推測できます。

今回は検証なので異なる条件での比較検討が容易でしたが、本番環境で同じ問題が起きた時に条件を変えて比較することは困難です。そこで、今回のような事象が起きた時にボトルネックを見抜く手段の一つとして、待機イベントの情報収集という方法が有用かを確認しました。

検証環境

WindowsHyper-V による仮想マシンを検証環境としました。

ホスト

プロセッサ Intel Core i5-6600 CPU @ 3.30GHz
メモリ 20.0 GB
SSD SanDisk SDSSDH3
OS Windows 10 Pro バージョン 21H2

Hyper-V

プロセッサ 4個の仮想プロセッサ
メモリ 8.0 GB
OS Red Hat Enterprise Linux release 8.6

PostgreSQL

PostgreSQL 14.5 をソースコードからインストールしました。

pg_wait_sampling (GitHub - postgrespro/pg_wait_sampling: Sampling based statistics of wait events) の 2022/09/24 時点での最新版をインストールしました。

デフォルト設定との差分を以下に示します。

shared_buffers = 2GB
effective_cache_size = 6GB
maintenance_work_mem = 512MB
checkpoint_completion_target = 0.9
wal_buffers = 16MB
default_statistics_target = 100
random_page_cost = 1.1
effective_io_concurrency = 200
work_mem = 10485kB
min_wal_size = 2GB
max_wal_size = 8GB
max_worker_processes = 4
max_parallel_workers_per_gather = 2
max_parallel_workers = 4
max_parallel_maintenance_workers = 2
autovacuum = off
shared_preload_libraries = 'pg_wait_sampling'

検証スクリプト

概要は以下のとおりです。

  • pgbench のクライアント数およびワーカスレッド数を 1 - 24 まで変化させる
  • pgbench は 180 秒実行する
  • pgbench 実行から 60 秒後に vmstat, iostat を 60 秒間計測する
  • pgbench 実行中の待機イベントを pg_wait_sampling で集計する

通常パターンを計測する際のスクリプトを提示します。

#!/bin/bash

for cnum in `seq 1 24`
do
  echo "${cnum} の実行です。"
  
  pgbench -i -s 32 testdb
  
  psql -d testdb -c 'SELECT pg_wait_sampling_reset_profile();'
  
  pgbench -c ${cnum} -j ${cnum} -T 180 testdb >> pgbench_normal_${cnum}.txt &
  pid_pgbench=${!}
  
  sleep 60
  
  vmstat 60 2 >> pgbench_normal_${cnum}.txt &
  pid_vmstat=${!}
  
  iostat -x 60 2 >> pgbench_normal_${cnum}.txt &
  pid_iostat=${!}
  
  wait ${pid_pgbench} ${pid_vmstat} ${pid_iostat}
  
  psql --csv -d testdb -c "SELECT ${cnum} AS cnum, event_type, event, SUM(count) AS sum_event FROM pg_wait_sampling_profile WHERE event IS NOT NULL GROUP BY event_type, event ORDER BY sum_event desc;" >> pgbench_normal_wait_profile.txt
done

検証パターン

以下のパターンで検証します。

  • 通常パターン (normal)
  • unloggedパターン (unlogged)
    • pgbench で --unlogged-tables オプションを追加します。
  • 非同期コミットパターン (asynchronous)
    • postgresql.conf で synchronous_commit = off を指定します。

検証結果と考察

TPS や CPU の利用状況などは測定結果の一部を先述したため、ここでは割愛します。

各パターンでの待機イベントの傾向を確認します。サンプリングで現れることの多かった待機イベントが処理時間の多くを占めている(つまりボトルネック)と考えます。

特徴的な待機イベントを抜粋してグラフに表しています。

待機イベントの意味はマニュアルを参照しました。
28.2. 統計情報コレクタ

pgbenchの通常モードでの待機イベント

normal ではクライアント数に比例して LWLock:WALWrite が増加しています。この待機イベントは「WALバッファがディスクに書き込まれるのを待機しています」です。ここから、WAL 書き込みがボトルネックになっていると推測できます。

Lock:transactionid の増加も目立ちます。この待機イベントは「トランザクションが終了するのを待機しています」です。これは行ロック待ちで発生するようです。下記の記事を参考にしました。
トランザクションIDへのロックと行ロック

TPS に比例するように Client:ClientRead が増えています。この待機イベントは「クライアントからのデータの読み込みを待機しています」なので、TPS に比例するのは自然なことと考えられます。

pgbenchのunloggedモードでの待機イベント

unlogged ではクライアント数に比例して Client:ClientRead が増加しています。このことから、大半の時間をクライアントからのデータ読み込みで待機していると推測できます。

なお、クライアント数に応じて Lock:transactionid が増加することは normal と同様です。

pgbenchのasynchronousモードでの待機イベント

asynchronous は unlogged と同様の傾向にあることが読み取れます。

これらの結果により、normal では WAL 書き込みがボトルネックとなっていたと推測できます。

まとめ

今回の検証では待機イベントの情報収集手段として、pg_wait_sampling を利用しました。pg_wait_sampling を本番環境で後から導入することは難しいかもしれません。ですが、待機イベントの情報は pg_stat_activity ビューの wait_event_type, wait_event 列で確認できます。また、これを定期的に取得することで傾向を確認できます。

ボトルネックの調査手法の一つとして、待機イベントに着目するという手段が使えそうです。

PostgreSQL の結合テーブル数と計画時間の関係

概要

PostgreSQL のマニュアルには以下の記載があります。

結合の対象がせいぜい2、3個のテーブルなら心配するほど結合の種類は多くありません。 しかし、テーブル数が増えると可能な結合の数は指数関数的に増えていきます。 10程度以上にテーブルが増えると、すべての可能性をしらみつぶしに探索することはもはや実用的ではなくなります。 6や7個のテーブルでさえも、計画を作成する時間が無視できなくなります。

https://www.postgresql.jp/document/14/html/explicit-joins.html

これが実際どの程度なのか、PostgreSQL の結合テーブル数と計画時間の関係を調べてみました。

検証

検証環境

WindowsHyper-V による仮想マシンを検証環境としました。

ホスト
プロセッサ Intel Core i5-6600 CPU @ 3.30GHz
メモリ 20.0 GB
SSD SanDisk SDSSDH3
OS Windows 10 Pro バージョン 21H2
Hyper-V
プロセッサ 4個の仮想プロセッサ
メモリ 8.0 GB
OS Red Hat Enterprise Linux release 8.6
DB PostgreSQL 14.5
PostgreSQL

ソースコードからインストールし、デフォルト設定のままとしました。

検証方法

計画時間には以下の項目が影響すると想定しました。

  • 結合テーブル数
  • 結合方式
  • 遺伝的問い合わせ最適化 (GEQO)

そこで、以下の条件を組み合わせて検証しました。()内は本記事内での表記です。

  • 結合テーブル数を 2 ~ 20 で変化させる
  • FROM 句の記載
    • 単純に並べる (list)
    • CROSS JOIN で結合 (cross)
  • WHERE 句の結合条件有無
    • 無し (only)
    • 有り (where)

各条件のクエリを EXPLAIN (SUMMARY) で 5 回測定し、Planning Time の中央値を採用しました。

実験に用いたスクリプトを抜粋します。

CREATE TABLE tab1 (id integer);
CREATE TABLE tab2 (id integer);
(省略)
CREATE TABLE tab20 (id integer);

INSERT INTO tab1 SELECT generate_series(1, 1*1000);
INSERT INTO tab2 SELECT generate_series(1, 2*1000);
(省略)
INSERT INTO tab20 SELECT generate_series(1, 20*1000);

VACUUM ANALYZE;

-- list_only
EXPLAIN (SUMMARY) SELECT * FROM tab1, tab2;
EXPLAIN (SUMMARY) SELECT * FROM tab1, tab2, tab3;
(省略)
EXPLAIN (SUMMARY) SELECT * FROM tab1, tab2, tab3, ... , tab20;

-- list_where
EXPLAIN (SUMMARY) SELECT * FROM tab1, tab2 WHERE tab1.id = tab2.id;
EXPLAIN (SUMMARY) SELECT * FROM tab1, tab2, tab3 WHERE tab1.id = tab2.id AND tab2.id = tab3.id;
(省略)
EXPLAIN (SUMMARY) SELECT * FROM tab1, tab2, tab3, ... , tab20 WHERE tab1.id = tab2.id AND tab2.id = tab3.id ... AND tab19.id = tab20.id;

-- cross_only
EXPLAIN (SUMMARY) SELECT * FROM tab1 CROSS JOIN tab2;
EXPLAIN (SUMMARY) SELECT * FROM tab1 CROSS JOIN tab2 CROSS JOIN tab3;
(省略)
EXPLAIN (SUMMARY) SELECT * FROM tab1 CROSS JOIN tab2 CROSS JOIN tab3 ... CROSS JOIN tab20;

-- cross_where
EXPLAIN (SUMMARY) SELECT * FROM tab1 CROSS JOIN tab2 WHERE tab1.id = tab2.id;
EXPLAIN (SUMMARY) SELECT * FROM tab1 CROSS JOIN tab2 CROSS JOIN tab3 WHERE tab1.id = tab2.id AND tab2.id = tab3.id;
(省略)
EXPLAIN (SUMMARY) SELECT * FROM tab1 CROSS JOIN tab2 CROSS JOIN tab3 ... CROSS JOIN tab20 WHERE tab1.id = tab2.id AND tab2.id = tab3.id ... AND tab19.id = tab20.id;

検証結果

テーブル数と計画時間(計画時間は対数スケール)
<図1 テーブル数と計画時間(計画時間は対数スケール)>

テーブル数と計画時間
<図2 テーブル数と計画時間>
※ list_where は計画時間が大きすぎるため、省略しています。

階乗のグラフ(片対数グラフ)
<図3 階乗のグラフ(片対数グラフ)>

結合テーブル数が増えるほど、計画時間は増えます。結合テーブル数を n としたとき、結合順の候補数は n! となります。よって、計画時間も n! に比例すると考えられます。図1と図3がよく似ていることからもこれを裏付けています。

テーブル数が 8 以下の時は list と cross で計画時間の差異がありません。このことから、結合の平坦化処理にかかる時間は無視できるほど小さいといえます。

テーブル数が 8 を超えると、cross の計画時間増加がいったん落ち着きます。これは結合の平坦化処理を行うテーブル数を制限する join_collapse_limit のデフォルトが 8 であることに起因していると考えられます。

以前の記事で join_collapse_limit の挙動を考察しました。
PostgreSQL の join_collapse_limit がプランナに与える影響 - ぱと隊長日誌
ここで、join_collapse_limit = 3 であれば、JOIN 句は 3 個までしか平坦化されないため、結合関係が {A B C},{C D E} のように分割される。また、プランナーはそれぞれの結合関係で最適な結合順を判断するとしました。

結合関係が分割されたことで検討すべき結合順候補数の違いとなり、計画時間の違いになったと考えています。

なお、cross の計画時間増加の変化がテーブル数 8 の次は 15 で同様に現れています。これも結合関係の分割に起因しているのではないかと推測しています。join_collapse_limit = 8 で各結合関係が 8 までしかテーブルを扱えないとすれば、下記のようになるからです。
{tab1 tab2 ... tab8} {tab8 tab9 ... tab15}

list の場合はテーブル数が 12 になると計画時間が減少しています。これは GEQO 発動のテーブル数を設定している geqo_threshold のデフォルトが 12 であることに起因していると考えられます。つまり、テーブル数が 12 以上になると GEQO に切り替わり、計画時間の減少に寄与しました。

cross の場合はテーブル数が 12 以上でも計画時間の減少は見られません。これは join_collapse_limit = 8 で結合の平坦化が 8 個までに制限されているため、各結合関係が GEQO 発動条件に合致しなかったためと考えられます。

結合条件の有無(only と where)で比較すると、計画時間は only < where となっています。only であれば単なる直積であるため、Nested Loop と Seq Scan の組み合わせだけ考えることになります。ですが、where のように結合条件が加わると、Hash Join なども検討することになり、計画に時間がかかったと推測されます。

マニュアルでも結合の計画に時間がかかることを示唆しています。

リレーショナル演算子の中で、処理と最適化が一番難しいのは結合です。 実行可能な問い合わせ計画の数は問い合わせの中に含まれる結合の数によって指数関数的に増加します。 個々の結合や、多様なインデックス(例えばPostgreSQLのB-tree、ハッシュ、GiST、GINなど)をリレーションのアクセスパスとして処理するため、様々な結合メソッド(例えばPostgreSQLのネステッドループ、ハッシュ結合、マージ結合など)を提供することが、さらなる最適化を行わなければならない腐心の原因となっています。

60.1. 複雑な最適化問題としての問い合わせ処理

まとめ

結合テーブル数が増えるほど、計画時間は増えます。ただし、その増え方は join_collapse_limit, geqo_threshold といったパラメータの影響を受けます。

今回検証に用いた比較的シンプルなクエリであっても、テーブル数が 11 の時に 300 ms を超える計画時間となりました。計画時間と生成されるプランの質のバランスを考えた時、計画時間に関わるパラメータのデフォルト値はかなり妥当な設定といえるのかもしれません。

PostgreSQL の join_collapse_limit がプランナに与える影響

はじめに

PostgreSQL の join_collapse_limit の説明をマニュアルから引用します。

join_collapse_limit (integer)
最終的にリストがこの項目数以下になる時、プランナは、明示的なJOIN構文(FULL JOINを除く)をFROM項目のリストに直します。 この値を小さくすれば計画作成時間は減少しますが、劣った問い合わせ計画が作成される可能性があります。

20.7. 問い合わせ計画

ですが、この記述からは join_collapse_limit がプランナに与える影響をイメージできませんでした。そこで、実験から推測を行いました。

PostgreSQL 14.5 をベースに調査しました。
また、src/backend/optimizer/README を参考にしました。

平坦化

SELECT * FROM A, B, C; の結合関係を {A B C} のように表現するとします。この時、A と B を結合してから C と結合しても、B と C を結合してから A を結合しても、論理的には同じです。

明示的な JOIN 句があると「平坦化」という処理が行われます。これについて明確な説明はありませんが、マニュアルの「14.3. 明示的なJOIN句でプランナを制御する」を参考に推測すると、以下の処理を行うようです。

SELECT * FROM A CROSS JOIN B CROSS JOIN C;
 ↓
SELECT * FROM A, B, C;

このように平坦化したうえでプランナーが最適な結合順を判断します。

join_collapse_limit

PostgreSQL は明示的な JOIN 句の全てを平坦化するわけではありません。ここで join_collapse_limit が効いてきます。

以下のクエリで考えます。

SELECT * FROM A CROSS JOIN B CROSS JOIN C CROSS JOIN D CROSS JOIN E WHERE ...;

join_collapse_limit = 8(デフォルト)であれば {A B C D E} は5個なので、JOIN 句は全て平坦化されます。

ですが、join_collapse_limit = 3 に設定すると、JOIN 句は3個までしか平坦化できません。その結果として結合関係が {A B C},{C D E} のように分割されます。結合関係は分割されましたが、この例は C で結合されていることに注意してください。そして、プランナーはそれぞれの結合関係で最適な結合順を判断します。

結合関係が分割される、ということは src/backend/optimizer/README の "Join Tree Construction" の記述から解釈しました。

以下のテストデータで実際の挙動を確認します。

CREATE TABLE tab1 (id integer);
CREATE TABLE tab2 (id integer);
CREATE TABLE tab3 (id integer);
CREATE TABLE tab4 (id integer);
CREATE TABLE tab5 (id integer);

INSERT INTO tab1 SELECT generate_series(1, 1*1000);
INSERT INTO tab2 SELECT generate_series(1, 2*1000);
INSERT INTO tab3 SELECT generate_series(1, 3*1000);
INSERT INTO tab4 SELECT generate_series(1, 4*1000);
INSERT INTO tab5 SELECT generate_series(1, 5*1000);

VACUUM ANALYZE;

単純な CROSS JOIN

テーブルを CROSS JOIN で結合し、結合条件は指定しません。join_collapse_limit と結合順の関係を確認します。

SET join_collapse_limit = 8;

EXPLAIN SELECT * FROM tab5 CROSS JOIN tab4 CROSS JOIN tab3 CROSS JOIN tab2 CROSS JOIN tab1;
                                      QUERY PLAN
--------------------------------------------------------------------------------------
 Nested Loop  (cost=0.00..1500300075025251.50 rows=120000000000000000 width=20)
   ->  Nested Loop  (cost=0.00..300075025166.00 rows=24000000000000 width=16)
         ->  Nested Loop  (cost=0.00..75025098.00 rows=6000000000 width=12)
               ->  Nested Loop  (cost=0.00..25046.50 rows=2000000 width=8)
                     ->  Seq Scan on tab2  (cost=0.00..29.00 rows=2000 width=4)
                     ->  Materialize  (cost=0.00..20.00 rows=1000 width=4)
                           ->  Seq Scan on tab1  (cost=0.00..15.00 rows=1000 width=4)
               ->  Materialize  (cost=0.00..59.00 rows=3000 width=4)
                     ->  Seq Scan on tab3  (cost=0.00..44.00 rows=3000 width=4)
         ->  Materialize  (cost=0.00..78.00 rows=4000 width=4)
               ->  Seq Scan on tab4  (cost=0.00..58.00 rows=4000 width=4)
   ->  Materialize  (cost=0.00..98.00 rows=5000 width=4)
         ->  Seq Scan on tab5  (cost=0.00..73.00 rows=5000 width=4)
(13 rows)

結果を下表に示します。

join_collapse_limit 結合順
1 tab4, tab5, tab3, tab2, tab1
2 tab4, tab5, tab3, tab2, tab1
3 tab3, tab4, tab5, tab1, tab2
4 tab2, tab3, tab4, tab5, tab1
5 tab1, tab2, tab3, tab4, tab5
8 tab1, tab2, tab3, tab4, tab5

join_collapse_limit = 8 の結果から、結合順は tab1, tab2, tab3, tab4, tab5 が最適であるとわかります。

join_collapse_limit = 3 の結果に至った理由を推察します。この時、結合関係は {tab5 tab4 tab3}, {tab3 tab2 tab1} となっていたと思われます。そして、各結合関係で最適な結合順に並び替えると(join_collapse_limit = 8 の結果を参考にできます)、{tab3 tab4 tab5}, {tab1 tab2 tab3} となります。各結合順を踏まえて最終的に {tab3 tab4 tab5 tab1 tab2} になったと考えられます。

join_collapse_limit = 4 の結果についても推察します。この時、結合関係は {tab5 tab4 tab3 tab2}, {tab4 tab3 tab2 tab1} になっていたと思われます。各結合関係で最適な結合順に並び替えると {tab2 tab3 tab4 tab5}, {tab1 tab2 tab3 tab4} となり、最終的に {tab2 tab3 tab4 tab5 tab1} になりました。

WHERE 句で結合条件を指定する

テーブルを CROSS JOIN で結合し、結合条件を指定します。join_collapse_limit と結合順の関係を確認します。

SET join_collapse_limit = 3;

EXPLAIN SELECT * FROM tab5 CROSS JOIN tab4 CROSS JOIN tab3 CROSS JOIN tab2 CROSS JOIN tab1 WHERE tab5.id = tab4.id AND tab4.id = tab3.id AND tab3.id = tab2.id AND tab2.id = tab1.id;

                                            QUERY PLAN
--------------------------------------------------------------------------------------------------
 Hash Join  (cost=385.05..423.47 rows=192 width=20)
   Hash Cond: (tab2.id = tab5.id)
   ->  Seq Scan on tab2  (cost=0.00..29.00 rows=2000 width=4)
   ->  Hash  (cost=379.05..379.05 rows=480 width=16)
         ->  Hash Join  (cost=268.25..379.05 rows=480 width=16)
               Hash Cond: (tab5.id = tab1.id)
               ->  Hash Join  (cost=240.75..337.75 rows=2400 width=12)
                     Hash Cond: (tab4.id = tab5.id)
                     ->  Seq Scan on tab4  (cost=0.00..58.00 rows=4000 width=4)
                     ->  Hash  (cost=203.25..203.25 rows=3000 width=8)
                           ->  Hash Join  (cost=81.50..203.25 rows=3000 width=8)
                                 Hash Cond: (tab5.id = tab3.id)
                                 ->  Seq Scan on tab5  (cost=0.00..73.00 rows=5000 width=4)
                                 ->  Hash  (cost=44.00..44.00 rows=3000 width=4)
                                       ->  Seq Scan on tab3  (cost=0.00..44.00 rows=3000 width=4)
               ->  Hash  (cost=15.00..15.00 rows=1000 width=4)
                     ->  Seq Scan on tab1  (cost=0.00..15.00 rows=1000 width=4)
(17 rows)

この時の結合順は tab3, tab5, tab4, tab1, tab2 でした。これが先の考え方で説明できるかを確認します。

結合関係は {tab5 tab4 tab3}, {tab5 tab2 tab1} となっていたと思われます。それぞれで最適な結合順を実行計画で確認します。結合条件は先の実行計画を参考にしました。

SET join_collapse_limit = 8;

-- {tab5 tab4 tab3} の結合順
EXPLAIN SELECT * FROM tab5, tab4, tab3 WHERE tab5.id = tab3.id AND tab4.id = tab5.id;
                                   QUERY PLAN
--------------------------------------------------------------------------------
 Hash Join  (cost=240.75..337.75 rows=2400 width=12)
   Hash Cond: (tab4.id = tab5.id)
   ->  Seq Scan on tab4  (cost=0.00..58.00 rows=4000 width=4)
   ->  Hash  (cost=203.25..203.25 rows=3000 width=8)
         ->  Hash Join  (cost=81.50..203.25 rows=3000 width=8)
               Hash Cond: (tab5.id = tab3.id)
               ->  Seq Scan on tab5  (cost=0.00..73.00 rows=5000 width=4)
               ->  Hash  (cost=44.00..44.00 rows=3000 width=4)
                     ->  Seq Scan on tab3  (cost=0.00..44.00 rows=3000 width=4)
(9 rows)

-- {tab5 tab2 tab1} の結合順
EXPLAIN SELECT * FROM tab5, tab2, tab1 WHERE tab5.id = tab1.id AND tab2.id = tab5.id;
                                   QUERY PLAN
--------------------------------------------------------------------------------
 Hash Join  (cost=141.75..182.25 rows=400 width=12)
   Hash Cond: (tab2.id = tab5.id)
   ->  Seq Scan on tab2  (cost=0.00..29.00 rows=2000 width=4)
   ->  Hash  (cost=129.25..129.25 rows=1000 width=8)
         ->  Hash Join  (cost=27.50..129.25 rows=1000 width=8)
               Hash Cond: (tab5.id = tab1.id)
               ->  Seq Scan on tab5  (cost=0.00..73.00 rows=5000 width=4)
               ->  Hash  (cost=15.00..15.00 rows=1000 width=4)
                     ->  Seq Scan on tab1  (cost=0.00..15.00 rows=1000 width=4)
(9 rows)

EXPLAIN の結果から、各結合関係で最適な結合順に並び替えると、{tab3 tab5 tab4}, {tab1 tab5 tab2} となりました。各結合順を踏まえつつ全体を結合すると、最終的に {tab3 tab5 tab4 tab1 tab2} となり、先の EXPLAIN 結果と一致しました。

同じテーブルを指定する

同じテーブルを別名で指定した時、結合順の判定でどのように扱われるのかを確認しました。

SET join_collapse_limit = 8;

EXPLAIN SELECT * FROM tab3 AS tab3a CROSS JOIN tab3 AS tab3b CROSS JOIN tab2 AS tab2a CROSS JOIN tab2 AS tab2b CROSS JOIN tab1 as tab1a CROSS JOIN tab1 as tab1b;
                                            QUERY PLAN
--------------------------------------------------------------------------------------------------
 Nested Loop  (cost=0.00..450150050025012736.00 rows=36000000000000000000 width=24)
   ->  Nested Loop  (cost=0.00..150050025012652.00 rows=12000000000000000 width=20)
         ->  Nested Loop  (cost=0.00..50025012600.50 rows=4000000000000 width=16)
               ->  Nested Loop  (cost=0.00..25012566.50 rows=2000000000 width=12)
                     ->  Nested Loop  (cost=0.00..12532.50 rows=1000000 width=8)
                           ->  Seq Scan on tab1 tab1a  (cost=0.00..15.00 rows=1000 width=4)
                           ->  Materialize  (cost=0.00..20.00 rows=1000 width=4)
                                 ->  Seq Scan on tab1 tab1b  (cost=0.00..15.00 rows=1000 width=4)
                     ->  Materialize  (cost=0.00..39.00 rows=2000 width=4)
                           ->  Seq Scan on tab2 tab2a  (cost=0.00..29.00 rows=2000 width=4)
               ->  Materialize  (cost=0.00..39.00 rows=2000 width=4)
                     ->  Seq Scan on tab2 tab2b  (cost=0.00..29.00 rows=2000 width=4)
         ->  Materialize  (cost=0.00..59.00 rows=3000 width=4)
               ->  Seq Scan on tab3 tab3a  (cost=0.00..44.00 rows=3000 width=4)
   ->  Materialize  (cost=0.00..59.00 rows=3000 width=4)
         ->  Seq Scan on tab3 tab3b  (cost=0.00..44.00 rows=3000 width=4)
(16 rows)
join_collapse_limit 結合順
1 tab3b, tab3a, tab2a, tab2b, tab1a, tab1b
2 tab3b, tab3a, tab2a, tab2b, tab1a, tab1b
3 tab2a, tab3a, tab3b, tab1a, tab2b, tab1b
4 tab2b, tab2a, tab3a, tab3b, tab1a, tab1b
5 tab1a, tab2a, tab2b, tab3a, tab3b, tab1b
6 tab1b, tab1a, tab2a, tab2b, tab3a, tab3b
8 tab1b, tab1a, tab2a, tab2b, tab3a, tab3b

同じテーブルを別名で指定した時、それらは別々のテーブルとして結合順が判定されるようです。もし同じテーブルを重複してカウントしないとすれば、join_collapse_limit = 3 以降は結果が同じになるはずだからです。

まとめ

ソースコードと実際の挙動をトレースしたわけではないため、実験結果からの推測にずれがあるかもしれません。ですが、join_collapse_limit がどのような働きをしているかを概念的につかむことができたのではないでしょうか。

join_collapse_limit の使い方として、postgresql.conf ではデフォルトを指定し、JOIN 句の結合順序を明示的に指定したい場合のみ都度 SET join_collapse_limit = 1; とする、というのが良いと思われます。