ClickHouse 深度解析与面试指南¶
基于 olad_data_ap 项目实践,ClickHouse 作为核心 OLAP 引擎 来源:olad_data_ap/ARCH-02/00-技术架构总纲.md §10.4
一、为什么 ClickHouse 这么快?¶
1.1 核心原因(面试必背)¶
| 优化点 | 实现原理 | 效果 |
|---|---|---|
| 列式存储 | 同列数据连续存储(Column-oriented) | 只读取需要的列,减少 I/O |
| 向量化执行 | SIMD 指令一次处理多个数据 | CPU 充分利用,批量计算 |
| MergeTree 引擎 | 数据按主键排序后分区存储 | 稀疏索引快速跳过大量数据 |
| 数据压缩 | 列值重复多,压缩比高(10-30x) | 减少磁盘读取,提升缓存命中率 |
1.2 vs 其他 OLAP 引擎¶
ClickHouse vs 竞品:
- MySQL:行式存储,随机读写快但分析慢
- Hive/SparkSQL:基于 HDFS,延迟高
- Druid:实时但数据量有限
- Doris:兼容 MySQL 协议,但生态较新
ClickHouse 优势:超快聚合、压缩率高、SQL 友好
ClickHouse 劣势:无事务、并发有限、运维复杂度高
二、MergeTree 引擎族¶
2.1 表引擎选择(项目中的应用)¶
| 引擎 | 用途 | 项目对应 |
|---|---|---|
| MergeTree | 基础引擎,主键排序 | dws_ad_perf_daily 默认 |
| ReplacingMergeTree | 按主键去重 | 有更新场景的表 |
| SummingMergeTree | 自动聚合 | ADS 预聚合表 |
| AggregatingMergeTree | 增量聚合 | 物化视图 |
| CollapsingMergeTree | 软删除 | 状态变更表 |
| VersionedCollapsingMergeTree | 增量变更历史 | 复杂状态表 |
2.2 主键与排序键设计(核心面试点)¶
-- 项目中的实际设计(来自 ARCH-00 §10.4)
-- 排序键:(tenant_id, dt, source, account_id)
-- 分区键:toYYYYMM(dt)
CREATE TABLE dws_ad_perf_daily
(
tenant_id UInt32,
dt Date,
source String,
account_id UInt32,
spend Decimal(18, 2),
revenue Decimal(18, 2),
...
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(dt)
ORDER BY (tenant_id, dt, source, account_id)
TTL dt + INTERVAL 730 DAY
面试追问:排序键选择原则
Q: 排序键为什么这么选?
A:
1. tenant_id 放最前面 → 多租户隔离,按租户查询可快速裁剪
2. dt 其次 → 时间过滤最常见,分区内快速定位
3. source/account_id → 高频维度放在后边减少数据量
Q: 排序键和主键的区别?
A:
- Primary Key = 数据唯一性约束(可重复)
- ORDER BY = 数据物理排序顺序(决定索引结构)
- ClickHouse 中两者可以相同也可以不同
Q: 分区键为什么按月?
A:
- 分区数过多(>100)会增加元数据开销
- 按月:1年=12分区,5年=60分区,合理
- 高频数据可改用 toYYYYMMDD 按天分区
2.3 跳数索引(Skip Index)¶
-- 项目中用到 minmax / set 索引
-- 用于高基数维度过滤
ALTER TABLE dws_ad_perf_daily
ADD INDEX idx_set_account (account_id) TYPE set(1000);
ALTER TABLE dws_ad_perf_daily
ADD INDEX idx_minmax_country (country) TYPE minmax;
| 索引类型 | 适用场景 |
|---|---|
| minmax | 等值查询(country = 'US') |
| set | 高频枚举值(account_id IN (...)) |
| bloom_filter | 高基数字符串(URL、用户ID) |
| ngrambf_v1 | 字符串模糊匹配 |
三、项目中的实际应用¶
3.1 ODS 层(原始数据)¶
-- 来自 infra/clickhouse/init.sql
-- 特点:仅 INSERT,禁止 UPDATE/DELETE
CREATE TABLE ods_meta_ads_campaign_raw
(
account_id String,
campaign_id String,
campaign_name String,
status String,
dt Date,
ingested_at DateTime DEFAULT now()
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(dt)
ORDER BY (account_id, campaign_id, dt)
3.2 DWD 层(清洗标准化)¶
-- 事件标准化,维度统一
-- 项目中对应 dwd_ad_event
CREATE TABLE dwd_ad_event
(
tenant_id UInt32,
event_date Date,
source Enum8('meta' = 1, 'google' = 2, 'tiktok' = 3),
account_id String,
campaign_id String,
ad_id String,
-- 标准化事件
event_type Enum8('impression' = 1, 'click' = 2, 'conversion' = 3),
event_value Decimal(18, 4),
-- 维度
country String,
device Enum8('mobile' = 1, 'desktop' = 2),
...
)
ENGINE = ReplacingMergeTree(event_date)
PARTITION BY toYYYYMM(event_date)
ORDER BY (tenant_id, event_date, source, account_id, ad_id)
3.3 DWS 层(主题宽表)¶
-- 来自 dbt/models/marts/dws_ad_perf_daily.sql
-- 每天一个租户+账户+来源+维度组合的汇总
CREATE TABLE dws_ad_perf_daily
(
tenant_id UInt32,
dt Date,
source String,
account_id UInt32,
-- 国家×设备粒度
country String,
device String,
-- 指标
impressions UInt64,
clicks UInt64,
conversions UInt64,
spend_usd Decimal(18, 2),
revenue_usd Decimal(18, 2),
-- 派生指标(计算字段)
ctr Float64,
cvr Float64,
roas Float64
)
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(dt)
ORDER BY (tenant_id, dt, source, account_id, country, device)
PRIMARY KEY (tenant_id, dt)
四、查询优化技巧¶
4.1 PREWHERE(列裁剪)¶
-- 只查需要的列,避免全列读取
SELECT
account_id,
spend,
revenue
FROM dws_ad_perf_daily
WHERE
tenant_id = 1
AND dt = '2026-05-01'
AND source = 'meta'
PREWHERE tenant_id -- 提前过滤,大幅减少数据量
4.2 FINAL vs 非 FINAL¶
-- ReplacingMergeTree 表必须加 FINAL 才能获取最终状态
-- 但性能损耗大,慎用
SELECT * FROM dwd_ad_event FINAL; -- 去重后结果
SELECT * FROM dwd_ad_event; -- 可能有重复
4.3 采样查询¶
-- 大表采样,加速开发/预览
SELECT * FROM dws_ad_perf_daily SAMPLE 0.01; -- 1% 采样
4.4 物化视图(预聚合)¶
-- 项目中用于加速看板查询
-- 来自 ARCH-00 §10.3 查询路由策略
CREATE MATERIALIZED VIEW ads_dashboard_top_campaign
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(dt)
ORDER BY (tenant_id, dt, source, campaign_id)
AS
SELECT
tenant_id,
dt,
source,
campaign_id,
sum(spend) AS total_spend,
sum(revenue) AS total_revenue,
sum(clicks) AS total_clicks
FROM dws_ad_perf_daily
GROUP BY tenant_id, dt, source, campaign_id
五、面试高频问题¶
Q1: ClickHouse 为什么比 MySQL 快 100 倍?¶
答:四个原因叠加
1. 列式存储:只读需要的列,I/O 减少 90%+
2. 向量化执行:SIMD 批量计算,CPU 利用率提升
3. 主键排序+稀疏索引:快速跳过不需要的数据块
4. 数据压缩:压缩比 10-30x,内存和磁盘操作大幅减少
追问:如果表很大(10亿行),查询还是慢怎么办?
答:
1. 检查 WHERE 条件是否利用了主键(前缀匹配)
2. 考虑增加跳数索引(minmax/set/bloom)
3. 创建物化视图预聚合
4. 调整分区策略(避免过多小分区)
5. 考虑分表或集群(Sharding)
Q2: 数据什么时候合并?¶
答:
1. 后台自动合并(MergeTree 机制)
2. 触发时机:分区合并、同一分区内数据积累到一定量
3. FINAL 可以强制获取合并后结果(但有性能损耗)
Q3: 如何保证 Exactly-Once?¶
答:ClickHouse 本身不保证,需要外部机制
1. 写入端:事务 + 幂等去重(GROUP BY / ReplacingMergeTree)
2. 消费端:checkpoint 记录偏移量
3. 项目中用 Airflow DAG 调度,保证重跑不会重复计算
Q4: 分区键和排序键的区别?¶
答:
- 分区键(PARTITION BY):控制数据文件的目录划分
- 排序键(ORDER BY):决定数据在文件内的物理顺序
选择原则:
- 分区键:通常按时间,减少扫描范围
- 排序键:高频查询字段放前面,支持范围查询
六、项目实践 Checklist¶
- 理解 MergeTree 引擎选择(DWD 用 ReplacingMergeTree,DWS 用 SummingMergeTree)
- 能解释排序键选择逻辑(tenant_id + dt + source + account_id)
- 理解分区键策略(按月分区,避免过多分区)
- 掌握 PREWHERE / FINAL / SAMPLE 用法
- 能设计简单的物化视图加速查询
- 理解查询路由:看板 → ADS 预聚合 → Redis 缓存 → DWS
相关文档: - olad_data_ap 技术架构总纲 §10 - dbt 学习路径