Snowflake 与 ClickHouse 对比
两个引擎在存储、计算和 SQL 方言上的差异,以及哪些 Snowflake 惯用写法在 ClickHouse 中没有直接对应物。
本文是面向从 Snowflake 迁移到 ClickHouse 的合作伙伴的参考资料。它涵盖了驱动设计决策的架构差异,以及你在 NYC Taxi 工作负载中会遇到的六个 SQL 方言差异。
1. 架构对比
存储
Snowflake 替你做出所有物理存储决策。数据以压缩的列式微分区(micro-partition)形式存放在云对象存储中。你只需选择仓库规格和表结构;其余一切由 Snowflake 处理,聚簇、压实和文件管理都是自动的。
ClickHouse 要求你显式做出物理存储决策。创建表时,你需要指定:
- engine(决定数据如何存储、合并和去重)
- ORDER BY(它就是物理排序顺序和主索引)
- 可选项:PARTITION BY、TTL、SETTINGS(压缩编解码器、合并行为)
这些是正确性决策,不是性能调优旋钮。选错引擎会静默产生错误的查询结果。选错 ORDER BY 会让本该很快的查询变成全表扫描。
查询执行
Snowflake 使用基于虚拟仓库的无共享 MPP 架构。一个仓库就是一组处理查询的计算节点。仓库运行期间你都要付费,空闲时间同样消耗 credit。自动挂起有所帮助,但冷启动会增加延迟。
ClickHouse 使用向量化执行。ClickHouse Cloud 会独立地为每个计算服务自动扩缩容,空闲时缩容到零。多个计算服务可以共享同一份存储(通过 SharedMergeTree),这就是 ClickHouse Cloud 的计算-计算分离模型,其中每个服务都是共用数据层之上的一个独立计算层。
并发模型
Snowflake 通过创建独立的仓库来隔离工作负载。ETL 使用 TRANSFORM_WH,分析使用 ANALYTICS_WH。每个仓库都有专属计算资源;一个缓慢的 ETL 作业不会拖垮分析查询。
ClickHouse Cloud 通过计算-计算分离支持同样的模式:你可以预置多个共享同一份存储的计算服务。每个服务都是一个独立的自动扩缩容计算层,ETL 跑在一个服务上,交互式分析跑在另一个上,彼此之间没有资源争用。在单个服务内部,工作负载隔离通过软配额(按用户或按查询的 max_threads、priority、max_memory_usage)和带资源限制的用户 profile 来实现。对于大多数查询在毫秒级完成的分析型工作负载,单个服务已经足够,按查询配额则是更轻量的选项。
成本模型
| Snowflake | ClickHouse Cloud | |
|---|---|---|
| 计算 | Credit(仓库运行秒数) | 计算单元(与存储分开计费) |
| 存储 | $23/TB/月 | 约 $0.023/GB/月(更便宜) |
| 缩容到零 | 仅自动挂起 | 完整支持缩容到零 |
| 数据传输 | 入站免费;出站收费 | 标准云出站费率 |
最显著的差异是:在 Snowflake 中,无论是否有查询在跑,你都要为仓库运行时间付费。在 ClickHouse Cloud 中,查询之间计算资源会缩容到零。对于突发式分析型工作负载,ClickHouse Cloud 通常比同等配置的 Snowflake 便宜 3-8 倍。
2. SQL 方言差异
NYC Taxi 工作负载包含六种需要转换的构造。它们全部出现在 01-setup-snowflake/queries/ 的 Q1–Q7 中。
差异 1:QUALIFY
QUALIFY 是 Snowflake 的扩展语法,用于按窗口函数结果过滤行,类似于 HAVING 按聚合结果过滤。在本次迁移中,我们把 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 引擎是什么它都能工作,并且让窗口函数结果变得显式。任何 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类型(此前为实验性)在较新的 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当数组具有有界、已知的结构时,首选预展开方案(方案 2)。对于临时查询或数组长度可变的情况,首选 arrayJoin(方案 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 layer对于分析型模型,dbt-clickhouse 中的 delete_insert 增量策略是在语义上最接近 MERGE INTO 的做法。它会删除与来批数据中任一键匹配的已有行,然后插入全部来批行,按分区原子地完成。
ReplacingMergeTree 的关键陷阱: 后台去重是异步的。在两次合并之间,一行的旧版本和新版本会同时存在于表中。对于必须每个键只返回一行的查询,务必使用
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)使任何迁移重试或生产者重试都是幂等的
切换完成后,dbt 的 delete_insert 策略负责分析层的 upsert。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(本实验课) | |
|---|---|---|
| 变更跟踪 | 表上的内部 stream 对象(TRIPS_CDC_STREAM) | 无等价物,切换后生产者直接写入 ClickHouse |
| 变更事件 | METADATA$ACTION:INSERT/UPDATE/DELETE | 来自 ClickHouse 生产者的直接 INSERT |
| 延迟 | 可配置的 task 调度(最短 1 分钟) | 可配置的批次间隔(默认 10 秒) |
| 消费方式 | SQL task 读取 stream 并写入目标 | Python 生产者(producer/producer.py) |
| 结构变更 | 手工协调 | 由生产者代码控制结构 |
迁移后,生产者直接写入 ClickHouse,不需要 Streams 或 Tasks。dbt 的 delete_insert 策略负责分析层的 upsert。周期性聚合(Snowflake Tasks)在 ClickHouse 中的原生替代是可刷新 materialized view,本实验课的 dbt 项目提供了一个 analytics.mv_live_trip_feed,不过实验课并未开启它的刷新间隔(参见模块 05)。
5. 成本模型深入解析
Snowflake:基于 credit
一个 Snowflake credit 约合 $3(Enterprise 版)。成本 = 仓库规格 × 运行时间。SMALL 仓库每小时消耗 1 credit,MEDIUM 消耗 2。自动挂起最短为 60 秒,这意味着即便只跑一条查询,也至少要花掉 1/60 小时的费用。
对于 NYC Taxi 实验课(X-Small 仓库,1 credit/小时):
- 第 1 部分的搭建:约 2–4 credit(约 $6–12)
- 后续每次 8 小时会话:约 4–8 credit/天(约 $12–24)
- ANALYTICS_WH 的资源监控器上限为每月 50 credit(约 $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 Taxi 实验课:
- 5000 万行 × 每行约 300 字节,未压缩约 15GB → 在 ClickHouse 中压缩后约 8GB
- 存储成本:约 $0.18/月
- 第 3 部分实验活跃期间(约 2 小时)的计算:约 $0.20–0.40
第 3 部分总成本:约 $2–4,而 Snowflake 同样一次会话约需 $6–12。
这一成本差异解释了为什么许多组织从 Snowflake 起步(运维更简单),随着分析型工作负载增长再迁移到 ClickHouse(成本更低 + 性能更高)。