UNION ALLの上でIN (サブクエリ)を書いたら、57万行を歩いた:PostgreSQLの見積もりと、= ANY(ARRAY(...))
2つの表をUNION ALLで1つに見せたら、品番検索が4秒かかるようになった。手元で再現すると、INのせいで、プランナーが人気順の索引を全部歩いていた。見積もりの数字を読んで原因を確かめ、= ANY(ARRAY(...))で0.3 msに戻した記録。
UNION ALLの上では、プランナーがIDの一意性を知らず、IN (サブクエリ)の結合を何十万行と見積もります。「人気順に少し歩けば足りる」と賭けて、全部を歩きました。IDを先に集める = ANY(ARRAY(...))にすると、主キーで引くだけになり、150 msが0.3 msになりました。
先に結果
同じ形の2つの表(50万件と7万件)を、UNION ALL のビューで1つに見せました。そのビューに対して、「品番に当たる商品を、人気順に30件」を、2通りに書いて比べた結果です。
| 書き方 | 実行時間(5回) | 読んだ行 |
|---|---|---|
id IN (SELECT ...) | 144〜157 ms | 570,000行(人気順の索引を全部) |
id = ANY (ARRAY(SELECT ...)) | 0.31〜0.39 ms | 1行(主キーで引く) |
参考:表が1つで IN | 0.22〜0.30 ms | 1行 |
当たるのは1件だけなのに、IN では、2つの表の全行を人気順に読んでいました。同じ IN でも、表が1つなら速いままでした。UNION ALL の上で書いたことが、引き金になっています。
実際のサービスでは、JSONの大きな列と日本語の全文検索の索引があるので、差はもっと大きく出ました。本番と同じデータで、品番の検索が4秒かかっていたのが、= ANY(ARRAY(...)) に直して46 msになりました。
何が起きたか
ある商品カタログでは、本体の商品を items、別の分類の商品を other_items に分けて持っています。検索の画面は両方を区別しないので、UNION ALL で1つに見せたビュー catalog を読みます。
品番の検索は、表記の揺れ(VGS-12345、vgs12345 など)を吸収するため、「当たる可能性のある書き方」をいくつか UNION で集めて、その中のIDに絞ります。最初は、素直に IN で書きました。
SELECT p.id FROM catalog p
WHERE p.active AND p.id IN (
SELECT id FROM catalog WHERE search_text LIKE '%vgs-12345%'
UNION SELECT id FROM catalog WHERE search_text LIKE '%vgs12345%'
UNION SELECT id FROM catalog WHERE code = 'vgs12345')
ORDER BY COALESCE(p.rank, 2147483647), p.released DESC, p.id
LIMIT 30;EXPLAIN ANALYZE の要点は、次のとおりです(索引の細部は省いています)。
Limit (actual rows=1 loops=1)
-> Nested Loop (actual rows=1 loops=1)
Join Filter: (items.id = items_1.id)
Rows Removed by Join Filter: 569999
-> Merge Append (actual rows=570000 loops=1)
Sort Key: (COALESCE(items.rank, 2147483647)), items.released DESC, items.id
-> Index Scan using items_coalesce_released_id_idx on items (actual rows=500000 loops=1)
-> Index Scan using other_items_coalesce_released_id_idx on other_items (actual rows=70000 loops=1)
-> Materialize (actual rows=1 loops=570000)
-> Unique (actual rows=1 loops=1)
...(品番の候補を集める部分。ここは速い)
Execution Time: 179.686 ms- 外側は、2つの表の人気順の索引を、
Merge Appendで混ぜながら、上から順に読んでいる - 1行ごとに、品番の候補(1件)と照らし合わせ、569,999行を捨てている
- 30件そろったら止まるつもりだったが、1件しかないので、最後まで読んだ
なぜ、この賭けを選んだのか
プランナーの見積もりを見ると、理由が分かります(ANALYZE を外した EXPLAIN)。
Limit (cost=743.11..835.88 rows=30 width=15)
-> Nested Loop (cost=743.11..1023116.75 rows=330600 width=15)
-> Merge Append (cost=0.85..35447.86 rows=570000 width=15)
-> Materialize (cost=742.26..743.42 rows=116 width=32)- 品番の候補は、116件と見積もった(実際は1件)
- 結合の結果は、330,600行と見積もった。これは、116 × 570,000 × 0.005 とぴったり同じ
- 0.005は、PostgreSQLが、統計から選択率を出せないときに使う既定の値。UNION ALLで混ぜた
idについて、「一意で、1つのIDには1行しかない」ことを、プランナーは使えていない
33万行も当たるなら、人気順に上から読めば、30件はすぐにそろうはずです。570,000行のうち、30 ÷ 330,600 の割合、つまり50行ほど読めば済む計算で、Limit のコストが835と小さく出ています。その賭けが、1件しか当たらない検索で外れました。
表が1つなら、id は主キーなので、プランナーは「1つのIDに1行」を知っています。だから、候補のIDから主キーで引くプランを選び、速いままでした。
直し方:= ANY(ARRAY(...))
IN を、= ANY (ARRAY(...)) に書き換えます。
SELECT p.id FROM catalog p
WHERE p.active AND p.id = ANY (ARRAY(
SELECT id FROM catalog WHERE search_text LIKE '%vgs-12345%'
UNION SELECT id FROM catalog WHERE search_text LIKE '%vgs12345%'
UNION SELECT id FROM catalog WHERE code = 'vgs12345'))
ORDER BY COALESCE(p.rank, 2147483647), p.released DESC, p.id
LIMIT 30;Limit (actual rows=1 loops=1)
InitPlan 1
-> Unique (actual rows=1 loops=1)
...(品番の候補を集める部分)
-> Sort (actual rows=1 loops=1)
-> Append (actual rows=1 loops=1)
-> Index Scan using items_pkey on items (actual rows=1 loops=1)
Index Cond: (id = ANY ((InitPlan 1).col1))
-> Index Scan using other_items_pkey on other_items (actual rows=0 loops=1)
Index Cond: (id = ANY ((InitPlan 1).col1))
Execution Time: 0.513 msARRAY(サブクエリ)は、先に1回だけ実行される(InitPlan)。結果は、IDの配列という1つの値になる- 外側から見ると、
id = ANY(配列)は、ただの条件。結合ではないので、結合の行数を見積もる必要がない - その条件は、UNION ALLのそれぞれの表に押し込まれ、各表の主キーで引く
意味は同じです。違うのは、プランナーに「結合として計画させるか」「先に値を作って、条件として渡すか」です。
自分の環境で再現する
PostgreSQL 17.11 と、標準の拡張 pg_trgm だけで再現できます。使い捨てのデータベースで実行してください。
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- 50万件と7万件の、同じ形の2つの表。500件に1件だけ、人気順位がある
CREATE TABLE items (id text PRIMARY KEY, code text NOT NULL, released date NOT NULL,
rank int, active boolean NOT NULL DEFAULT true, search_text text NOT NULL);
CREATE TABLE other_items (LIKE items INCLUDING ALL);
INSERT INTO items
SELECT 'p' || i, code, date '2010-01-01' + i % 6000, CASE WHEN i % 500 = 0 THEN i / 500 END, true,
code || ' title ' || i
FROM generate_series(1, 500000) i,
LATERAL (SELECT chr(97 + i % 26) || chr(97 + i / 26 % 26) || chr(97 + i / 676 % 26)
|| lpad((i % 99999)::text, 5, '0') AS code) c;
INSERT INTO other_items
SELECT 'o' || i, 'zz' || i, date '2015-01-01' + i % 4000, CASE WHEN i % 100 = 0 THEN 1000 + i / 100 END, true,
'zz' || i || ' other ' || i
FROM generate_series(1, 70000) i;
-- どちらの表にも、同じ索引を張る
CREATE INDEX ON items (code);
CREATE INDEX ON other_items (code);
CREATE INDEX ON items (COALESCE(rank, 2147483647), released DESC, id) WHERE active;
CREATE INDEX ON other_items (COALESCE(rank, 2147483647), released DESC, id) WHERE active;
CREATE INDEX ON items USING gin (search_text gin_trgm_ops);
CREATE INDEX ON other_items USING gin (search_text gin_trgm_ops);
-- 2つの表を、1つに見せる
CREATE VIEW catalog AS
SELECT id, code, released, rank, active, search_text FROM items
UNION ALL
SELECT id, code, released, rank, active, search_text FROM other_items;
VACUUM ANALYZE;そのあと、上の2つのクエリの先頭に EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF) を付けて実行します。IN のほうで Merge Append (actual rows=570000 が出れば、再現できています。
- 測った環境:PostgreSQL 17.11(Docker、Apple Silicon)。時間は環境で変わるが、読む行数の差は変わらない
- データの作り方や件数を変えると、プランナーが別のプランを選ぶこともある。たとえば、品番の候補を
code = '...'の1つだけにすると、INでも速いプランになった
UNION ALLの上で書くときの、ほかの注意
同じ理由で、UNION ALLの上では、プランナーが表ごとの事情を使いにくくなります。表の大きさも、当たる行の割合も、2つの表でまるで違うのに、1つの見積もりで計画することになるからです。
そこで、別のサイトで2つの表を混ぜて検索するときは、最初から次の形で書きました。
- 表ごとに、同じ条件、同じ並び、同じ
LIMITのクエリを書く:それぞれの表が、その表だけを相手にしたときのプラン(人気順の索引、ベクトルの索引など)を使える - 短い結果どうしを、外側で並べ直す:2つの表の結果は、多くても
LIMITの2倍なので、並べ直しは軽い
SELECT id FROM (
(SELECT id, rank, released FROM items p WHERE <条件> ORDER BY <並び> LIMIT 10)
UNION ALL
(SELECT id, rank, released FROM other_items p WHERE <条件> ORDER BY <並び> LIMIT 10)
) r
ORDER BY <並び> LIMIT 10;各表から10件ずつ取れば、合わせた上位10件は、必ずその中にあります。ただし、この形がいつも速いとは限りません。似た合成データで、条件の重い検索を比べたときは、UNION ALLのままでも表ごとでも1 ms前後で、大きな差は出ませんでした。効くかどうかは、EXPLAIN ANALYZE で確かめてください。
チェックリスト
- UNION ALLのビューや副問い合わせに、
IN (SELECT ...)で絞り込んでいないか -
EXPLAINの結合のrows=が、現実より桁違いに大きくないか(統計がない既定の0.005が使われていないか) -
ORDER BY ... LIMITの下に、Merge Appendで全行を読む索引走査がないか - 候補が少ないと分かっているなら、
= ANY (ARRAY(...))で、先に値を作って渡しているか - 表ごとに書いたほうが、それぞれの表の索引を使えないか
- 直したら、遅かった入力と、いつもの入力の両方で、
EXPLAIN ANALYZEを取り直したか