概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: avoid OR for better PostgreSQL query performance - Cybertec
- 原文公開日: 2018/05/07
- 著者: Laurenz Albe
- サイト: CYBERTEC — データサイエンス分野でのPostgreSQLサポートやコンサルティングを行っている企業です
※挿絵は原著者自らによるものです。
PostgreSQL: 「OR」を避けてパフォーマンスを向上させよう(翻訳)
「帰れ!」「非効率!」「同義反復!」
© Laurenz Albe 2018OR
を1つでも見つけた瞬間、恐ろしさに身の毛もよだつ思いがします。たいていの場合、OR
はクエリのパフォーマンス低下の原因となるからです。
言うまでもないことですが、OR
は理由があってSQLに存在しています。OR
を使わずにやる方法がどうしてもなければ、やはり使わざるを得ません。しかし、OR
がパフォーマンスに与える悪影響については知っておくべきです。
本記事では「いいOR
」と「だめなOR
」、そして後者のデメリットを避ける方法について解説します。
小さなスキーマのサンプル
以下のシンプルなセットアップをデモに使います。
CREATE TABLE a(id integer NOT NULL, a_val text NOT NULL);
INSERT INTO a
SELECT i, md5(i::text)
FROM generate_series(1, 100000) i;
CREATE TABLE b(id integer NOT NULL, b_val text NOT NULL);
INSERT INTO b
SELECT i, md5(i::text)
FROM generate_series(1, 100000) i;
ALTER TABLE a ADD PRIMARY KEY (id);
ALTER TABLE b ADD PRIMARY KEY (id);
ALTER TABLE b ADD FOREIGN KEY (id) REFERENCES a;
VACUUM (ANALYZE) a;
VACUUM (ANALYZE) b;
等価条件やLIKE
条件を持つクエリをtext
カラムで実行したくないので、インデックスが少々必要になります。
CREATE INDEX a_val_idx ON a(a_val text_pattern_ops);
CREATE INDEX b_val_idx ON b(b_val text_pattern_ops);
text_pattern_ops
がわからない場合は公式ドキュメントをご覧ください。
「よい」OR
OR
はSQLクエリのほとんどにおいて問題を生じません。クエリ結果から行をフィルタで除外するのに使わなければ、クエリのパフォーマンスに負の影響を生じることはありません。
つまり、SELECT
リスト内のCASE
式に登場するOR
は心配無用です。
残念なことに、パフォーマンスに問題を生じる箇所でOR
を見かけることもしばしばです。その場所とはWHERE
句です。
「ダメな」OR
以下はWHERE
句でOR
が使われている例のひとつですが、これはまだましな方です。
EXPLAIN (COSTS off)
SELECT id FROM a
WHERE id = 42
OR a_val = 'value 42';
QUERY PLAN
-----------------------------------------------------------
Bitmap Heap Scan on a
Recheck Cond: ((id = 42) OR (a_val = 'value 42'::text))
-> BitmapOr
-> Bitmap Index Scan on a_pkey
Index Cond: (id = 42)
-> Bitmap Index Scan on a_val_idx
Index Cond: (a_val = 'value 42'::text)
(7 rows)
PostgreSQLでは、2つのインデックスのbitmapを「bitmap OR」で結合できるので、クエリで実際にインデックススキャンを用いることができます。
ただし、bitmapインデックススキャンではbitmapをビルドしなければならなくなるため、通常のインデックススキャンよりコストが上昇する点にご注意ください。しかもRAMの使用量も増大します。それらのbitmapごとにwork_mem
のメモリ容量まで使われる可能性があります。
(id, a_val)
にマルチカラムインデックス(複数列インデックス)を用いても、このクエリでは何の効果も生じないので、実行コストをこれ以上下げる方法は存在しません。
OR
よりもIN
がよい
以下は、上のクエリをもっとバカバカしくした例です。
EXPLAIN (COSTS off)
SELECT id FROM a
WHERE id = 42
OR id = 4711;
QUERY PLAN
--------------------------------------------
Bitmap Heap Scan on a
Recheck Cond: ((id = 42) OR (id = 4711))
-> BitmapOr
-> Bitmap Index Scan on a_pkey
Index Cond: (id = 42)
-> Bitmap Index Scan on a_pkey
Index Cond: (id = 4711)
(7 rows)
ここでもbitmapインデックススキャンが使われています。しかし、この忌々しいOR
をまったく使わなくても、あるシンプルな方法でクエリを書き換えられます。
EXPLAIN (COSTS off)
SELECT id FROM a
WHERE id IN (42, 4711);
QUERY PLAN
---------------------------------------------------
Index Only Scan using a_pkey on a
Index Cond: (id = ANY ('{42,4711}'::integer[]))
(2 rows)
どこが変わったかおわかりでしょうか?OR
を取り除くと、たちまち高効率なインデックススキャンが使われるようになりました!
等価条件ならこの方法が向いているとお考えかもしれません。では以下のクエリはどうすればよいでしょう?
SELECT id FROM a
WHERE a_val LIKE 'something%'
OR a_val LIKE 'other%';
上のクエリを改善するために、PostgreSQLのオプティマイザが上のクエリのIN
を= ANY
に書き換えていることにご注目ください。
これは標準SQLにおける「quantified comparison predicate(量化比較述語)」と呼ばれるもので、比較演算子 ANY
の右側の値のいずれかとの比較がTRUE
になるとtrueになります(標準SQLでは右側の値としてサブクエリのみを定義していますが、PostgreSQLではこの構文をarrayに拡張しています)。
LIKE
も比較演算子なので、以下のように書き換えられます。
EXPLAIN (COSTS off)
SELECT id FROM a
WHERE a_val LIKE ANY (ARRAY['something%', 'other%']);
QUERY PLAN
----------------------------------------------------------
Seq Scan on a
Filter: (a_val ~~ ANY ('{something%,other%}'::text[]))
(2 rows)
残念ながら、この方法ではインデックスを使えません。
pg_trgm
で解決する
しかし、PostgreSQLのインデックスなら他にも手段があります。早速試してみましょう。そのためにはpg_trgm
というPostgreSQL拡張が必要です。
CREATE EXTENSION pg_trgm;
これで、このカラムにGINトライグラムインデックスを作成できるようになります。
CREATE INDEX a_val_trgm_idx ON a USING gin (a_val gin_trgm_ops);
クエリがいい感じになりました。
EXPLAIN (COSTS off)
SELECT id FROM a
WHERE a_val LIKE ANY (ARRAY['something%', 'other%']);
QUERY PLAN
--------------------------------------------------------------------
Bitmap Heap Scan on a
Recheck Cond: (a_val ~~ ANY ('{something%,other%}'::text[]))
-> Bitmap Index Scan on a_val_trgm_idx
Index Cond: (a_val ~~ ANY ('{something%,other%}'::text[]))
(4 rows)
トライグラムインデックスの威力がわかりますね。
- 注1: このインデックスは、
%
で始まる検索パターンでも利用できます。 -
注2: GINインデックスのサイズはかなり大きくなります。容量のずっと小さいGISTインデックスを使ってこれを避けることもできますが、検索の効率は落ちます。
「見苦しい」OR
以下のようにOR
が別テーブルの条件と組み合わさると、実に残念なことになります。
EXPLAIN (COSTS off)
SELECT id, a.a_val, b.b_val
FROM a JOIN b USING (id)
WHERE a.id = 42
OR b.id = 42;
QUERY PLAN
---------------------------------------------
Merge Join
Merge Cond: (a.id = b.id)
Join Filter: ((a.id = 42) OR (b.id = 42))
-> Index Scan using a_pkey on a
-> Index Scan using b_pkey on b
(5 rows)
これでは、2つのテーブルのJOINの計算を完了した後で、条件にマッチする行をすべて除外しなければならなくなります。この例で言うと、100,000行をわざわざ計算しておきながら、条件にマッチしない99,999行を捨てることになります。
「見苦しい」OR
を回避する
ありがたいことに、これと同等なクエリが存在します。少々長くなる代わりに、実行のコストはずっと小さくなります。
EXPLAIN (COSTS off)
SELECT id, a.a_val, b.b_val
FROM a JOIN b USING (id)
WHERE a.id = 42
UNION
SELECT id, a.a_val, b.b_val
FROM a JOIN b USING (id)
WHERE b.id = 42;
QUERY PLAN
----------------------------------------------------------
Unique
-> Sort
Sort Key: a.id, a.a_val, b.b_val
-> Append
-> Nested Loop
-> Index Scan using a_pkey on a
Index Cond: (id = 42)
-> Index Scan using b_pkey on b
Index Cond: (id = 42)
-> Nested Loop
-> Index Scan using a_pkey on a a_1
Index Cond: (id = 42)
-> Index Scan using b_pkey on b b_1
Index Cond: (id = 42)
(14 rows)
このクエリは2つの部分からできていますが、その両方で高効率のインデックススキャンを利用してそれぞれが1行を返します。さらに、たまたまどちらの結果行も同じなので、UNION
で1つの行に減らされます。
クエリの2つの部分が異なる集合を返すのであれば、UNION
ではなくUNION ALL
を使う方がよい結果になります(後者は重複を除去するための余分な処理が不要です)。
この手法でクエリを書き換える場合、書き換え後のクエリが必ずしも同等になるとは限らないという点を理解しておくべきです。元のクエリが返す行が同一の場合は、UNION
で削除できるでしょう。ここで用いた例では、クエリ結果に主キーが含まれているのでこの点の心配は無用です。私の調べでは、この方法がこれまで現実に問題になったことはめったにありません。