Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails以外の開発一般

PostgreSQL: 「OR」を避けてパフォーマンスを向上させよう(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

※挿絵は原著者自らによるものです。

PostgreSQL: 「OR」を避けてパフォーマンスを向上させよう(翻訳)

生きるべきか『OR』死すべきか

生きるべきか『OR』死すべきか、それが問題だ
「帰れ!」「非効率!」「同義反復!」
© Laurenz Albe 2018
PostgreSQLクエリのチューニングは私たちCybertecの日常的な業務ですが、チューニング中にクエリにORを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で削除できるでしょう。ここで用いた例では、クエリ結果に主キーが含まれているのでこの点の心配は無用です。私の調べでは、この方法がこれまで現実に問題になったことはめったにありません。

関連記事

PostgreSQL 10の使って嬉しい5つの機能(翻訳)

データベースのランダム読み出しは要注意(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。