❄️

Snowflakeが速い理由 マイクロパーティションの中身を覗いてみる。

に公開

はじめに

こんにちは、Fusicの岡山です。
最近Snowflakeを利活用しており、自分の勉強かつ超ざっくりSnowflakeのことをまとめてみたいというモチベーションが出てきたので、記事を書くことにしました。

そして、地味に6月1日から毎日投稿できないかということで
「Snowflake一人アドカレチャレンジ」としてチャレンジしています。
どこまでいけるか頑張ってみます笑

早速ですが、Snowflakeといえば「速い!」というイメージですよね。

Snowflakeのセミナーや紹介記事を見ていると、トップバッターに出ててくるのが

「マイクロパーティション」 というキーワード。

これのお陰で高速になるということですが、実際どうなの?ということでざっくり検証していきましょう。

まず「従来のパーティションとの違い」から

従来のDWHなどのパーティショニングは利用者がキーを指定して、日付といった任意の単位でデータを分ける方式でした。

Snowflakeのマイクロパーティションは全く異なるアプローチを取っていて、データを突っ込むだけでSnowflakeがいい感じに50~500MBの塊に自動分割をしてくれます。

違いをまとめるとこんな感じ。

比較項目 既存DWHのパーティション Snowflakeのマイクロパーティション
分割方法 ユーザーが PARTITION BY で指定する Snowflakeが自動で分割する
分割単位 日付・地域など、大きめの論理単位 圧縮前50〜500MB程度の小さな物理単位
チューニング キー選びが性能に直結する 基本は指定不要で、ロード順序が効く
メンテナンス 追加・分割・結合を意識する Snowflakeが自動管理する
読み飛ばし 指定したパーティション単位で判断する 列ごとのMin/Maxなどのメタデータで判断する

マイクロパーティションの中身とメタデータ

速さの理由はこの「小さく分ける」ことではなく、分割された塊の中で

  • 列ごとにデータを保存圧縮
  • 各列のMin/Max/Nullなどのメタデータを持つ
  • クエリ時は不要な列や塊をスキップできる

特にこのメタデータ(Min/Max/Nullといった情報)がクエリ実行時の不要パーティションのスキップ(プルーニング)に効くためクエリを高速に処理できるとのこと。

例えば WHERE region = 'Tokyo' なら、Snowflakeは各マイクロパーティションの region のMin/Maxをチェックします

で、Tokyoが入っていないと判断できる塊はスキップします。

これがプルーニングで、スキャン量を減らせる=つまり必要なデータだけすぐに取ってこれるから速いということですね。

実際にパーティション構造を観察してみる

では実際に、データをつくってこの構造を確かめてみましょう。
今回は地域別というパーティションを期待したデータをINSERTしてみて、挙動を見ていこうと思います。

まずは作業場所であるDB、スキーマをつくっていきます。

-- 作業場所を作る
USE ROLE SYSADMIN;

CREATE DATABASE IF NOT EXISTS sf_handson;
CREATE SCHEMA IF NOT EXISTS sf_handson.public;

CREATE WAREHOUSE IF NOT EXISTS wh_snowflake
    WAREHOUSE_SIZE = 'XSMALL'
    AUTO_SUSPEND = 60
    AUTO_RESUME = TRUE
    INITIALLY_SUSPENDED = TRUE;

USE WAREHOUSE wh_snowflake;
USE DATABASE sf_handson;
USE SCHEMA public;

SELECT CURRENT_ROLE(), CURRENT_WAREHOUSE(), CURRENT_DATABASE(), CURRENT_SCHEMA();
-- → SYSADMIN / WH_SNOWFLAKE / SF_HANDSON / PUBLIC あたりになっていれば OK

ここからは sf_handson.public に、観察用のテーブルを作っていきます。

-- 地域別にデータを作成(後でプルーニング効果を見るため)
CREATE OR REPLACE TABLE day01_partitioned (
    id INT,
    region STRING,
    amount NUMBER(10,2),
    order_date DATE,
    status STRING,
    payload STRING
);

Snowflakeは、データを挿入・ロードした順序をもとにマイクロパーティションを作る(はず)。

つまり「TokyoのデータだけINSERT → OsakaのデータだけINSERT」と分けて入れると、region の値が近い行が同じ塊に入りやすいと思われます。

逆にランダムに混ぜると、1つの塊に複数地域が混ざりやすくなります。

まず本当にそうなるのか。そして、この違いがプルーニングにどう効くかを体感できればハッピーです。

まずはregionごとに固めて順番にインサートしてみましょう

-- Tokyoデータ
INSERT INTO day01_partitioned
SELECT
    seq4(),
    'Tokyo',
    UNIFORM(500, 50000, RANDOM(100)),
    DATEADD('day', MOD(seq4(), 365), '2025-01-01'),
    CASE MOD(seq4(), 3)
        WHEN 0 THEN 'pending'
        WHEN 1 THEN 'shipped'
        ELSE 'completed'
    END,
    RANDSTR(2000, RANDOM())
FROM TABLE(GENERATOR(ROWCOUNT => 100000));

-- Osakaデータ
INSERT INTO day01_partitioned
SELECT
    seq4() + 100000,
    'Osaka',
    UNIFORM(500, 50000, RANDOM(200)),
    DATEADD('day', MOD(seq4(), 365), '2025-01-01'),
    CASE MOD(seq4(), 3)
        WHEN 0 THEN 'pending'
        WHEN 1 THEN 'shipped'
        ELSE 'completed'
    END,
    RANDSTR(2000, RANDOM())
FROM TABLE(GENERATOR(ROWCOUNT => 100000));

-- Sapporoデータ
INSERT INTO day01_partitioned
SELECT
    seq4() + 200000,
    'Sapporo',
    UNIFORM(500, 50000, RANDOM(300)),
    DATEADD('day', MOD(seq4(), 365), '2025-01-01'),
    CASE MOD(seq4(), 3)
        WHEN 0 THEN 'pending'
        WHEN 1 THEN 'shipped'
        ELSE 'completed'
    END,
    RANDSTR(2000, RANDOM())
FROM TABLE(GENERATOR(ROWCOUNT => 100000));

payload は検証用のダミー文字列となります。
行数だけだと1つのマイクロパーティションに収まりやすいため、複数パーティションが作られるようにデータ量を増やしています。

行を増やすと時間がかかってコストがかかってしまいますからね。。。

インサートし終わったら、テーブル全体の情報を確認します。

SELECT
    table_name,
    row_count,
    bytes,
    bytes / 1024 / 1024 AS size_mb
FROM INFORMATION_SCHEMA.TABLES
WHERE table_name = 'DAY01_PARTITIONED';

こんな感じのテーブル情報になるはず。

次にクラスタリング情報を確認します。

-- パーティションがどれくらい「整理されているか」を確認
SELECT SYSTEM$CLUSTERING_INFORMATION('day01_partitioned', '(region)');

返ってきた結果はこちら

{
  "cluster_by_keys" : "LINEAR(region)",
  "total_partition_count" : 27,
  "total_constant_partition_count" : 27,
  "average_overlaps" : 0.0,
  "average_depth" : 1.0,
  "partition_depth_histogram" : {
    "00000" : 0,
    "00001" : 27,
    "00002" : 0,
    "00003" : 0,
    "00004" : 0,
    "00005" : 0,
    "00006" : 0,
    "00007" : 0,
    "00008" : 0,
    "00009" : 0,
    "00010" : 0,
    "00011" : 0,
    "00012" : 0,
    "00013" : 0,
    "00014" : 0,
    "00015" : 0,
    "00016" : 0
  },
  "clustering_errors" : [ ]
}

返り値の意味はこんな感じです。

  • cluster_by_keys: クラスタリングキー(今回はregion)
  • total_partition_count: 全パーティション数
  • total_constant_partition_count: 指定列がconstant状態に達した(=再クラスタリングの恩恵が小さい)パーティション数
  • average_overlaps: パーティションの値域の重なり具合
  • average_depth: パーティションの深さ(小さいほど良い)

特にtotal_partition_counttotal_constant_partition_countが両方とも27になっています。

これは複数マイクロパーティションができて、かつ、再クラスタリングをしなくてもいい(regionでいい感じに固まっている)といえます。

また、average_overlapsが0で、average_depthが1なので
「regionの重なりがなくプルーニングしやすい」状態になったので、狙った形になりました。

プルーニング効果を実測する

それでは実際にクエリを投げてスキャン量を比較していきましょう

-- キャッシュを無効化(正確な測定のため)
ALTER SESSION SET USE_CACHED_RESULT = FALSE;

-- === テストA: 全件スキャン ===
SELECT SUM(amount) FROM day01_partitioned;

-- === テストB: WHERE句あり(Tokyoだけ) ===
SELECT SUM(amount) FROM day01_partitioned WHERE region = 'Tokyo';

-- === テストC: WHERE句あり(Osakaだけ) ===
SELECT SUM(amount) FROM day01_partitioned WHERE region = 'Osaka';

このクエリで、クエリごとの「どれだけマイクロパーティションを読み込んだか」を確認します。
これでWHERE region = 'Tokyo' や Osaka を付けたときに、全27個のマイクロパーティションのうち一部だけ読んでいればプルーニングされていると言えるはずです。

SELECT
    query_text,
    total_elapsed_time,
    bytes_scanned,
    partitions_scanned,
    partitions_total,
    ROUND(partitions_scanned / NULLIF(partitions_total, 0) * 100, 1) AS scan_ratio_pct
FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY
WHERE query_text LIKE '%day01_partitioned%'
  AND query_text NOT LIKE '%QUERY_HISTORY%'
  AND query_text NOT LIKE '%SYSTEM$%'
  AND start_time >= DATEADD('hour', -2, CURRENT_TIMESTAMP())
ORDER BY start_time DESC
LIMIT 10;

確かにしていますね。

  • 全件: 27 / 27 = 100%
  • Tokyo: 9 / 27 = 33.3%
  • Osaka: 9 / 27 = 33.3%

3地域を同じ件数で分けてロードしているので各地域がざっくり9パーティションに分かれました。

つまりregion条件で不要な18個のパーティションを読まずに済んだということになります。

ランダムにロードした場合と比べてみる

先ほどの検証はあくまできれいにパーティションされたデータをプルーニングが効いた状態で読めたことを示しただけなので、実際に速いかという証明にはなっていません。

なので次はランダムでロードした場合もやってみることで実行時間を比べてみましょう。
既存テーブルを用いて順番をバラバラにして挿入しています。

-- ランダム混在テーブル(同じ30万件だがregionがバラバラ)
CREATE OR REPLACE TABLE day01_random AS
SELECT * FROM day01_partitioned ORDER BY RANDOM(999);

-- ランダムテーブルのクラスタリング情報
SELECT SYSTEM$CLUSTERING_INFORMATION('day01_random', '(region)');
-- → average_overlaps が高くなるはず = プルーニングが効きにくい

-- 同じクエリで比較
ALTER SESSION SET USE_CACHED_RESULT = FALSE;
SELECT SUM(amount) FROM day01_random WHERE region = 'Tokyo';

クラスタリング情報の結果

{
  "cluster_by_keys" : "LINEAR(region)",
  "total_partition_count" : 29,
  "total_constant_partition_count" : 0,
  "average_overlaps" : 28.0,
  "average_depth" : 29.0,
  "partition_depth_histogram" : {
    "00000" : 0,
    "00001" : 0,
    "00002" : 0,
    "00003" : 0,
    "00004" : 0,
    "00005" : 0,
    "00006" : 0,
    "00007" : 0,
    "00008" : 0,
    "00009" : 0,
    "00010" : 0,
    "00011" : 0,
    "00012" : 0,
    "00013" : 0,
    "00014" : 0,
    "00015" : 0,
    "00016" : 0,
    "00032" : 29
  },
  "clustering_errors" : [ ]
}

先程のregion単位でインサートしたテーブルと比較して、regionが1地域に固まったパーティションがなく、ほぼ全部のパーティションでregionの範囲が重なっている、つまり各マイクロパーティション内にTokyo/Osaka/Sapporoが混ざり、プルーニングが効きにくい状態になったということです。

それではソートされた1つ目のテーブルと、ランダムにソートされたテーブルでのスキャン性能を比較しましょう。

実行したクエリはこちら。
直近2時間の実行履歴から整列したテーブルとランダムテーブルのTokyo検索を1件ずつ拾って比較するというものです。

WITH recent_queries AS (
    SELECT
        query_text,
        total_elapsed_time,
        bytes_scanned,
        partitions_scanned,
        partitions_total,
        start_time
    FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY
    WHERE start_time >= DATEADD('hour', -2, CURRENT_TIMESTAMP())
      AND query_text NOT LIKE '%QUERY_HISTORY%'
      AND query_text NOT LIKE '%SYSTEM$%'
),
sorted_query AS (
    SELECT
        'sorted' AS table_type,
        total_elapsed_time,
        bytes_scanned,
        partitions_scanned,
        partitions_total
    FROM recent_queries
    WHERE query_text LIKE '%day01_partitioned%Tokyo%'
      AND query_text NOT LIKE '%day01_random%'
    QUALIFY ROW_NUMBER() OVER (ORDER BY start_time DESC) = 1
),
random_query AS (
    SELECT
        'random' AS table_type,
        total_elapsed_time,
        bytes_scanned,
        partitions_scanned,
        partitions_total
    FROM recent_queries
    WHERE query_text LIKE '%day01_random%Tokyo%'
    QUALIFY ROW_NUMBER() OVER (ORDER BY start_time DESC) = 1
)
SELECT
    table_type,
    total_elapsed_time,
    bytes_scanned,
    partitions_scanned,
    partitions_total,
    ROUND(partitions_scanned / NULLIF(partitions_total, 0) * 100, 1) AS scan_pct
FROM sorted_query
UNION ALL
SELECT
    table_type,
    total_elapsed_time,
    bytes_scanned,
    partitions_scanned,
    partitions_total,
    ROUND(partitions_scanned / NULLIF(partitions_total, 0) * 100, 1) AS scan_pct
FROM random_query;

結果

狙った通りランダムテーブルよりも整列テーブルのほうがマイクロパーティションのスキャン数が少なく、それによって速度も3倍速いという結果になりました。

まとめ

今回の比較検証ではマイクロパーティションが自動で作成される流れと、パーティション内の整理されている度合いでパーティションのスキャン効率が変わり、速度に直結するという結果を見ることができました。

つまり、Snowflakeが「インデックスなしでも速い」と言われる理由は、単に裏側で自動分割しているからではなく、各マイクロパーティションのメタデータを使って、読まなくていいデータを読み飛ばしているからということがわかりました。

そして、こういった情報整理をいい感じにやってくれるのがSnowflakeの良いところということですね。

おわり

Fusic 技術ブログ

Discussion