クリスマスには間に合いませんでしたが、空いていたPostgreSQL Advent Calendar 2025(シリーズ2)とSRA Advent Calendar 2025の3日目の記事として。
はじめに
PostgreSQL 18 から EXPLAIN ANALYZE の結果に「実行されたインデックススキャンの回数」が Index Searches として出力されるようになりました。
では、この情報から何を知ることができるのでしょうか?
インデックススキャンが複数回実行される例
インデックススキャンが複数回実行される例をいくつか紹介します。
Nested Loop Join による複数回スキャン
まず、単純に Nested Loop Join の内側で複数回スキャンが行われた場合には、その回数に応じて Index Searches がカウントされます。
testidx=# EXPLAIN ANALYZE SELECT * FROM t, generate_series(1,10) c WHERE t.i = c;
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------
Nested Loop (cost=0.29..43.30 rows=12 width=8) (actual time=0.037..0.056 rows=20.00 loops=1)
Buffers: shared hit=21
-> Function Scan on generate_series c (cost=0.00..0.10 rows=10 width=4) (actual time=0.009..0.010 rows=10.00 loops=1)
-> Index Only Scan using t_i_idx on t (cost=0.29..4.31 rows=1 width=4) (actual time=0.003..0.004 rows=2.00 loops=10)
Index Cond: (i = c.c)
Heap Fetches: 0
Index Searches: 10
Buffers: shared hit=21
Planning Time: 0.148 ms
Execution Time: 0.086 ms
(10 rows)
ループが10回繰り返されたので、インデックススキャンも10回行われ Index Searches: 10 が出力されています。
IN / ANY による複数回インデックス検索
興味深いのは1回のテーブル検索で、複数のインデックススキャンが行われるパターンです。
具体的には、検索条件に ANY や IN で配列が使われた場合です。
testidx=# EXPLAIN ANALYZE SELECT * FROM t WHERE t.i IN (100,500,800);
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------
Index Only Scan using t_i_idx on t (cost=0.29..12.93 rows=3 width=4) (actual time=0.030..0.041 rows=3.00 loops=1)
Index Cond: (i = ANY ('{100,500,800}'::integer[]))
Heap Fetches: 0
Index Searches: 3
Buffers: shared hit=7
Planning Time: 0.126 ms
Execution Time: 0.065 ms
(7 rows)
この例では、Index Only Scan の実行は全体で1回だけですが、Index Searches: 3 と出力されており、その内側では インデックススキャンが3回実行されたことがわかります。
具体的には、i の値 100, 500, 800 それぞれをキーにした個々のインデックス検索が発生しています。
OR 条件と PostgreSQL 18 の ANY への書き換え
ちなみに、PostgreSQL 18 では OR 条件を ANY に書き換える改良がされており、以下のようなクエリでも EXPLAIN ANALYZE は同じ結果となります。
testidx=# EXPLAIN ANALYZE SELECT * FROM t WHERE t.i = 100 OR t.i=500 OR t.i=800;
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------
Index Only Scan using t_i_idx on t (cost=0.29..12.93 rows=3 width=4) (actual time=0.031..0.043 rows=3.00 loops=1)
Index Cond: (i = ANY ('{100,500,800}'::integer[]))
Heap Fetches: 0
Index Searches: 3
Buffers: shared hit=7
Planning Time: 0.151 ms
Execution Time: 0.070 ms
(7 rows)
PostgreSQL 17 では、OR 条件を ANY に置き換えられず、以下のように Bitmap Index Scan で実行されていました。
testidx=# EXPLAIN ANALYZE SELECT * FROM t WHERE t.i = 100 OR t.i=500 OR t.i=800;
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------
Bitmap Heap Scan on t (cost=12.90..24.21 rows=3 width=4) (actual time=0.133..0.149 rows=3 loops=1)
Recheck Cond: ((i = 100) OR (i = 500) OR (i = 800))
Heap Blocks: exact=3
-> BitmapOr (cost=12.90..12.90 rows=3 width=0) (actual time=0.104..0.105 rows=0 loops=1)
-> Bitmap Index Scan on t_i_idx (cost=0.00..4.30 rows=1 width=0) (actual time=0.067..0.067 rows=1 loops=1)
Index Cond: (i = 100)
-> Bitmap Index Scan on t_i_idx (cost=0.00..4.30 rows=1 width=0) (actual time=0.021..0.021 rows=1 loops=1)
Index Cond: (i = 500)
-> Bitmap Index Scan on t_i_idx (cost=0.00..4.30 rows=1 width=0) (actual time=0.014..0.014 rows=1 loops=1)
Index Cond: (i = 800)
Planning Time: 0.279 ms
Execution Time: 0.240 ms
(12 rows)
Bitmap Index Scan が3回実行されているので、インデックススキャンの回数が3回となるのは PostgreSQL 18 と同じです。
PostgreSQL 17 では Index Searches が出力されないので上記の結果からは確認できませんが、pg_stat_all_indexes システムビューを確認すると、クエリ実行の前後で idx_scan の値が3だけ増えていることから確認できます。
testidx=# SELECT idx_scan FROM pg_stat_all_indexes WHERE indexrelname = 't_i_idx';
idx_scan
----------
103
(1 row)
testidx=# SELECT FROM t WHERE t.i = 100 OR t.i=500 OR t.i=800;
--
(3 rows)
testidx=# SELECT idx_scan FROM pg_stat_all_indexes WHERE indexrelname = 't_i_idx';
idx_scan
----------
106
(1 row)
このようにインデックススキャンの回数は同じなのですが、PostgreSQL 17 では Bitmap Index Scan の後に Bitmap Heap Scan を実行する必要がある一方で、PostgreSQL 18 では ANY に変換することで Index Only Scan が使えるといった利点があります。
B-tree インデックスにおける複数回インデックススキャン
話を PostgreSQL 18 に戻しましょう。以下の条件では、実行されるインデックススキャンの回数(Index Searches)は 3 となっていました。
WHERE t.i IN (100,500,800)
これは、条件の中で指定されている値の数が3つだから、と容易に想像がつきます。では、値の個数は3つのまま数値を変えてみましょう。
testidx=# EXPLAIN ANALYZE SELECT * FROM t WHERE t.i IN (100,200,300);
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------
Index Only Scan using t_i_idx on t (cost=0.42..13.33 rows=3 width=4) (actual time=0.060..0.063 rows=3.00 loops=1)
Index Cond: (i = ANY ('{100,200,300}'::integer[]))
Heap Fetches: 0
Index Searches: 1
Buffers: shared hit=4
Planning Time: 0.163 ms
Execution Time: 0.094 ms
(7 rows)
すると、Index Searches は 1 となり、インデックスの回数は1回だけであったことがわかります。これはなぜでしょうか?
これを理解するために、PostgreSQL の B-tree インデックスで複数回インデックススキャンが行われるしくみを少し説明します。
条件が WHERE t.i IN (100,500,800) と WHERE t.i IN (100,200,300) の場合、いずれの場合も、まず最初に t.i = 100 の条件でインデックス検索が行われます。
最初の検索では B-tree のルートページから下向きにリンクをたどって、この条件に合うタプルが含まれるであろうリーフページを探索します。そのリーフページの中に対象のタプル(i=100)を見つけた後、今度はリーフページ内を右方向に探索して条件に合うタプルを探し続けます。リーフページ内の探索が終われば、その右隣のリーフページへと探索を続けます。B-tree インデックスの内部ではキーの値は昇順にソートされて格納されているため、このように右方向に探索を続ければ条件に合うすべてのタプルを見つけることができます。
右方向の探索を続けるうちに、最初の条件(i=100)に合うタプルを全て見つけた後に「条件に合わないタプル」が見つかります。この段階で次の条件(i=500 または i=200)の検索に移るのですが、その新しいキー値のタプルが現在探索中のリーフページ内に含まれるかどうかで振る舞いが変わってきます。同じリーフページに新しいキー値のタプルが含まれている場合、その位置から右方向の探索を続けます。しかし、次のキー値が含まれていない場合には、右方向の探索は行わずに、新しいキーで B-tree ルートページからの探索を新たに開始します。これが、インデックススキャン回数(Index Searches や pg_stat_all_indexes.idx_scan)に反映されます。
例にあげた WHERE t.i IN (100,500,800) の場合、 最後(一番右)の i=100 のタプルが見つかったリーフページの中に、次のキー i=500 のタプルが見つからなかったため、新しいインデックススキャンが発生。また、i=500 から i=800 の探索に移るときも同様に新しいインデックススキャンが行われ、合計3回のインデックススキャンとなりました。
一方で、WHERE t.i IN (100,200,300) の場合は、最後に i=100 が見つかったリーフページに次の i=200 のタプルが見つかったため右方向の探索を継続。さらに、i=200 から i=300 の探索に移るときも同様に右方向の探索が継続され、結果としてルートページからのインデックススキャンは1回だけとなりました。
このように、実際のインデックススキャンの実行回数は検索条件だけではなく、インデックスページの物理的状況によって左右されます。PostgreSQL 18 で EXPLAIN ANALYZE 出力に追加された Index Searches は、この振る舞いを確認するためにも有用な情報です。
Index Skip Scan と Index Searches
Index Searches にその振る舞いが現れる PostgreSQL 18 の新機能の1つとして、Index Skip Scan があります。
これは簡単に言うと、複数列インデックスの先頭カラムが WHERE 条件に含まれない場合にも、インデックスを有効に活用できる機能です。
複数列インデックス
まず複数列インデックスの動作について確認しておきましょう。以下のように複数カラムにインデックスが作成されているテーブルを考えます。
testidx=# \d t2
Table "public.t2"
Column | Type | Collation | Nullable | Default
--------+---------+-----------+----------+---------
i | integer | | |
j | integer | | |
Indexes:
"t2_i_j_idx" btree (i, j)
このテーブルで以下のようなクエリを実行すると、Index Searches は 9 となりました。
testidx=# EXPLAIN ANALYZE SELECT * FROM t2 WHERE t2.i IN (0,1,2) and t2.j IN (1000,2000,3000);
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------
Index Only Scan using t2_i_j_idx on t2 (cost=0.29..38.74 rows=6 width=8) (actual time=0.038..0.068 rows=9.00 loops=1)
Index Cond: ((i = ANY ('{0,1,2}'::integer[])) AND (j = ANY ('{1000,2000,3000}'::integer[])))
Heap Fetches: 0
Index Searches: 9
Buffers: shared hit=19
Planning Time: 0.210 ms
Execution Time: 0.096 ms
(7 rows)
この場合、まず最初の検索されるキーは (i,j) = (0,1000) です。そして、その次は i=0 はそのまま、j の値が次に進んで (i,j) = (0,2000) となります。続いて、(i,j) = (0,3000)。その後は、i の値が次に進んで (i,j) = (1,1000)、といった具合に検索が続いていきます。今回は、キーの値が進むたびに新しいインデックススキャンが発生し、合計9回となりました。
Index Skip Scan
次に、検索条件からインデックスの先頭要素である i を除いたクエリをみてみましょう。
testidx=# EXPLAIN ANALYZE SELECT * FROM t2 WHERE t2.j IN (1000,2000,3000);
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------
Index Only Scan using t2_i_j_idx on t2 (cost=0.29..51.63 rows=9 width=8) (actual time=0.040..0.077 rows=9.00 loops=1)
Index Cond: (j = ANY ('{1000,2000,3000}'::integer[]))
Heap Fetches: 0
Index Searches: 11
Buffers: shared hit=24
Planning Time: 0.131 ms
Execution Time: 0.099 ms
(7 rows)
このクエリの検索条件には t2.i の値の指定が無いため、可能なあらゆる i の値について t2.j IN (1000,2000,3000) の条件を満たすタプルを検索する必要があります。
PostgreSQL 17 以前では、こういった場合にはテーブルの全スキャン(Seq Scan)が選択されていました。しかし PostgreSQL 18 では、上の結果の通り、こういったクエリでもインデックススキャンが使用可能です。詳しい仕組みはここでは説明しませんが、省略された i に対応する検索条件が B-tree インデックス検索中に逐次決定され、それぞれの(i,j)の組み合わせについて繰り返しインデックススキャンを実行することで、リーフページの全タプルを右方向に検索する必要をなくしています。これが PostgreSQL 18 で導入された、Index Skip Scan と呼ばれているものです。インデックススキャンの回数とその際の i の条件はやはりインデックスページの物理的状況に依存しますが、EXPLAIN ANALYZE の Index Searches より、今回はルートページからのインデックススキャンが 11 回実行されたことがわかります。
おわりに
PostgreSQL 18 で EXPLAIN ANALYZE に追加された Index Searches を皮切りに、ここに表示されるインデックススキャンの回数は何を意味しているのか、どういう場合に複数回のインデックススキャンが実行されるのか、PostgreSQL 18 新機能である Index Skip Scan との関連について簡単に説明をしました。EXPLAIN ANALYZE のちょっとした出力から PostgreSQL の内部を考える、本記事がそんな皆様の想像力の一助となれば幸いです。