跳转至

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 学习路径