1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Snowflakeのクエリ最適化 — クレジットを無駄にしないために

1
Posted at

はじめに

Snowflakeを使い始めてしばらくすると、クレジットの消費が気になってくる。

「このクエリ、なんで遅いんだろう」「先月のクレジット消費が思ったより多い」という問題が出てきたので、クエリ最適化とコスト管理を真剣に調べた。RDBMSのチューニングと似ている部分もあるが、Snowflake固有の考え方が必要な部分もあった。


まずクエリのパフォーマンスを計測する

最適化の前に現状把握から。

-- 直近のクエリ実行時間を確認
SELECT
    query_id,
    query_text,
    total_elapsed_time / 1000            AS seconds,
    execution_time / 1000                AS execution_seconds,
    compilation_time / 1000              AS compilation_seconds,
    bytes_scanned / 1024 / 1024 / 1024   AS gb_scanned,
    percentage_scanned_from_cache        AS cache_hit_pct,
    rows_produced,
    warehouse_name,
    start_time
FROM snowflake.account_usage.query_history
WHERE start_time >= DATEADD(day, -7, CURRENT_TIMESTAMP())
    AND total_elapsed_time > 5000  -- 5秒以上かかったクエリ
ORDER BY total_elapsed_time DESC
LIMIT 20;
-- クレジットを最も消費しているウェアハウス
SELECT
    warehouse_name,
    SUM(credits_used)               AS total_credits,
    SUM(credits_used_cloud_services) AS cloud_credits,
    COUNT(*)                         AS query_count
FROM snowflake.account_usage.warehouse_metering_history
WHERE start_time >= DATEADD(day, -30, CURRENT_TIMESTAMP())
GROUP BY warehouse_name
ORDER BY total_credits DESC;

Pythonからも取得できるので定期的にレポートを出す仕組みを作った。

from snowflake_client import SnowflakeClient
from config           import SnowflakeConfig

sf = SnowflakeClient(SnowflakeConfig())

def get_expensive_queries(days: int = 7, threshold_seconds: int = 10):
    return sf.query(f"""
        SELECT
            query_id,
            LEFT(query_text, 100)                    AS query_preview,
            total_elapsed_time / 1000                AS seconds,
            bytes_scanned / 1024 / 1024 / 1024       AS gb_scanned,
            percentage_scanned_from_cache            AS cache_hit_pct,
            warehouse_name
        FROM snowflake.account_usage.query_history
        WHERE start_time >= DATEADD(day, -{days}, CURRENT_TIMESTAMP())
            AND total_elapsed_time > {threshold_seconds * 1000}
        ORDER BY total_elapsed_time DESC
        LIMIT 50
    """)

expensive = get_expensive_queries(days=7, threshold_seconds=10)
print(expensive.to_string())

クエリプロファイルを読む

Snowflake UIの「Query Profile」機能が強力。

-- クエリを実行してIDを取得
SELECT * FROM orders WHERE amount > 10000;

-- 直前のクエリIDを確認
SELECT LAST_QUERY_ID();

Query ProfileをUIで見ると:

OperationNode
├── TableScan(テーブルスキャン)
│   ├── Partitions scanned: 100/500  ← 全パーティションの20%だけスキャン
│   └── Bytes scanned: 2.5 GB
├── Filter(フィルタ)
└── Aggregate(集計)

注目すべき指標:

Partitions scanned / total → 低いほど良い(pruningが効いている)
Bytes scanned              → 少ないほど良い
percentage_scanned_from_cache → 高いほど良い(キャッシュヒット)
Spillage to disk           → あるとメモリ不足のサイン

マイクロパーティションとプルーニング

Snowflakeの内部でデータはマイクロパーティションという単位で格納されている。

テーブル
└── マイクロパーティション(50MB〜500MB程度)
    ├── min/max値のメタデータ
    ├── NULL値の数
    └── 実データ(列指向で圧縮)

WHERE句の条件がマイクロパーティションのmin/max範囲外なら、そのパーティションをスキャンしない。これをプルーニングという。

-- プルーニングが効く例(日付範囲での絞り込み)
SELECT * FROM orders
WHERE created_at BETWEEN '2024-01-01' AND '2024-01-31';
-- created_atでデータが並んでいれば1月のパーティションだけスキャン

-- プルーニングが効きにくい例(ランダムなIDの絞り込み)
SELECT * FROM orders
WHERE user_id = 12345;
-- user_idがランダムに分散していると全パーティションをスキャン

プルーニングを効かせるためのデータ投入順

-- 悪い例 — ランダムな順序でINSERT
INSERT INTO orders SELECT * FROM source ORDER BY RANDOM();

-- 良い例 — 日時順でINSERT(日付クエリのプルーニングが効く)
INSERT INTO orders SELECT * FROM source ORDER BY created_at;

クラスタリングキー

大きなテーブルでプルーニングが効かない場合、クラスタリングキーを設定する。

-- クラスタリングキーを設定
ALTER TABLE orders CLUSTER BY (created_at::DATE);

-- 複合クラスタリングキー
ALTER TABLE orders CLUSTER BY (region, created_at::DATE);

-- クラスタリングの状態を確認
SELECT SYSTEM$CLUSTERING_INFORMATION('orders', '(created_at::DATE)');
// CLUSTERING_INFORMATIONの出力例
{
  "total_partition_count": 500,
  "total_constant_partition_count": 0,
  "average_overlaps": 2.4,  // 低いほど良い(1.0が理想)
  "average_depth": 3.1,     // 低いほど良い
  "partition_depth_histogram": {...}
}

average_overlapsが高いとクラスタリングが崩れているサイン。Snowflakeは自動的にReclustering(再クラスタリング)するが、追加コストが発生するので注意。

クラスタリングキーを使うべきケース

✓ 数TB以上の大きなテーブル
✓ 特定のカラムで絞り込むクエリが頻繁に実行される
✓ プルーニング率が低い(Query Profileで確認)

✗ 小さなテーブル(効果がなく維持コストだけかかる)
✗ 様々な列でランダムにクエリされるテーブル

マテリアライズドビュー

集計クエリを事前に計算して結果を保持する仕組み。

-- 通常のビュー(クエリのたびに計算する)
CREATE VIEW daily_sales_view AS
SELECT
    created_at::DATE AS sale_date,
    region,
    SUM(amount)      AS total_amount,
    COUNT(*)         AS order_count
FROM orders
GROUP BY 1, 2;

-- マテリアライズドビュー(結果を保持し、自動更新する)
CREATE MATERIALIZED VIEW daily_sales_mv AS
SELECT
    created_at::DATE AS sale_date,
    region,
    SUM(amount)      AS total_amount,
    COUNT(*)         AS order_count
FROM orders
GROUP BY 1, 2;
-- マテリアライズドビューへのクエリ(高速)
SELECT * FROM daily_sales_mv
WHERE sale_date >= '2024-01-01'
ORDER BY total_amount DESC;

マテリアライズドビューはベーステーブルに変更があると自動でリフレッシュされる。リフレッシュにもクレジットが消費されるので、更新頻度の高いテーブルには向かない。

通常ビューとマテリアライズドビューの使い分け

通常ビュー:
  ✓ ベーステーブルが頻繁に更新される
  ✓ クエリの頻度が低い
  ✓ 複雑な結合やサブクエリが含まれる

マテリアライズドビュー:
  ✓ 集計クエリが頻繁に実行される
  ✓ ベーステーブルの更新頻度が低い〜中程度
  ✓ クエリのレスポンスタイムを優先したい

結果キャッシュの活用

Snowflakeは同じクエリを24時間以内に再実行すると、ウェアハウスを使わずに結果キャッシュから返す。

-- 1回目(ウェアハウスを使う)
SELECT region, SUM(amount) FROM orders GROUP BY region;
-- 実行時間: 3.2秒

-- 2回目(結果キャッシュから返る)
SELECT region, SUM(amount) FROM orders GROUP BY region;
-- 実行時間: 0.02秒
-- キャッシュを使わないようにする(テスト時)
ALTER SESSION SET USE_CACHED_RESULT = FALSE;

-- キャッシュヒット率を確認
SELECT
    AVG(percentage_scanned_from_cache) AS avg_cache_hit_pct
FROM snowflake.account_usage.query_history
WHERE start_time >= DATEADD(day, -7, CURRENT_TIMESTAMP());

キャッシュを活用するために:

-- ✓ クエリの書き方を統一する(大文字小文字・スペースが違うとキャッシュミス)
-- ✓ ダッシュボードのクエリは定期実行してキャッシュを温める
-- ✗ CURRENT_TIMESTAMP()を使うとクエリが毎回変わりキャッシュが効かない

-- 悪い例(毎回クエリが変わる)
SELECT * FROM orders
WHERE created_at >= DATEADD(hour, -24, CURRENT_TIMESTAMP());

-- 良い例(日付を固定するとキャッシュが効く)
SELECT * FROM orders
WHERE created_at::DATE = CURRENT_DATE() - 1;

ウェアハウスサイズの選定

クエリによって適切なウェアハウスサイズが違う。

-- 小さいクエリ用のウェアハウス
CREATE WAREHOUSE small_wh
    WAREHOUSE_SIZE = 'X-SMALL'
    AUTO_SUSPEND   = 60
    AUTO_RESUME    = TRUE;

-- 重い集計用のウェアハウス
CREATE WAREHOUSE large_wh
    WAREHOUSE_SIZE = 'LARGE'
    AUTO_SUSPEND   = 120
    AUTO_RESUME    = TRUE;
# クエリの種類によってウェアハウスを切り替える
def run_heavy_aggregation(sql: str):
    with get_snowflake_connection() as conn:
        cursor = conn.cursor()
        # 重い処理の前に大きいウェアハウスに切り替え
        cursor.execute("USE WAREHOUSE large_wh")
        cursor.execute(sql)
        result = cursor.fetch_pandas_all()
        # 処理後は小さいウェアハウスに戻す
        cursor.execute("USE WAREHOUSE small_wh")
        return result

ウェアハウスのスケールアップとスケールアウト

-- スケールアップ(サイズを大きくする)
-- メモリ不足(Spillage)が発生しているとき
ALTER WAREHOUSE my_warehouse SET WAREHOUSE_SIZE = 'LARGE';

-- スケールアウト(同じサイズで並列度を上げる)
-- 同時クエリが多いとき
ALTER WAREHOUSE my_warehouse SET MAX_CLUSTER_COUNT = 3;
スケールアップ が有効:
  ・単一クエリが遅い
  ・Spillage to disk が発生している
  ・JOINやGROUP BYが重い

スケールアウト が有効:
  ・クエリが並行して大量に実行される
  ・待ち行列(Queuing)が発生している

クエリを書くときの最適化ポイント

SELECT * を避ける

-- 悪い例
SELECT * FROM orders;

-- 良い例(必要な列だけ取得)
SELECT order_id, user_id, amount, created_at FROM orders;

Snowflakeは列指向ストレージなので、SELECT *は全列を読み込んでスキャン量が増える。

フィルタは早めに適用する

-- 悪い例(JOINしてからフィルタ)
SELECT o.*, u.region
FROM orders o
JOIN users u ON o.user_id = u.user_id
WHERE o.created_at >= '2024-01-01';

-- 良い例(サブクエリで先にフィルタ)
SELECT o.*, u.region
FROM (
    SELECT * FROM orders
    WHERE created_at >= '2024-01-01'
) o
JOIN users u ON o.user_id = u.user_id;

LIMIT を使う(開発時)

-- 開発中はLIMITで確認する
SELECT * FROM very_large_table LIMIT 100;

-- COUNT(*)で件数だけ確認
SELECT COUNT(*) FROM very_large_table
WHERE created_at >= '2024-01-01';

DISTINCT よりGROUP BYが速いことがある

-- DISTINCTより
SELECT DISTINCT user_id FROM orders;

-- GROUP BYのほうが速いことがある
SELECT user_id FROM orders GROUP BY user_id;

リソースモニターでコスト上限を設定

-- 月次のクレジット上限を設定
CREATE RESOURCE MONITOR monthly_monitor
    WITH CREDIT_QUOTA = 500  -- 月500クレジットまで
    FREQUENCY         = MONTHLY
    START_TIMESTAMP   = IMMEDIATELY
    TRIGGERS
        ON 50 PERCENT DO NOTIFY        -- 50%消費で通知
        ON 80 PERCENT DO NOTIFY        -- 80%消費で通知
        ON 100 PERCENT DO SUSPEND      -- 100%でウェアハウス停止
        ON 110 PERCENT DO SUSPEND_IMMEDIATE;  -- 即時停止

-- ウェアハウスにモニターを適用
ALTER WAREHOUSE my_warehouse SET RESOURCE_MONITOR = monthly_monitor;

最適化チェックリスト

実務で確認するポイントをまとめた。

クエリレベル:
□ SELECT * を使っていないか
□ WHERE句でパーティションプルーニングが効いているか
□ JOINの前にフィルタを適用しているか
□ 結果キャッシュが活用されているか(CURRENT_TIMESTAMP()に注意)
□ Query Profileでボトルネックを確認したか

テーブルレベル:
□ 大きなテーブルにクラスタリングキーが設定されているか
□ 頻繁に参照する集計にマテリアライズドビューを使っているか

ウェアハウスレベル:
□ AUTO_SUSPENDが設定されているか
□ クエリの種類に合ったウェアハウスサイズか
□ Spillage to diskが発生していないか(サイズアップが必要)

コスト管理:
□ リソースモニターが設定されているか
□ 定期的にクレジット消費量を確認しているか
□ 不要なウェアハウスが起動したままになっていないか

まとめ

  • Query Profileでボトルネックを特定してから最適化する
  • マイクロパーティションのプルーニングを意識したクエリを書く
  • 大きなテーブルにはクラスタリングキーを設定する
  • 頻繁に実行する集計クエリはマテリアライズドビューを検討する
  • 結果キャッシュを活用するためクエリの書き方を統一する
  • ウェアハウスはクエリの種類によってサイズを使い分ける
  • リソースモニターで上限を設定してコスト暴走を防ぐ

「なんとなく動いている」状態から「なぜ速い/遅いのかわかった上で動かしている」状態になるには、Query Profileを読む習慣をつけるのが一番の近道だった。

1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?