DuckDBのクエリプロファイルなどを確認する

本記事では、DuckDB を使ってオブジェクトストレージ上の Parquet ファイルに対してクエリを実行し、クエリプロファイルや I/O トレース情報などを確認します。

結果から先に述べておくと、

- １テーブルスキャンの単純なクエリではプルーニング（絞り込み時に不要なデータの読み込みを回避）が効いている。
- スタースキーマで良く行われる Join Filtering や Aggregation Pushdown などの最適化処理が効いていることを確認することはできなかった。

# 1. はじめに

## 1-1. DuckDB とは

DuckDB は組み込み型の SQL OLAP データベースです。

SQL OLAP データベースは Snowflake や Amazon Redshift のように大量データを SQL 文により分析するための DB を指します。DuckDB もその点は Snowflake などと同じなのですが、加えて組み込みデータベース SQLite のようにアプリケーションと同一プロセス内でデータを処理するのが特徴になっています。組み込みという特徴からデータ自体は自身では保持せず、外部の Parquet ファイルなどに対して読み書きするのがメインのユースケースだと思います。

## 1-2. 今回の作業

最近、Twitter で DuckDB を使っているという話をチラホラ見かけて興味を持ったので、ちょっと試してみます。

DB を理解するにはアーキテクチャを押さえた上で実際にどう処理が行われるか確認するのが手っ取り早いと私は考えています。そこで、今回は以下の画像のようなスタースキーマのデータ（オブジェクトストレージ上の Parquet ファイル）に対してクエリを実行し、

- クエリプロファイル（実行計画 ＋ 実行統計）
- I/O トレース

といった情報を取得し、確認してみたいと思います。

![スタースキーマ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/388675/736b5465-dad3-a129-1184-2c8c120824eb.png)

# 2. 準備

## 2-1. 環境

今回は以下の環境で試します。

- DuckDB CLI v0.8.2-dev2509
- オブジェクトストレージ MinIO version 2023-07-21T21:12:44Z

（ともに Oracle Linux 8.8 (x86_64) をインストールした VirtualBox 仮想マシン上で動作させています）

DuckDB のリリース版（8/5時点では 0.8.1 ）ではなく 開発版を利用しているのは、Parquet ファイルを読み込む際に、データ型を明示的に指定できる機能が 0.8.1 にはまだ含まれていなかったためです。

また、オブジェクトストレージというと通常は Amazon S3 などのパブリッククラウドのサービスを利用しますが、オブジェクトストレージへの I/O リクエストの情報を取得したかったため、今回は MinIO を利用しています。（当初は Amazon S3 のアクセスログで同様の取得を取得しようとしたのですが、リクエスト実行からログが出力されるまで時間がかなり掛かるため、今回は S3 の利用は諦めました）

## 2-2. データ作成

売上ファクトのデータを作成することがメインになるのですが、今回は 3 か月分のデータを 1 か月ごとにパーティション分割した Parquet ファイルを準備します。また、1パーティションファイル内には 5 つの row group（独立して圧縮＆エンコードが行われるレコードの集合） を持つように設定しています。ファイルの構造は以下になります。

![ファイル構造.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/388675/5cae12de-ec99-c7cf-6b79-371aec49cf2a.png)

Parquet ファイルの生成は Python のライブラリ PyArrow で行いました。以下にコードを示しますが、読み飛ばしても問題ありません。

まずは売上ファクトのデータ。

```python
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
import random
import os
import shutil

# item_key * shop_keyの組み合わせのPyArrowテーブルデータを生成
def make_pyarrow_table(item_key_start, item_key_end, shop_key_start, shop_key_end):
    item_cnt = item_key_end - item_key_start + 1
    shop_cnt = shop_key_end - shop_key_start + 1
    
    df = pd.DataFrame(
    {
        'item_key': list(range(item_key_start, item_key_end + 1)) * shop_cnt,
        'shop_key': [shop_key_start + i // item_cnt for i in range(item_cnt * shop_cnt)],
        'sales_quantity': [random.randint(1, 10) for i in range(item_cnt * shop_cnt)],
        'sales_amount': [random.randint(100, 1000) for i in range(item_cnt * shop_cnt)]
    })
    
    return pa.Table.from_pandas(df)

# 後にParquetWriterに渡すスキーマ情報を取得するダミーテーブル
dummy_table = make_pyarrow_table(1, 1, 1, 1)

# item_keyは10000001～11000000の100万件
item_key_start = 10000001
item_key_end   = 11000000

# shop_keyは1001～1050の50件
shop_key_start = 1001
shop_key_end   = 1050

# time_keyは101, 102, 103の3件
time_keys = [101, 102, 103]

# 1ファイル内に5つ行グループを作成
row_group_cnt = 5

shop_key_cnt = shop_key_end - shop_key_start + 1

shutil.rmtree('f_sales', ignore_errors = True)

for time_key in time_keys:
    file_dir = 'f_sales/time_key={}/'.format(time_key)
    file_path = file_dir + '/{}.parquet'.format(time_key)
    os.makedirs(file_dir, exist_ok=True)
    
    with pq.ParquetWriter(file_path, dummy_table.schema) as writer:
        for row_group_no in range(row_group_cnt):
            v_shop_key_start = shop_key_start + shop_key_cnt // row_group_cnt * row_group_no
            v_shop_key_end   = v_shop_key_start + shop_key_cnt // row_group_cnt - 1
            
            table = make_pyarrow_table(item_key_start, item_key_end, v_shop_key_start, v_shop_key_end)
            writer.write_table(table)
```

次に、時間次元、商品次元、店舗次元のデータを以下のように作成しています（こちらは 1 テーブル = 1 ファイルです）。

```python
# 時間軸は2023/4～2023/6の３か月分
d_time = pd.DataFrame({
    'time_key': [101, 102, 103],
    'ym': ['202304', '202305', '202306']
})

pq.write_table(pa.Table.from_pandas(d_time), 'd_time.parquet')

# 50件分の店舗データ（1地域10店舗）
shop_cnt_per_region = 10

d_shop = pd.DataFrame({
    'shop_key': list(range(shop_key_start, shop_key_end + 1)),
    'shop_name': ['shop{:0>4}'.format(i) for i in range(shop_key_start, shop_key_end + 1)],
    'region_name': ['region{:0>4}'.format((i - 1) // shop_cnt_per_region + 1) for i in range(shop_key_start, shop_key_end + 1)]
})

pq.write_table(pa.Table.from_pandas(d_shop), 'd_shop.parquet')

# 100万件分の商品データ（1カテゴリ1万商品）
item_cnt_per_category = 10000

d_item = pd.DataFrame({
    'item_key': list(range(item_key_start, item_key_end + 1)),
    'item_name': ['item{:0>8}'.format(i) for i in range(item_key_start, item_key_end + 1)],
    'item_category_name': ['category{:0>4}'.format((i - 1) // item_cnt_per_category + 1) for i in range(item_key_start, item_key_end + 1)]
})

pq.write_table(pa.Table.from_pandas(d_item), 'd_item.parquet')
```

これらを作成したデータを MinIO の ```sales_history``` というバケットにアップロードします。

## 2-3. Parquetファイルのメタデータ確認

DuckDB から Parquet ファイルに対してクエリを行う前に、Parquet ファイルの内部構造（メタデータ）を少し確認します。

まず、以下のように生成した売上ファクトのファイルのうちの１つ ```102.parquet``` のメタデータを取得します。

```python
metadata = pq.read_metadata('<ファイルパス>/f_sales/time_key=102/102.parquet')
```

取得したメタデータの中身は以下になっています。

```
<pyarrow._parquet.FileMetaData object at 0x7fbb3ada2ca0>
  created_by: parquet-cpp-arrow version 12.0.0
  num_columns: 4
  num_rows: 50000000
  num_row_groups: 5
  format_version: 2.6
  serialized_size: 5007
```

以下のことが分かります。

- カラム数は４（パーティションキー time_key は含まれない）
- レコードは 5,000 万件
- row group は 5 つ

１番目の row group の中身をカラムごとに見てみます。

```python
row_group = metadata.row_group(0)
for i in range(row_group.num_columns):
    column_name = row_group.column(i).path_in_schema
    min = row_group.column(i).statistics.min
    max = row_group.column(i).statistics.max
    size = row_group.column(i).total_compressed_size
    
    print('{} = {} - {} ({}B)'.format(column_name, min, max, size))
```

結果は以下のようになります。

```
item_key = 10000001 - 11000000 (40307130B)
shop_key = 1001 - 1010 (503B)
sales_quantity = 1 - 10 (5020629B)
sales_amount = 100 - 1000 (12525182B)
```

対象の row group に含まれるそれぞれのカラムの最小値/最大値とサイズが確認できます。特にサイズは以下になります。（当然と言えば当然ですが、```shop_key``` の圧縮率スゴイ。）

- item_key：38.4 MB
- shop_key：503 B
- sales_quantity：4.8 MB
- sales_amount：11.9 MB

## 2-4. DuckDBからMinIOに接続するための準備

DuckDB から MinIO に接続するためには、MinIO 起動後に以下のコマンドを実行します。1 行目は一度実行すれば再度接続後に実行する必要はありません（その他の設定は DuckDB 接続のたび必要）。S3 ではなく今回用いる MinIO を利用するためには最後 2 行の設定が必要になります（SSL を利用しない ＆ バケット名をホスト名ではなくパスに含める）。

```
INSTALL httpfs;
LOAD httpfs;
SET s3_access_key_id='<MinIOのアクセスキー>';
SET s3_secret_access_key='<MinIOのシークレットキー>';
SET s3_endpoint='<MinIOサーバーのホスト名>:9000';
SET s3_use_ssl=false;
SET s3_url_style='path';
```

これで DuckDB から MinIO 上のファイルにアクセスできるのですが、クエリの記述を簡潔にするために Parquet ファイルにアクセスするためのビューを作成しておきます。売上ファクトのビュー(```f_sales```)で```hive_types```というオプションで```time_key```カラムのデータ型を```int64```に指定していますが、これはデフォルトではパーティションキーのデータ型は```varchar```と自動認識されることを回避するためです（この型指定の機能は 8/5 時点で DuckDB のリリース版 0.8.1 には含まれておらず、開発版にしか存在しない）。

```sql
create view f_sales
as
select
	time_key,
	item_key,
	shop_key,
	sales_quantity,
	sales_amount
from
	read_parquet(
		's3://sales-history/f_sales/*/*.parquet',
		hive_types = {'time_key': int64}
	)
;

create view d_time
as
select
	time_key,
	ym
from
	read_parquet('s3://sales-history/d_time.parquet')
;

create view d_item
as
select
	item_key,
	item_name,
	item_category_name
from
	read_parquet('s3://sales-history/d_item.parquet')
;

create view d_shop
as
select
	shop_key,
	shop_name,
	region_name
from
	read_parquet('s3://sales-history/d_shop.parquet')
;
```

 
## 2-5. MinIOのトレースログ取得

DuckDB は、I/O 処理 = オブジェクトストレージへのリクエストに関して HEAD/GET などのメソッドごとの処理回数はクエリプロファイルに含まれているのですが、以下の情報は含まれていません。

- どのファイルにアクセスしたか
- 読み込んだデータのサイズがいくつか

そのため、MinIO 側でトレースログを取得してそれらの情報を取得します。

MinIOのトレースログを取得するためには MinIO Client コマンド```mc```を利用します。

まず、前段階として接続先情報(```myminio```)を以下で登録します。

```
$ mc alias set myminio/ http://<MinIOサーバーのホスト名>:9000 <MinIOのアクセスキー> <MinIOのシークレットキー>
```

その後にコマンドを実行すると、トレースログを取得し始めます。取得を終了するには Ctrl + C で終了します。

```
$ mc admin trace myminio/
```

以上で準備は完了です。

# 3. クエリ実行とプロファイル確認

Parquet ファイルにクエリを実行し、プロファイル情報と I/O トレース情報を取得していきます。まず売上ファクト単独のクエリを確認した後に、今回の確認の中心であるスタースキーマへのクエリを確認していきます。

## 3-1. ファクトテーブルのフルスキャンなクエリ

まず売上ファクトのみ１テーブルをフルスキャンする簡単なクエリでクエリプロファイルと I/O トレースを取得してみます。

以下のクエリを実行します。

```sql
explain analyze
select
	count(distinct time_key),
	count(distinct item_key),
	count(distinct shop_key),
	sum(sales_quantity),
	sum(sales_amount)
from
	f_sales
;
```

このクエリのクエリプロファイルは以下になります。1.5 億件を読み込んでいることが分かります。

![プラン.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/388675/23fc2041-02e6-39c7-1f76-110da7d26b0d.png)

その際の MinIO のトレースログは以下になります。ちょっと読み辛いですが、ログ 1 行の一番右端の数字（右スクロールしてみてください）がダウンロードしたサイズを表します。55 MiB（2-3. で確認した row group のカラムごとのサイズの合計値と一致）をダウンロードしているリクエストが各ファイルごとに 5 回ずつ、合計 15 回実行されています。おそらく1 つの row group ごとに 1 リクエストでデータを取得していると思われます。（ダウンロードサイズが小さいリクエストはメタデータ取得）

（見やすさのため、タイムスタンプを削除し、IPアドレスはマスキングしています）

```
[200 OK] s3.ListObjectsV2 ***:9000/sales-history/?encoding-type=url&list-type=2&prefix=f_sales%2F  ***    987µs       ⇣  975.665µs  ↑ 84 B ↓ 983 B
[200 OK] s3.HeadObject ***:9000/sales-history/f_sales/time_key%3D101/101.parquet ***    395µs       ⇣  0s         ↑ 84 B ↓ 0 B
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D101/101.parquet ***    10.777ms     ⇣  10.614791ms  ↑ 90 B ↓ 8 B
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D101/101.parquet ***    5.01ms       ⇣  4.896004ms  ↑ 90 B ↓ 4.9 KiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D101/101.parquet ***    806.297ms    ⇣  63.180821ms  ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D101/101.parquet ***    1.295488s    ⇣  48.460449ms  ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D101/101.parquet ***    600.703ms    ⇣  36.317504ms  ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D101/101.parquet ***    608.366ms    ⇣  33.97751ms  ↑ 90 B ↓ 55 MiB
[200 OK] s3.HeadObject ***:9000/sales-history/f_sales/time_key%3D102/102.parquet ***    1.379ms      ⇣  0s         ↑ 84 B ↓ 0 B
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D101/101.parquet ***    639.045ms    ⇣  64.231088ms  ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D102/102.parquet ***    41.162ms     ⇣  41.072749ms  ↑ 90 B ↓ 8 B
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D102/102.parquet ***    5.681ms      ⇣  5.562279ms  ↑ 90 B ↓ 4.9 KiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D102/102.parquet ***    830.395ms    ⇣  41.424699ms  ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D102/102.parquet ***    619.059ms    ⇣  26.160936ms  ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D102/102.parquet ***    567.947ms    ⇣  27.412878ms  ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D102/102.parquet ***    703.466ms    ⇣  92.558326ms  ↑ 90 B ↓ 55 MiB
[200 OK] s3.HeadObject ***:9000/sales-history/f_sales/time_key%3D103/103.parquet ***    421µs       ⇣  0s         ↑ 84 B ↓ 0 B
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D103/103.parquet ***    126.196ms    ⇣  126.093194ms   ↑ 90 B ↓ 8 B
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D102/102.parquet ***    630.89ms     ⇣  26.44223ms  ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D103/103.parquet ***    5.431ms      ⇣  5.379735ms  ↑ 90 B ↓ 4.9 KiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D103/103.parquet ***    565.501ms    ⇣  20.088476ms  ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D103/103.parquet ***    588.359ms    ⇣  33.784412ms  ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D103/103.parquet ***    559.15ms     ⇣  33.120283ms  ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D103/103.parquet ***    581.735ms    ⇣  43.858821ms  ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D103/103.parquet ***    489.962ms    ⇣  27.358215ms  ↑ 90 B ↓ 55 MiB
```

## 3-2. ファクトテーブルを絞り込むクエリ

次に以下のクエリを実行します。意図としては ```102.parquet``` の 1 番目の row group のみにデータを絞り込むクエリです。

```sql
explain analyze
select
	count(distinct time_key),
	count(distinct item_key),
	count(distinct shop_key),
	sum(sales_quantity),
	sum(sales_amount)
from
	f_sales
where
	time_key = 102
and
	shop_key between 1001 and 1010
;
```

クエリプロファイルは以下になります。1,000 万件に絞り込まれているのが分かります。

![プラン.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/388675/6a0d2d92-b7aa-97df-2df0-f531d36142b3.png)

この際の I/O トレースは以下になります。以下のことが分かります。

- ```102.parquet``` からのみデータを読み込んでいる（```101.parquet``` に 2 回リクエストが出ていますが、これはメタデータ取得）
- サイズからも row group 1 つのみ読み込んでいる

最後の 4 行がレコードデータを取得するクエリですが、2-3. に確認した row group 内の各カラムのサイズと一致していることから、これらが 4 カラム分のデータの取得をしていると思われます。3-1. では1リクエストで row group を 1 つ取得していましたが、今回はカラムごとに 1 リクエストなんですね。

```
[200 OK] s3.ListObjectsV2 ***:9000/sales-history/?encoding-type=url&list-type=2&prefix=f_sales%2F  ***    997µs       ⇣  975.956µs  ↑ 84 B ↓ 983 B
[200 OK] s3.HeadObject ***:9000/sales-history/f_sales/time_key%3D101/101.parquet ***    434µs       ⇣  0s         ↑ 84 B ↓ 0 B
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D101/101.parquet ***    4.846ms      ⇣  4.716135ms  ↑ 90 B ↓ 8 B
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D101/101.parquet ***    5.641ms      ⇣  5.463628ms  ↑ 90 B ↓ 4.9 KiB
[200 OK] s3.HeadObject ***:9000/sales-history/f_sales/time_key%3D102/102.parquet ***    506µs       ⇣  0s         ↑ 84 B ↓ 0 B
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D102/102.parquet ***    4.855ms      ⇣  4.228286ms  ↑ 90 B ↓ 8 B
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D102/102.parquet ***    4.506ms      ⇣  4.341977ms  ↑ 90 B ↓ 4.9 KiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D102/102.parquet ***    24.753ms     ⇣  24.492926ms  ↑ 90 B ↓ 503 B
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D102/102.parquet ***    357.87ms     ⇣  33.843924ms  ↑ 90 B ↓ 38 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D102/102.parquet ***    60.421ms     ⇣  8.821565ms  ↑ 90 B ↓ 4.8 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D102/102.parquet ***    184.34ms     ⇣  85.442514ms  ↑ 90 B ↓ 12 MiB
```

パーティションファイルや row group について不要なデータの読み込みをスキップするプルーニングが効いていることが分かりました。

## 3-3. スタースキーマへのクエリ（その１）

ここからが本番です。スタースキーマに対する以下のクエリを実行します。このクエリは 2 ヶ月分（2023/04～2023/05）のデータのみを集計の対象としているため、本来であれば ```101.parquet``` と ```102.parquet``` の 2 ファイルのみ読み込んでほしいところです。

```sql
explain analyze
select
	count(distinct item_key),
	count(distinct shop_key),
	sum(sales_quantity),
	sum(sales_amount)
from
	f_sales s
inner join
	d_time t on (s.time_key = t.time_key)
where
	t.ym between '202304' and '202305'
;
```

クエリプロファイルは以下になります。売上ファクトのデータは特に絞り込みを行わずに 1.5 億件取り込んでるようですね。Snowflake など DWH 系の DB では、Join Filtering といってディメンジョンデータ（今回は時間次元）の絞り込みの結果に基づいてファクトデータを絞り込むという最適化が良く行われるのですが、DuckDB ではそういった最適化はしないっぽいですね。

![プラン.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/388675/fccb0e03-1f8a-fc6b-84f3-e960a97a5561.png)

MinIO のトレースログも以下のようになっており、3 ファイル 15 row group すべてを読み込んでいるようです。

```
[200 OK] s3.ListObjectsV2 ***:9000/sales-history/?encoding-type=url&list-type=2&prefix=f_sales%2F  ***    1.018ms      ⇣  995.783µs  ↑ 84 B ↓ 983 B
[200 OK] s3.HeadObject ***:9000/sales-history/f_sales/time_key%3D101/101.parquet ***    424µs       ⇣  0s         ↑ 84 B ↓ 0 B
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D101/101.parquet ***    28.663ms     ⇣  28.529576ms  ↑ 90 B ↓ 8 B
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D101/101.parquet ***    6.496ms      ⇣  6.151711ms  ↑ 90 B ↓ 4.9 KiB
[200 OK] s3.HeadObject ***:9000/sales-history/d_time.parquet ***    438µs       ⇣  0s         ↑ 84 B ↓ 0 B
[206 Partial Content] s3.GetObject ***:9000/sales-history/d_time.parquet ***    11.395ms     ⇣  11.345608ms  ↑ 90 B ↓ 8 B
[206 Partial Content] s3.GetObject ***:9000/sales-history/d_time.parquet ***    1.47ms       ⇣  1.452992ms  ↑ 90 B ↓ 1.8 KiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/d_time.parquet ***    1.043ms      ⇣  1.022216ms  ↑ 90 B ↓ 89 B
[206 Partial Content] s3.GetObject ***:9000/sales-history/d_time.parquet ***    881µs       ⇣  855.469µs  ↑ 90 B ↓ 110 B
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D101/101.parquet ***    815.608ms    ⇣  49.072972ms  ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D101/101.parquet ***    1.170899s    ⇣  34.171984ms  ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D101/101.parquet ***    607.495ms    ⇣  27.454213ms  ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D101/101.parquet ***    717.773ms    ⇣  59.567012ms  ↑ 90 B ↓ 55 MiB
[200 OK] s3.HeadObject ***:9000/sales-history/f_sales/time_key%3D102/102.parquet ***    481µs       ⇣  0s         ↑ 84 B ↓ 0 B
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D101/101.parquet ***    625.58ms     ⇣  21.991863ms  ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D102/102.parquet ***    61.331ms     ⇣  61.229873ms  ↑ 90 B ↓ 8 B
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D102/102.parquet ***    5.187ms      ⇣  5.087045ms  ↑ 90 B ↓ 4.9 KiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D102/102.parquet ***    687.242ms    ⇣  34.270046ms  ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D102/102.parquet ***    592.484ms    ⇣  17.927843ms  ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D102/102.parquet ***    619.098ms    ⇣  37.254828ms  ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D102/102.parquet ***    733.202ms    ⇣  137.30899ms  ↑ 90 B ↓ 55 MiB
[200 OK] s3.HeadObject ***:9000/sales-history/f_sales/time_key%3D103/103.parquet ***    4.385ms      ⇣  0s         ↑ 84 B ↓ 0 B
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D102/102.parquet ***    647.502ms    ⇣  21.814217ms  ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D103/103.parquet ***    53.786ms     ⇣  53.674627ms  ↑ 90 B ↓ 8 B
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D103/103.parquet ***    5.404ms      ⇣  5.271061ms  ↑ 90 B ↓ 4.9 KiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D103/103.parquet ***    646.49ms     ⇣  25.188736ms  ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D103/103.parquet ***    784.902ms    ⇣  29.334722ms  ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D103/103.parquet ***    875.145ms    ⇣  137.975089ms   ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D103/103.parquet ***    844.357ms    ⇣  142.098105ms   ↑ 90 B ↓ 55 MiB
[206 Partial Content] s3.GetObject ***:9000/sales-history/f_sales/time_key%3D103/103.parquet ***    852.301ms    ⇣  100.275044ms   ↑ 90 B ↓ 55 MiB
```

パーティションキー以外の絞り込みを行う以下のようなクエリでも Join Filter が効かないのは同じです。（```sh.region_name = 'region0101'``` は ```shop_key``` が 1001 から 1010 のレコードに合致するので、売上ファクトの各 Parquet ファイルの1 番目の row group のデータしか対象にしていません。）

```sql
explain analyze
select
	sh.region_name,
	count(distinct s.item_key),
	count(distinct s.shop_key),
	sum(s.sales_quantity),
	sum(s.sales_amount)
from
	f_sales s
inner join
	d_shop sh on (s.shop_key = sh.shop_key)
where
	sh.region_name = 'region0101'
group by
	sh.region_name
;
```

## 3-4. スタースキーマへのクエリ（その２）

次に、以下のようなクエリを実行してみます。

```sql
explain analyze
select
	sh.region_name,
	sum(s.sales_quantity),
	sum(s.sales_amount)
from
	f_sales s
inner join
	d_item i on (s.item_key = i.item_key)
inner join
	d_shop sh on (s.shop_key = sh.shop_key)
where
	i.item_category_name != 'category1001'
group by
	sh.region_name
;
```

このクエリのプロファイルは以下になります。商品次元と売上ファクトを結合し、その結果と店舗次元を結合し集計するというストレートな実行計画になっています。ただ、多くの DWH DB では店舗次元を結合する前に shop_key で集計するという Aggregation Pushdown という最適化が良く行われるのですが、DuckDB ではそれも行われないようです。（今回のケースでは、事前に集計することで結合する件数がほぼ 100 万分の 1 になるので効果は大きいです）


![プラン.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/388675/0be816f1-66cf-a796-4bd2-48484dbc439c.png)

MinIO のトレースログを載せるのはやめますが、売上ファクトの全 Parquet ファイルをスキャンしていることは 3-3. と変わりありません。

# 4. まとめ

スタースキーマの構造を持つオブジェクトストレージ上の Parquet ファイルに対してクエリを実行し、そのクエリプロファイルとI/Oのトレースを採ってみました。

その結果、以下の気付きがありました。

- 単純な 1 テーブルスキャンでは row group の読み込みプルーニングが効いている
- Join Filtering が効いていることは確認できなかった
- Aggregation Pushdownn も効いていることは確認できなかった

（もしかしたら最適化が発動する条件があり、今回はマッチしていなかっただけかもしれませんが）

少ないクエリを試してみた感触ですが、最適化機能があまりないのであれば、DuckDB はスタースキーマに対するアドホッククエリはあまり向かず、

- ほぼ全てのデータを集計するようなバッチ処理もしくは定型分析処理に向いている
- アドホック分析するなら、データモデルを正規化やスタースキーマにせず、1 つのデータマートテーブルに非正規化（大福帳化）することが好ましい

というのが予想ですが、実際はどうなんでしょうか（もちろん性能要件やコストなどの観点で問題なければアドホッククエリに用いても良いのですが）。

ユースケースをもう少し調べてみたいですね。