🐘

grouping sets機能の使い所とPostgreSQLにおける性能検証

2023/11/08に公開

はじめに

ログラスの龍島(りゅうしま)です。今回はSQLのちょっとニッチな機能の紹介と簡単な性能検証をしてみたいと思います。紹介する機能はgrouping setsです。主にPostgreSQLを念頭に話します。

この記事でわかること

  • grouping setsとはどういう機能なのか
  • どのような場面で使うのか
  • 性能面でどういった性質があるのか

grouping setsとは

SQL99で規定されているgroup byに関連する機能で、簡単に言うと「複数の組み合わせのgroup byを一度に実行できる」というものです。
PostgreSQLの公式ドキュメントとしては下記ですが、正直パッと理解しにくいと思いますので簡単な例を見てみましょう。
https://www.postgresql.jp/docs/15/queries-table-expressions.html#QUERIES-GROUPING-SETS

年月 大エリア 小エリア 商品 売上高
202310 北海道・東北 北海道 りんご 100
202310 北海道・東北 北海道 なし 200
202310 関東 東京 なし 500
202310 関東 千葉 なし 300
202311 北海道・東北 北海道 りんご 100
202311 北海道・東北 北海道 なし 300
202311 関東 東京 なし 400
202311 関東 千葉 なし 200

このデータに対して、年月ごとの全国、大エリア、小エリアの集計を出そうとするとどのようなクエリにすると良いでしょうか?つまり求めるアウトプットは下記のようなものです。

年月 大エリア 小エリア 合計売上高
202310 1100
202310 北海道・東北 300
202310 北海道・東北 北海道 300
202310 関東 800
202310 関東 千葉 300
202310 関東 東京 500
202311 1000
202311 北海道・東北 400
202311 北海道・東北 北海道 400
202311 関東 600
202311 関東 千葉 200
202311 関東 東京 400

「年月と大エリアと小エリア」(=小エリアの集計)、「年月と大エリア」(=大エリアの集計)、「年月」(=全国の集計)の3つの組み合わせでのgroup byが必要になります。単純にgroup byで表現すると下記のようになります。

-- 大エリア + 小エリアの集計
SELECT "年月", "大エリア", "小エリア", SUM("売上高") AS "合計売上高"
FROM sales_table
GROUP BY "年月", "大エリア", "小エリア"

UNION ALL

-- 大エリアの集計
SELECT "年月", "大エリア", NULL AS "小エリア", SUM("売上高") AS "合計売上高"
FROM sales_table
GROUP BY "年月", "大エリア"

-- 全国の集計
UNION ALL

SELECT "年月", NULL AS "大エリア", NULL AS "小エリア", SUM("売上高") AS "合計売上高"
FROM sales_table
GROUP BY "年月"

ORDER BY "年月", "大エリア", "小エリア" nulls first;

grouping setsを使うとこれは下記のように書き換えられます。

SELECT "年月", "大エリア", "小エリア", SUM("売上高") AS "合計売上高"
FROM sales_table
GROUP BY GROUPING SETS ( ("年月", "大エリア", "小エリア"),
                         ("年月", "大エリア"),
                         ("年月")
    )
ORDER BY "年月", "大エリア", "小エリア" NULLS FIRST;

かなりスッキリしましたね。このような複雑な集計処理を書けるのがgrouping setsです。

grouping関数

grouping setsを利用しようと各種ドキュメントを読んでいる際のつまづきポイントとして、grouping関数があります。少し解説しようと思います。

先程のデータを少し変更して、下記のようなデータだったとします。

年月 大エリア 小エリア 商品 売上高
202310 北海道 りんご 100
202310 北海道 なし 200
202310 関東 東京 なし 500
202310 関東 千葉 なし 300
202311 北海道 りんご 100
202311 北海道 なし 300
202311 関東 東京 なし 400
202311 関東 千葉 なし 200

北海道の階層が変更され、大エリアに北海道、小エリアは値を持たなくなりました。このデータに対して先程のSQLを実行すると下記のようになります。

年月 大エリア 小エリア 合計売上高
202310 北海道 300
202310 北海道 300
202310 関東 800
202310 関東 千葉 300
202310 関東 東京 500
202310 1100
202311 北海道 400
202311 北海道 400
202311 関東 600
202311 関東 千葉 200
202311 関東 東京 400
202311 1000

北海道の行が重複して表示されるようになりました。これは

  • 「年月、大エリア」で集約した際のレコード
  • 「年月、大エリア、小エリア」で集約した際の小エリアがnullのレコード

が両方出てしまっていることによるものです。これを区別するのにgrouping関数が利用できます。
小エリアに対してgrouping関数の結果も併せて出力してみます。

SELECT "年月",
       "大エリア",
       "小エリア",
       grouping("小エリア") as 小エリアgrouping,
       SUM("売上高")     AS "合計売上高"
FROM sales_table
GROUP BY GROUPING SETS ( ("年月", "大エリア", "小エリア"),
                         ("年月", "大エリア"),
                         ("年月")
    )
ORDER BY "年月", "大エリア", "小エリア" NULLS FIRST;
年月 大エリア 小エリア 小エリアgrouping 合計売上高
202310 北海道 0 300
202310 北海道 1 300
202310 関東 1 800
202310 関東 千葉 0 300
202310 関東 東京 0 500
202310 1 1100
202311 北海道 0 400
202311 北海道 1 400
202311 関東 1 600
202311 関東 千葉 0 200
202311 関東 東京 0 400
202311 1 1000

grouping関数は引数の式が集約対象の場合1が立ちます。つまり1が「年月、大エリア」で集約した際のレコード、0が「年月、大エリア、小エリア」で集約した際の小エリアがnullのレコードというわけです。
どちらか不要な場合はhaving句で除いておくと良いです。

SELECT "年月",
       "大エリア",
       "小エリア",
       grouping("小エリア") as 小エリアgrouping,
       SUM("売上高")     AS "合計売上高"
FROM sales_table
GROUP BY GROUPING SETS ( ("年月", "大エリア", "小エリア"),
                         ("年月", "大エリア"),
                         ("年月")
    )
having "小エリア" is not null or grouping("小エリア") = 1
ORDER BY "年月", "大エリア", "小エリア" NULLS FIRST;

使い所

複数のカラムにまたがった値を集計して同列で扱いたい場合に使うことが多いです。具体的には上記の例のエリア情報のように、階層構造がカラムで分かれて表現されているようなデータがイメージしやすいです。
Loglassにおいてもレポート機能においてまさに階層化されたデータを集計するために利用しています。
https://prtimes.jp/main/html/rd/p/000000073.000052025.html

grouping setsのパフォーマンス

grouping setsはgroup by + union allと比較してシンプルに記述できるだけでなく、パフォーマンス面でも有利と考えられます。なぜならgroup by + union allの手法はgroup byの回数分対象のデータセットをスキャンする必要があるのに対し、grouping setsでは一度のスキャンで済むためです。

実際に実行時間や実行計画を比較してみます。
実行したSQLや検証のスクリプトを記載していますが、結果のみ興味のある方は読み飛ばしていただければと思います。

データセットの用意

前例と似たような、年月とエリア(1~5)ごとに売上高を持っているテーブルで1,000万件のテストデータを作成します。

テストデータセット作成SQL
-- テストテーブルの作成
DROP TABLE IF EXISTS test_data;
-- テーブルを作成
CREATE TABLE test_data (
                           id serial PRIMARY KEY,
                           year_month INT,
                           area_1 INT,
                           area_2 INT,
                           area_3 INT,
                           area_4 INT,
                           area_5 INT,
                           revenue INT
);

-- ランダムなテストデータを挿入
INSERT INTO test_data (year_month, area_1, area_2, area_3, area_4, area_5, revenue)
SELECT
    floor(random() * (202312 - 202301 + 1) + 202301)::INT,  -- year_month
    floor(random() * 10 + 1)::INT,  -- area_1
    floor(random() * 10 + 1)::INT,  -- area_2
    floor(random() * 10 + 1)::INT,  -- area_3
    floor(random() * 10 + 1)::INT,  -- area_4
    floor(random() * 10 + 1)::INT,  -- area_5
    floor(random() * 1000 + 1)::INT  -- revenue
FROM generate_series(1, 10000000);
ANALYSE test_data;

union all + group byとgrouping setsの比較

検証環境PostgreSQL 15.2 on MacBookPro M1Proにて各SQLを10回実行し平均で比較してみます。

union all + group by SQL
-- year_month = 202310 と area_1 ~ area_5 で集計
SELECT area_1, area_2, area_3, area_4, area_5, SUM(revenue) AS total_revenue
FROM test_data
WHERE year_month = 202310
GROUP BY area_1, area_2, area_3, area_4, area_5

UNION ALL

-- 同様に area_1 ~ area_4 で集計
SELECT area_1, area_2, area_3, area_4, NULL, SUM(revenue) AS total_revenue
FROM test_data
WHERE year_month = 202310
GROUP BY area_1, area_2, area_3, area_4

UNION ALL

-- 同様に area_1 ~ area_3 で集計
SELECT area_1, area_2, area_3, NULL, NULL, SUM(revenue) AS total_revenue
FROM test_data
WHERE year_month = 202310
GROUP BY area_1, area_2, area_3

UNION ALL

-- 同様に area_1 ~ area_2 で集計
SELECT area_1, area_2, NULL, NULL, NULL, SUM(revenue) AS total_revenue
FROM test_data
WHERE year_month = 202310
GROUP BY area_1, area_2

UNION ALL

-- 同様に area_1 で集計
SELECT area_1, NULL, NULL, NULL, NULL, SUM(revenue) AS total_revenue
FROM test_data
WHERE year_month = 202310
GROUP BY area_1

ORDER BY area_1, area_2, area_3, area_4, area_5;
grouping sets SQL

SELECT area_1, area_2, area_3, area_4, area_5, SUM(revenue) AS total_revenue
FROM test_data
WHERE year_month = 202310
GROUP BY GROUPING SETS ( (area_1, area_2, area_3, area_4, area_5),
                         (area_1, area_2, area_3, area_4),
                         (area_1, area_2, area_3),
                         (area_1, area_2),
                         (area_1)
    )
ORDER BY area_1, area_2, area_3, area_4, area_5;
比較スクリプト
import psycopg2
import time

# PostgreSQLへの接続情報
conn = psycopg2.connect(
    dbname="your_db_name",
    user="your_user_name",
    password="your_password",
    host="your_host",
    port="your_port"
)

cur = conn.cursor()

# 処理時間を保存するリスト
union_all_times = []
grouping_sets_times = []

# UNION ALL クエリ
union_all_query = '''
-- year_month = 202310 と area_1 ~ area_5 で集計
SELECT area_1, area_2, area_3, area_4, area_5, SUM(revenue) AS total_revenue
FROM test_data
WHERE year_month = 202310
GROUP BY area_1, area_2, area_3, area_4, area_5

UNION ALL

-- 同様に area_1 ~ area_4 で集計
SELECT area_1, area_2, area_3, area_4, NULL, SUM(revenue) AS total_revenue
FROM test_data
WHERE year_month = 202310
GROUP BY area_1, area_2, area_3, area_4

UNION ALL

-- 同様に area_1 ~ area_3 で集計
SELECT area_1, area_2, area_3, NULL, NULL, SUM(revenue) AS total_revenue
FROM test_data
WHERE year_month = 202310
GROUP BY area_1, area_2, area_3

UNION ALL

-- 同様に area_1 ~ area_2 で集計
SELECT area_1, area_2, NULL, NULL, NULL, SUM(revenue) AS total_revenue
FROM test_data
WHERE year_month = 202310
GROUP BY area_1, area_2

UNION ALL

-- 同様に area_1 で集計
SELECT area_1, NULL, NULL, NULL, NULL, SUM(revenue) AS total_revenue
FROM test_data
WHERE year_month = 202310
GROUP BY area_1

ORDER BY area_1, area_2, area_3, area_4, area_5;
'''

# GROUPING SETS クエリ
grouping_sets_query = '''
SELECT area_1, area_2, area_3, area_4, area_5, SUM(revenue) AS total_revenue
FROM test_data
WHERE year_month = 202310
GROUP BY GROUPING SETS (
    (area_1, area_2, area_3, area_4, area_5),
    (area_1, area_2, area_3, area_4),
    (area_1, area_2, area_3),
    (area_1, area_2),
    (area_1)
    )
ORDER BY area_1, area_2, area_3, area_4, area_5;
'''

print("UNION ALL Query Times:")
for i in range(1, 11):
    # 予めバッファに乗せてキャッシュの影響を少なくする
    cur.execute("SELECT pg_prewarm('test_data');")

    # UNION ALL
    start_time = time.time()
    cur.execute(union_all_query)
    end_time = time.time()
    elapsed_time = end_time - start_time
    union_all_times.append(elapsed_time)
    print(f"  Run {i}: {elapsed_time}")

# 平均の計算
union_all_avg = sum(union_all_times) / len(union_all_times)
print(f"  Average Time: {union_all_avg}")

print("\nGROUPING SETS Query Times:")
for i in range(1, 11):
    # 予めバッファに乗せてキャッシュの影響を少なくする
    cur.execute("SELECT pg_prewarm('test_data');")

    # GROUPING SETS
    start_time = time.time()
    cur.execute(grouping_sets_query)
    end_time = time.time()
    elapsed_time = end_time - start_time
    grouping_sets_times.append(elapsed_time)
    print(f"  Run {i}: {elapsed_time}")

# 平均の計算
grouping_sets_avg = sum(grouping_sets_times) / len(grouping_sets_times)
print(f"  Average Time: {grouping_sets_avg}")

cur.close()
conn.close()

結果

UNION ALL Query Times:
  Run 1: 1.499704122543335
  Run 2: 1.4252417087554932
  Run 3: 1.4035181999206543
  Run 4: 1.4195802211761475
  Run 5: 1.4169158935546875
  Run 6: 1.4118247032165527
  Run 7: 1.4271800518035889
  Run 8: 1.4248430728912354
  Run 9: 1.4343249797821045
  Run 10: 1.4205358028411865
  Average Time: 1.4283668756484986

GROUPING SETS Query Times:
  Run 1: 1.2942430973052979
  Run 2: 1.2704789638519287
  Run 3: 1.2706820964813232
  Run 4: 1.2818210124969482
  Run 5: 1.3167130947113037
  Run 6: 1.2699871063232422
  Run 7: 1.2786409854888916
  Run 8: 1.2685649394989014
  Run 9: 1.2740168571472168
  Run 10: 1.2605938911437988
  Average Time: 1.2785742044448853

予想通りunion allが遅い結果となりましたが、平均で1.43s, 1.29sと自分の予想よりは大きな差となりませんでした。実行計画を見てもう少し深掘りしてみます。

union allの実行計画

pgMustardにて実行計画をビジュアライズしてみます。リンク先が見やすいです。
https://app.pgmustard.com/#/explore/74865ef1-54aa-45da-9d49-6bd5c8308dcd

全体としてクエリの見た目通り5回scan + aggregateを行いそれらをマージしている形になっています。
1度のscan + aggregateはばらつきがありますが、scanが337ms~836ms, aggregateが84ms~382ms程度です。各ノードの実行時間を足すと総実行時間を超えてしまいますが、GatherMergeなのでパラレルクエリで並列化されて実行されているためです。今回は3並列となっていました。

grouping setsの実行計画

https://app.pgmustard.com/#/explore/3c7ed2ae-70ca-47ee-b207-2313059ba7f7

シンプルに一度scan(444ms) + aggregate(845ms)となりました。scanはunion all方式の一回と同程度、aggregateはunion all方式の5回分を足した値(874ms)と同程度です。

上記からunion all方式はscanが多い分遅くなるが、並列実行することでその遅さを一定巻き返せていると言えます。
並列数を増やせればaggregate部分を並列化できる分union all方式が優勢となることも考えられますが、

  • 並列数は設定で上げることが可能だが、恩恵を受けられる上限はマシンのコア数に依存する
  • 並列化するためのオーバーヘッドも一定ある

ということから現実的にはgrouping setsの方が多くの場合で有利と考えられます。

scanにより時間がかかる場合の比較

集計処理を行う場合に複数のテーブルをjoinしてgroup byすることなどは一般によくあるケースだと思います。group byする対象のレコードを取得する処理により時間がかかる場合、上記結果を踏まえるとより差が開くことが予想されます。

一応検証してみます。今回は簡単のため、pg_sleep関数を使って擬似的にgroup by対象レコードの取得処理を遅延させてみます。データ取得ごとに1秒遅延します。

union allのSQL(集計対象取得遅延ver)
-- year_month = 202310 と area_1 ~ area_5 で集計
SELECT area_1, area_2, area_3, area_4, area_5, SUM(revenue) AS total_revenue
FROM test_data,
     (select pg_sleep(1)) t
WHERE year_month = 202310
GROUP BY area_1, area_2, area_3, area_4, area_5

UNION ALL

-- 同様に area_1 ~ area_4 で集計
SELECT area_1, area_2, area_3, area_4, NULL, SUM(revenue) AS total_revenue
FROM test_data,
     (select pg_sleep(1)) t
WHERE year_month = 202310
GROUP BY area_1, area_2, area_3, area_4

UNION ALL

-- 同様に area_1 ~ area_3 で集計
SELECT area_1, area_2, area_3, NULL, NULL, SUM(revenue) AS total_revenue
FROM test_data,
     (select pg_sleep(1)) t
WHERE year_month = 202310
GROUP BY area_1, area_2, area_3

UNION ALL

-- 同様に area_1 ~ area_2 で集計
SELECT area_1, area_2, NULL, NULL, NULL, SUM(revenue) AS total_revenue
FROM test_data,
     (select pg_sleep(1)) t
WHERE year_month = 202310
GROUP BY area_1, area_2

UNION ALL

-- 同様に area_1 で集計
SELECT area_1, NULL, NULL, NULL, NULL, SUM(revenue) AS total_revenue
FROM test_data,
     (select pg_sleep(1)) t
WHERE year_month = 202310
GROUP BY area_1

ORDER BY area_1, area_2, area_3, area_4, area_5;
grouping setsのSQL(集計対象取得遅延ver)
SELECT area_1, area_2, area_3, area_4, area_5, SUM(revenue) AS total_revenue
FROM test_data
   , (select pg_sleep(1)) t
WHERE year_month = 202310
GROUP BY GROUPING SETS ( (area_1, area_2, area_3, area_4, area_5),
                         (area_1, area_2, area_3, area_4),
                         (area_1, area_2, area_3),
                         (area_1, area_2),
                         (area_1)
    )
ORDER BY area_1, area_2, area_3, area_4, area_5;

結果

UNION ALL Query Times:
  Run 1: 8.361353874206543
  Run 2: 7.820831060409546
  Run 3: 7.780340909957886
  Run 4: 7.77163290977478
  Run 5: 7.79163122177124
  Run 6: 7.7224767208099365
  Run 7: 7.766075849533081
  Run 8: 7.764142036437988
  Run 9: 7.7846808433532715
  Run 10: 7.722808122634888
  Average Time: 7.828597354888916

GROUPING SETS Query Times:
  Run 1: 2.3444080352783203
  Run 2: 2.3388819694519043
  Run 3: 2.335810899734497
  Run 4: 2.391515016555786
  Run 5: 2.3471269607543945
  Run 6: 2.336261749267578
  Run 7: 2.3436548709869385
  Run 8: 2.334725856781006
  Run 9: 2.3339650630950928
  Run 10: 2.3504409790039062
  Average Time: 2.3456791400909425

差が開くことが確認できました。

検証まとめ

予想通りパフォーマンス面でもgrouping setsが有利ということがわかりました。union all方式は並列化できるため一定巻き返せてはいますが、並列化には上限があるため多くの場合でgrouping setsが有利です。
また結果から集計対象データの取得処理が重たい程grouping setsが有利になると言えます。また今回検証はしていませんが、union all方式はgroup byするパターンが増えるほど掛け算で計算量が増えるため、パターンが多い程grouping setsが有利であるとも言えそうです。

株式会社ログラス テックブログ

Discussion