Snowflake と ClickHouse の比較
2つのエンジンがストレージ、コンピュート、SQL 方言でどう違うか — そして、どの Snowflake のイディオムに ClickHouse の直接的な等価物が存在しないか。
このドキュメントは、Snowflake から ClickHouse へ移行するパートナー向けのリファレンスです。設計上の判断を左右するアーキテクチャの違いと、NYC タクシーのワークロードで遭遇する6つの SQL 方言のギャップを扱います。
1. アーキテクチャの比較
ストレージ
Snowflake は物理ストレージに関する判断をすべて代わりに行います。データはクラウドオブジェクトストレージ上で、圧縮されたカラムナのマイクロパーティションとして保存されます。指定するのはウェアハウスのサイズとテーブル構造だけで、残りは Snowflake が処理します — クラスタリング、コンパクション、ファイル管理はすべて自動です。
ClickHouse では、物理ストレージに関する判断を明示的に下す必要があります。テーブルを作成するときに指定するのは次のとおりです。
- エンジン(データがどう保存され、マージされ、重複排除されるかを決める)
- ORDER BY(物理的なソート順とプライマリインデックスになる)
- 任意で: PARTITION BY、TTL、SETTINGS(圧縮コーデック、マージの挙動)
これらは性能チューニングのつまみではなく、正しさに関わる判断です。エンジンを間違えると、クエリ結果が黙って不正になることがあります。ORDER BY を間違えると、速いはずのクエリがテーブル全体をスキャンすることになります。
クエリ実行
Snowflake は仮想ウェアハウスによるシェアードナッシング MPP を使います。ウェアハウスはクエリを処理するコンピュートノードのクラスタです。稼働している間ずっと課金され、アイドル時間もクレジットを消費します。オートサスペンドは助けになりますが、コールドスタートのレイテンシが加わります。
ClickHouse はベクトル化実行を使います。ClickHouse Cloud は各コンピュートサービスを独立にオートスケールし、アイドル時にはゼロまでスケールダウンします。複数のコンピュートサービスが同一のストレージを共有できます(SharedMergeTree 経由) — これが ClickHouse Cloud のコンピュート・コンピュート分離モデルであり、各サービスが共通のデータ層の上に立つ独立したコンピュート層になります。
同時実行モデル
Snowflake は別々のウェアハウスを作ることでワークロードを分離します。ETL は TRANSFORM_WH、分析は ANALYTICS_WH を使います。各ウェアハウスは専用のコンピュートを持つため、遅い ETL ジョブが分析クエリを飢餓状態にすることはありません。
ClickHouse Cloud は コンピュート・コンピュート分離 によって同じパターンをサポートします。同一のストレージを共有する複数のコンピュートサービスをプロビジョニングできます。各サービスは独立したオートスケーリングのコンピュート層であり、ETL は一方のサービスで、インタラクティブな分析はもう一方で動かせるため、両者の間でリソース競合が起きません。単一サービスの中では、ワークロードの分離はソフトクォータ(ユーザー単位またはクエリ単位の max_threads、priority、max_memory_usage)と、リソース制限を設定したユーザープロファイルによって実現します。クエリがミリ秒で完了するほとんどの分析ワークロードでは、単一サービスで十分であり、クエリ単位のクォータのほうが軽量な選択肢です。
コストモデル
| Snowflake | ClickHouse Cloud | |
|---|---|---|
| コンピュート | クレジット(ウェアハウス秒) | コンピュートユニット(ストレージとは別) |
| ストレージ | $23/TB/月 | 約 $0.023/GB/月(より安い) |
| ゼロへのスケール | オートサスペンドのみ | 完全なゼロスケールに対応 |
| データ転送 | イングレスは無料、エグレスは課金 | 標準のクラウドエグレス料金 |
最も大きな違いは、Snowflake ではクエリが走っているかどうかに関係なく ウェアハウスの稼働時間 に課金される点です。ClickHouse Cloud では、クエリの合間にコンピュートがゼロまでスケールダウンします。バースト的な分析ワークロードでは、ClickHouse Cloud は同等の Snowflake 構成に比べて通常3〜8倍安くなります。
2. SQL 方言のギャップ
NYC タクシーのワークロードには、書き換えが必要な構文が6つ含まれています。そのすべてが 01-setup-snowflake/queries/ の Q1〜Q7 に登場します。
ギャップ 1: QUALIFY
QUALIFY は Snowflake の拡張構文で、HAVING が集計結果で絞り込むのと同じように、window 関数の結果で行を絞り込みます。この移行では QUALIFY を方言のギャップとして扱い、サブクエリを使って書き換えます — これはあらゆる SQL エンジンで動作する、普遍的に移植可能なパターンです。
-- Snowflake
SELECT
trip_id,
pickup_at,
fare_amount,
ROW_NUMBER() OVER (PARTITION BY pickup_location_id ORDER BY fare_amount DESC) AS fare_rank
FROM fact_trips
WHERE pickup_at >= CURRENT_DATE - 7
QUALIFY fare_rank <= 10;
-- ClickHouse: wrap in a subquery
SELECT trip_id, pickup_at, fare_amount, fare_rank
FROM (
SELECT
trip_id,
pickup_at,
fare_amount,
ROW_NUMBER() OVER (PARTITION BY pickup_location_id ORDER BY fare_amount DESC) AS fare_rank
FROM analytics.fact_trips
WHERE pickup_at >= today() - 7
)
WHERE fare_rank <= 10;なぜ重要か: QUALIFY は Q3 に登場します。サブクエリへの書き換えは安全で移植可能なパターンです — ターゲットの SQL エンジンに関係なく動作し、window 関数の結果を明示的にします。Snowflake 固有の構文で危険なのは、それが黙って通用すると思い込むことです。移行が完了したと言い切る前に、必ずすべてのクエリをテストしてください。
ギャップ 2: VARIANT のコロンパス構文
Snowflake の VARIANT 型は、ネストしたフィールドへのアクセスにコロンパス記法を使います: column:field.subfield::TYPE。ClickHouse は半構造化データを String として保存し、クエリ時に JSONExtract* 関数で取り出します。
-- Snowflake
SELECT
trip_metadata:driver.rating::FLOAT AS driver_rating,
trip_metadata:app.version::STRING AS app_version,
trip_metadata:surge_multiplier::FLOAT AS surge
FROM trips_raw;
-- ClickHouse
SELECT
JSONExtractFloat(trip_metadata, 'driver', 'rating') AS driver_rating,
JSONExtractString(trip_metadata, 'app', 'version') AS app_version,
JSONExtractFloat(trip_metadata, 'surge_multiplier') AS surge
FROM default.trips_raw;JSONExtract* ファミリーの全体像: JSONExtractFloat、JSONExtractInt、JSONExtractString、JSONExtractBool、JSONExtractKeys、JSONExtractArrayRaw、JSONExtractRaw。ネストしたオブジェクトや配列を、さらに処理するために文字列として取り出したいときは JSONExtractRaw を使います。
ClickHouse の
JSON型を使わないのはなぜか:JSON型(以前は experimental)は最近の ClickHouse バージョンで利用できますが、セマンティクスが異なり、すべてのユースケースで本番に耐える段階には至っていません。移行ラボにおいては、String+JSONExtract*が安全で理解されている選択です。
ギャップ 3: LATERAL FLATTEN
Snowflake の LATERAL FLATTEN は、VARIANT 列の中の配列を行に展開します。ClickHouse には直接の等価物がありません。
-- Snowflake: explode a VARIANT array into rows
SELECT t.trip_id, f.value:stop_name::STRING AS stop_name
FROM trips_raw t,
LATERAL FLATTEN(input => t.trip_metadata:route_stops) f;
-- ClickHouse Option 1: JSONExtract into Array, then arrayJoin
SELECT
trip_id,
arrayJoin(JSONExtract(trip_metadata, 'route_stops', 'Array(String)')) AS stop_name
FROM default.trips_raw;
-- ClickHouse Option 2: Pre-flatten the column during dbt staging
-- In stg_trips.sql, extract all array elements to separate columns
-- or use the dbt model to reshape the data at load time事前にフラット化するアプローチ(Option 2)は、配列のスキーマが既知で要素数に上限があるときに適しています。arrayJoin(Option 1)は、アドホックなクエリや、配列の長さが可変のときに適しています。
ギャップ 4: MERGE INTO
Snowflake の MERGE INTO は upsert の主要な手段です。ClickHouse に MERGE 文はありません。ClickHouse で正しい等価物は、テーブルエンジンによって変わります。
-- Snowflake
MERGE INTO fact_trips t
USING staging_trips s ON t.trip_id = s.trip_id
WHEN MATCHED THEN UPDATE SET t.fare_amount = s.fare_amount, t.updated_at = s.updated_at
WHEN NOT MATCHED THEN INSERT VALUES (s.trip_id, s.pickup_at, ...);
-- ClickHouse with ReplacingMergeTree: just INSERT
-- RMT deduplicates by the ORDER BY key during background merges.
-- Use FINAL at query time to get the latest version:
INSERT INTO analytics.fact_trips SELECT * FROM staging_trips;
SELECT * FROM analytics.fact_trips FINAL WHERE trip_id = '...';
-- ClickHouse with dbt delete_insert incremental:
-- dbt handles the upsert by: DELETE WHERE key IN (new batch), then INSERT
-- This is the recommended approach for the analytics layerdbt-clickhouse の delete_insert インクリメンタル戦略は、分析モデルにとって MERGE INTO に最も近いセマンティクスの等価物です。取り込むバッチに含まれるいずれかのキーに一致する既存行を削除し、そのうえで取り込む行をすべて挿入します — パーティション単位でアトミックに実行されます。
ReplacingMergeTree の重要な落とし穴: バックグラウンドでの重複排除は非同期です。マージが走るまでの間は、行の古いバージョンと新しいバージョンの両方がテーブルに存在します。キーごとにちょうど1行を返さなければならないクエリでは、必ず
FINALを使ってください。重複排除のセマンティクス全体は MergeTree エンジン を参照してください。
ギャップ 5: Snowflake Streams (CDC)
Snowflake Streams はテーブルに対する行レベルの変更(INSERT、UPDATE、DELETE)を追跡します。METADATA$ACTION、METADATA$ISUPDATE、METADATA$ROW_ID というシステム列を公開します。ClickHouse には同等の内部機構がありません。
ClickHouse での等価物: プロデューサーの直接カットオーバー
ClickHouse には Snowflake Streams に相当する内部 CDC 機構がありません。この移行では、CDC コネクタを使うよりも単純なパターンを取ります。
- まずバルクロード —
scripts/02_migrate_trips.pyが Snowflake から過去の全行をバッチで読み取り、ClickHouse に挿入する - 次にプロデューサーをカットオーバー —
scripts/03_cutover.shが Snowflake のプロデューサーを止め、ClickHouse Cloud に直接書き込む ClickHouse プロデューサーを起動する - CDC のウィンドウは不要 — 過去データのロードはマイグレーションスクリプトが担い、ライブの書き込みはプロデューサーが引き継ぐ。
trips_rawのReplacingMergeTree(_synced_at)により、マイグレーションのリトライやプロデューサーのリトライは冪等になる
カットオーバー後は、分析層の upsert を dbt の delete_insert 戦略が処理します。Snowflake Streams と Tasks は完全に退役します。
ギャップ 6: 日付・時刻関数
Snowflake と ClickHouse では日付関数の名前が異なります。ほとんどは機械的な置き換えです。
| Snowflake | ClickHouse | 備考 |
|---|---|---|
DATE_TRUNC('hour', ts) | toStartOfHour(ts) | 他にも: toStartOfDay、toStartOfMonth、toStartOfWeek |
DATE_TRUNC('day', ts) | toDate(ts) | |
DATEADD('day', n, ts) | ts + INTERVAL n DAY | または addDays(ts, n) |
DATEDIFF('minute', t1, t2) | dateDiff('minute', t1, t2) | 関数名は小文字 |
CURRENT_DATE | today() | |
CURRENT_TIMESTAMP() | now() | |
TO_TIMESTAMP(epoch, 9) | fromUnixTimestamp64Nano(epoch) | CH では単位が明示的 |
YEAR(ts) | toYear(ts) | |
MONTH(ts) | toMonth(ts) | |
EXTRACT(epoch FROM ts) | toUnixTimestamp(ts) |
DateTime と DateTime64: ClickHouse の
DateTimeは秒精度です。ミリ秒精度(Snowflake のTIMESTAMP_NTZに合わせる場合)にはDateTime64(3, 'UTC')を使います。3は秒未満のスケール、'UTC'はタイムゾーンです。
3. データ移動の選択肢
| 手段 | 使う場面 | 備考 |
|---|---|---|
Python マイグレーションスクリプト (scripts/02_migrate_trips.py) | Snowflake → ClickHouse のバルクロード | snowflake-connector-python + clickhouse-connect による直接接続。再開可能で、追加のサービスは不要 — このラボで使う方法 |
| ClickPipes | Kafka、S3、Kinesis、PostgreSQL CDC、MySQL CDC | マネージドコネクタ。Snowflake はソースとしてサポートされない |
remoteSecure() | 別の ClickHouse サービスからのアドホックな取得 | Snowflake ソースには適用できない |
| オブジェクトストレージ経由 | 大規模な一度きりのロード | Snowflake → S3 → ClickHouse の S3 テーブル関数でエクスポート。AWS アカウントと IAM のセットアップが必要 |
| JDBC/ODBC | 独自の ETL パイプライン | 柔軟だが、独自のオーケストレーションが必要 |
このラボでは Python マイグレーションスクリプトが正しい選択です。追加のクラウドサービス(S3 も Kafka も)を必要とせず、完全にデバッグ可能で、パートナーがラボの他の手順のためにすでにインストールしているパッケージ(snowflake-connector-python、clickhouse-connect)を使うからです。
4. CDC アーキテクチャの比較
| Snowflake Streams + Tasks | ClickHouse(このラボ) | |
|---|---|---|
| 変更の追跡 | テーブル上の内部ストリームオブジェクト (TRIPS_CDC_STREAM) | 等価物なし — カットオーバー後はプロデューサーが ClickHouse に直接書き込む |
| 変更イベント | METADATA$ACTION: INSERT/UPDATE/DELETE | ClickHouse プロデューサーからの直接 INSERT |
| レイテンシ | タスクのスケジュールで設定(最短1分) | バッチ間隔で設定(デフォルト10秒) |
| 消費側 | SQL タスクがストリームを読み、ターゲットへ出力 | Python プロデューサー (producer/producer.py) |
| スキーマ変更 | 手作業での調整 | プロデューサーのコードがスキーマを制御 |
移行後は、プロデューサーが ClickHouse に直接書き込みます — Streams も Tasks も不要です。分析層の upsert は dbt の delete_insert 戦略が処理します。定期的な集計(Snowflake Tasks)には、ClickHouse ネイティブの代替として Refreshable Materialized Views があります。このラボの dbt プロジェクトには analytics.mv_live_trip_feed が1つ同梱されていますが、ラボではそのリフレッシュ間隔を有効化しません(モジュール05を参照)。
5. コストモデルの詳細
Snowflake: クレジット制
Snowflake のクレジットは約 $3(Enterprise)です。コスト = ウェアハウスサイズ × 稼働時間。SMALL のウェアハウスは1クレジット/時、MEDIUM は2クレジット/時を消費します。オートサスペンドの最短が60秒であるため、クエリが1本でも最低1/60時間分のコストがかかります。
NYC タクシーのラボ(X-Small ウェアハウス、1クレジット/時)の場合:
- パート1のセットアップ: 約2〜4クレジット(約 $6〜12)
- 以後8時間のセッションごと: 約4〜8クレジット/日(約 $12〜24)
- ANALYTICS_WH のリソースモニターは月50クレジット(約 $150)で上限を設定
ClickHouse Cloud: コンピュートとストレージが別
ClickHouse Cloud はコンピュートとストレージを別々に課金します。
- コンピュート: Development ティアはアクティブ時に約 $0.10/時、アイドル時はゼロまでスケール
- ストレージ: 約 $0.023/GB/月(Snowflake の $23/TB より大幅に安い)
- ClickPipes: サポートされるソース(Kafka、S3、Kinesis、PostgreSQL CDC、MySQL CDC — Snowflake は非対応)については Cloud サブスクリプションに含まれる
NYC タクシーのラボの場合:
- 5,000万行 × 約300バイト/行(非圧縮) = 約15GB → ClickHouse では約8GB(圧縮後)
- ストレージコスト: 約 $0.18/月
- パート3のラボがアクティブな間(約2時間)のコンピュート: 約 $0.20〜0.40
パート3の合計コスト: 約 $2〜4 — 同じセッションで Snowflake なら約 $6〜12。
このコスト差は、多くの組織が Snowflake から始め(運用がより単純なため)、分析ワークロードが拡大するにつれて ClickHouse へ移行する(コストがより低く、性能がより高いため)理由を説明しています。