Redshiftのサブクエリで “*” を使っても良いか?

結論は『使っても良い』ですが、『使った方が良い』ではありません。
2021.02.28

こんにちは。データアナリティクス事業本部の松村です。

SQLにおいて、推奨されないことのひとつとして、SELECT文で*を使用することがよく挙げられます。
本来はアプリケーションの処理に必要のない項目まで取得してしまうと、メモリやネットワークの帯域を無駄に消費してしまうから、というのがその理由です。さらに、Redshiftのような列指向ストレージのデータベースであれば、ストレージからのI/O負荷も取得する列数に応じて増えてしまうので、なおさら好ましくありません。

でもサブクエリや共通テーブル式においてはどうでしょう。最終的にSELECTする列さえちゃんと必要最小限に絞り込んでおけば、サブクエリや共通テーブル式ではSELECT *と書いても、クエリプランナーが実際に必要とされる列にのみアクセスするように最適化してくれるのではないでしょうか。
結論から言うと、おそらくはその通りだと思われます。実際に検証してみましたので、見ていきましょう。

比較対象のSQL

サブクエリで*を使ったSQLと、そうでないSQLを比較します。イメージとしてはこんな感じになります。

-- サブクエリに * を使ったSQL
SELECT sub1.id, sub1.value1, sub2.value1
FROM (SELECT * FROM table1 WHERE id < 4) AS sub1
    INNER JOIN (SELECT * FROM table2 WHERE id < 4) AS sub2
        ON sub1.id = sub2.id;

-- サブクエリで予め列を絞ったSQL
SELECT sub1.id, sub1.value1, sub2.value1
FROM (SELECT id, value1 FROM table1 WHERE id < 4) AS sub1
    INNER JOIN (SELECT id, value1 FROM table2 WHERE id < 4) AS sub2
        ON sub1.id = sub2.id;

これらの2つのSQLは、最終的に得ようとしている結果は同じものです。
いずれも実際に結合や最終結果で必要な列は、table1table2それぞれのidvalue1だけです。サブクエリで*を使っているか、必要な列のみ取得しているかの違いがあります。
この2つのSQLの実行計画を比較して、実際にアクセスされる列に変化があるかどうかを調べます。

検証

では実際に上記の2つのSQLの実行計画を比較してみます。

準備

まずはテーブルとデータを用意します。

テーブルを作成します。idvalue1以外に、検証対象SQLの実行には実際に必要とされない列value2も定義しておきます。

CREATE TABLE table1 (
    id integer,
    value1 varchar(10),
    value2 varchar(15),
    PRIMARY KEY (id)
);

CREATE TABLE table2 (
    id integer,
    value1 varchar(10),
    value2 varchar(15),
    PRIMARY KEY (id)
);

値を投入します。

INSERT INTO table1
VALUES
    (1, 'aaaaaaaaaa', 'あああああ'),
    (2, 'bbbbbbbbbb', 'いいいいい'),
    (3, 'cccccccccc', 'ううううう'),
    (4, 'dddddddddd', 'えええええ'),
    (5, 'eeeeeeeeee', 'おおおおお');

INSERT INTO table2
VALUES
    (1, 'AAAAAAAAAA', 'アアアアア'),
    (2, 'BBBBBBBBBB', 'イイイイイ'),
    (3, 'CCCCCCCCCC', 'ウウウウウ'),
    (4, 'DDDDDDDDDD', 'エエエエエ'),
    (5, 'EEEEEEEEEE', 'オオオオオ');

仕上げに統計情報を更新しておきます。

ANALYZE table1;
ANALYZE table2;

実行計画の比較

では実際に実行計画を取得してみましょう。まずはサブクエリに*を使ったSQLからです。

EXPLAIN
SELECT sub1.id, sub1.value1, sub2.value1
FROM (SELECT * FROM table1 WHERE id < 4) AS sub1
    INNER JOIN (SELECT * FROM table2 WHERE id < 4) AS sub2
        ON sub1.id = sub2.id;

                              QUERY PLAN                              
----------------------------------------------------------------------
 XN Hash Join DS_DIST_ALL_NONE  (cost=0.07..0.22 rows=4 width=32)
   Hash Cond: ("outer".id = "inner".id)
   ->  XN Seq Scan on table1  (cost=0.00..0.06 rows=4 width=18)
         Filter: (id < 4)
   ->  XN Hash  (cost=0.06..0.06 rows=4 width=18)
         ->  XN Seq Scan on table2  (cost=0.00..0.06 rows=4 width=18)
               Filter: (id < 4)
(7 rows)

実際にアクセスする列については、EXPLAIN VERBOSE ...を実行すればそれっぽいものを確認することができるのですが、この実行結果については詳しいリファレンスが存在しませんので、VERBOSEなしの結果を見ることにします。
11行目、14行目にそれぞれwidth=18と出力されていますが、これがそれぞれのサブクエリにおける、平均的な行の長さを表しています。ここではどちらも18バイトです。

サブクエリで予め列を絞ったSQLの実行計画も取得します。

EXPLAIN
SELECT sub1.id, sub1.value1, sub2.value1
FROM (SELECT id, value1 FROM table1 WHERE id < 4) AS sub1
    INNER JOIN (SELECT id, value1 FROM table2 WHERE id < 4) AS sub2
        ON sub1.id = sub2.id;

                              QUERY PLAN                              
----------------------------------------------------------------------
 XN Hash Join DS_DIST_ALL_NONE  (cost=0.07..0.22 rows=4 width=32)
   Hash Cond: ("outer".id = "inner".id)
   ->  XN Seq Scan on table1  (cost=0.00..0.06 rows=4 width=18)
         Filter: (id < 4)
   ->  XN Hash  (cost=0.06..0.06 rows=4 width=18)
         ->  XN Seq Scan on table2  (cost=0.00..0.06 rows=4 width=18)
               Filter: (id < 4)
(7 rows)

11行目、14行目にwidth=18と出力されており、これはサブクエリに*を使ったSQLと同じです。このことから、どちらのSQLも実際にアクセスする列数には変わりがないと想像できます。
それどころか、実行計画全体も両方のSQLで全く同じです。

実行計画の比較(共通テーブル式の場合)

共通テーブル式を使った場合の結果も見てみましょう。まずは*を使ったSQLからです。

EXPLAIN
WITH
sub1 AS (
    SELECT * FROM table1 WHERE id < 4
),
sub2 AS (
    SELECT * FROM table2 WHERE id < 4
)
SELECT sub1.id, sub1.value1, sub2.value1
FROM sub1
    INNER JOIN sub2 ON sub1.id = sub2.id;

                              QUERY PLAN                              
----------------------------------------------------------------------
 XN Hash Join DS_DIST_ALL_NONE  (cost=0.07..0.22 rows=4 width=32)
   Hash Cond: ("outer".id = "inner".id)
   ->  XN Seq Scan on table1  (cost=0.00..0.06 rows=4 width=18)
         Filter: (id < 4)
   ->  XN Hash  (cost=0.06..0.06 rows=4 width=18)
         ->  XN Seq Scan on table2  (cost=0.00..0.06 rows=4 width=18)
               Filter: (id < 4)
(7 rows)

次にサブクエリで予め列を絞ったSQLの実行計画です。

EXPLAIN
WITH
sub1 AS (
    SELECT id, value1 FROM table1 WHERE id < 4
),
sub2 AS (
    SELECT id, value1 FROM table2 WHERE id < 4
)
SELECT sub1.id, sub1.value1, sub2.value1
FROM sub1
    INNER JOIN sub2 ON sub1.id = sub2.id;

                              QUERY PLAN                              
----------------------------------------------------------------------
 XN Hash Join DS_DIST_ALL_NONE  (cost=0.07..0.22 rows=4 width=32)
   Hash Cond: ("outer".id = "inner".id)
   ->  XN Seq Scan on table1  (cost=0.00..0.06 rows=4 width=18)
         Filter: (id < 4)
   ->  XN Hash  (cost=0.06..0.06 rows=4 width=18)
         ->  XN Seq Scan on table2  (cost=0.00..0.06 rows=4 width=18)
               Filter: (id < 4)
(7 rows)

やはり実行計画には変わりがありません。

まとめ

このとおり、サブクエリや共通テーブル式においては、SELECTで*を指定したとしても、性能への影響はないであろうことがわかりました。

それではこの結果をもって、サブクエリや共通テーブル式ではSELECT *をガンガン使っていくべきかというと、実は個人的にはそうは思いません。
既にSELECT *がコーディング規約で例外なく禁じられていたり、もしくはいかなる場合であっても使うべきでないという認識がチーム内で大半を占めていたりするのでしたら、一律それを覆す労力に見合うほどのメリットはないと思います。SELECT *を使っても性能が変わらないだけであり、改善するわけではないからです。
ただ、サブクエリや共通テーブル式にまで律儀に列を列挙したらあまりに多くなりすぎて、記述やメンテナンスの負荷が上がってしまうというのでしたら、このブログや実行計画などのちゃんとした根拠をコメント等に記した上で、*の使用を検討しても良いでしょう。