ClickHouse MergeTree 存储引擎深度实战:从稀疏索引、Granule 到后台合并与去重语义的工程全解
如果你带着 B+ 树数据库的直觉去读 ClickHouse,几乎每一个假设都会被推翻:它没有 B+ 树,没有行级索引,没有原地更新,甚至 PRIMARY KEY 也不保证唯一。然而正是这套"什么都没有"的设计,让它能在百亿行表上把一次聚合压到亚秒级。
理解 MergeTree 的关键,不在于记住语法,而在理解它如何用不可变有序文件 + 稀疏索引 + 后台归并这三件事,把一个 OLAP 系统拆成了 LSM-Tree 的一个极简特例。
一、心智模型:Part、Granule 与 Mark
MergeTree 表在磁盘上不是一个大文件,而是一堆不可变的数据片段(Part)。每次 INSERT 生成一个新 Part,Part 内部按 ORDER BY 键全局有序;后台线程持续把小 Part 归并成大 Part。这就是 "MergeTree" 的名字来源。
CREATE TABLE events
(
tenant_id UInt32,
user_id UInt64,
event_time DateTime,
event_type LowCardinality(String),
revenue Decimal(12, 2)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (tenant_id, event_time, user_id)
SETTINGS index_granularity = 8192;
Part 内部再切成 Granule,一个 Granule 默认 8192 行(index_granularity),是 ClickHouse 最小的读取单位——注意不是压缩单位,也不是写入单位。压缩是独立的:列数据被切成压缩块(通常 64KB~1MB 原始字节)后单独压缩。
于是每个列文件(.bin)旁边都有一个 mark 文件(.mrk2),它记录每个 Granule 在压缩文件中的压缩块偏移 + 块内解压偏移。这个二级寻址很关键:它让 ClickHouse 能跳过整个压缩块,直接定位到一个 Granule 的起点,而不必解压前面所有数据。
二、稀疏索引:primary.idx 里到底存了什么
primary.idx 常驻内存,它不是每行一条记录,而是每个 Granule 的第一行主键值,共 N/8192 条。一个十亿行的表,主键索引也只有约 12 万条,几 MB 而已——这就是它能全量驻留内存的原因。
SELECT name, marks, rows, bytes_on_disk
FROM system.parts_columns
WHERE table = 'events' AND active AND name = 'user_id';
查询 WHERE tenant_id = 42 AND event_time BETWEEN ... 时,执行过程是这样的:
- 在
primary.idx上二分查找,定位可能命中的 Granule 区间; - 用
.mrk2把这些 Granule 的偏移解出来,只读取对应的压缩块; - 解压后向量化扫描,过滤出真实命中行。
注意第 3 步:Granule 是可能命中,不是精确命中。稀疏索引只负责把 100 万个 Granule 缩到 20 个,剩下的靠扫描。这是理解 MergeTree 性能的第一性原理——索引的收益与 ORDER BY 键的排序质量成正比,与索引数量无关。
一个反直觉的坑
很多人会额外建 PRIMARY KEY (tenant_id) 以为能加速:
ORDER BY (tenant_id, event_time, user_id)
PRIMARY KEY (tenant_id) -- 通常是错的
PRIMARY KEY 只能是 ORDER BY 的前缀。缩短它只会让 primary.idx 变小、粒度变粗,过滤掉的 Granule 更少,查询几乎必然变慢。默认不写 PRIMARY KEY —— 让它等于 ORDER BY —— 在 99% 的场景下都是最优解。
三、跳数索引:给无序列补一张网
当查询条件落在非 ORDER BY 前缀列上(比如 event_type),稀疏索引就失效了,只能全 Granule 扫描。此时用跳数索引(Data Skipping Index)在 Granule 粒度上再建一层粗过滤:
ALTER TABLE events ADD INDEX idx_type event_type TYPE set(0) GRANULARITY 4;
ALTER TABLE events ADD INDEX idx_user user_id TYPE bloom_filter(0.01) GRANULARITY 4;
ALTER TABLE events MATERIALIZE INDEX idx_type;
三种常用类型要按数据分布选:
| 类型 | 适用场景 | 代价 |
|---|---|---|
minmax | 与排序键相关、局部聚集的数值/时间 | 极小 |
set(N) | 低基数且每批 Granule 内取值很少 | 随基数增长 |
bloom_filter(p) | 高基数的点查(ID、URL) | 中等,有误判率 |
GRANULARITY 4 表示每 4 个 Granule(32768 行)建一条索引记录。它只能回答"这个区间肯定不含目标值",不能回答"包含"——所以命中率低的跳数索引是纯负优化,既占磁盘又拖慢合并。上线前务必用 system.query_log 对比 SelectedParts 与 SelectedRanges 是否真的下降。
四、PARTITION BY 不是索引,是目录
分区在 ClickHouse 里比在 MySQL 里弱得多。它不加速点查,它的价值只有两个:按分区 TTL 批量淘汰数据,以及分区级裁剪。
-- 按月分区,30 天后整目录 drop,零 IO 放大
ALTER TABLE events DROP PARTITION '202601';
分区裁剪之所以有效,是因为每个 Part 都会记录分区键的 min-max 值到内存里,查询时先排除整个分区目录。但代价是:分区过细会产生海量小 Part。日分区尚可,按小时分区在高频写入下几乎必然踩到 Too many parts。
经验法则:单个分区的 Part 数控制在 100 以内,单表总 Part 数控制在 10000 以内。
五、Too many parts:最经典的生产事故
Code: 252. DB::Exception: Too many parts (300).
Merges are processing significantly slower than inserts.
根因永远是同一条:写入批次太小、太频繁。每批 10 行插 1000 次,就会瞬间产生 1000 个 Part,而后台合并线程跟不上。
三种修法,按优先级:
-- 1. 客户端攒批:单次 INSERT 1 万~10 万行,这是最有效的
-- 2. 无法改客户端时,开启异步插入,由服务端攒批
SET async_insert = 1, wait_for_async_insert = 1,
async_insert_max_data_size = 10000000;
<!-- 3. 缓解阈值(治标,配合上面使用) -->
<merge_tree>
<parts_to_throw_insert>600</parts_to_throw_insert>
<max_bytes_to_merge_at_max_space_in_pool>161061273600</max_bytes_to_merge_at_max_space_in_pool>
</merge_tree>
判断合并是否健康,直接看合并队列:
SELECT database, table, elapsed, progress,
num_parts, total_size_bytes_compressed,
formatReadableSize(total_size_bytes_compressed) AS size
FROM system.merges;
如果 num_parts 长期大于 100,或者 elapsed 持续走高,说明写入模式已经超过磁盘合并能力,必须上游攒批。
六、ReplacingMergeTree:去重是"最终"的,不是"立即"的
需要按主键保留最新版本时(典型场景:CDC 入湖),用 ReplacingMergeTree:
CREATE TABLE events_rep
(
tenant_id UInt32,
user_id UInt64,
event_time DateTime,
revenue Decimal(12,2),
version UInt64
)
ENGINE = ReplacingMergeTree(version)
PARTITION BY toYYYYMM(event_time)
ORDER BY (tenant_id, user_id, event_time);
它只在合并时去重,且只在同一个 Part 或一次合并涉及的 Part 之间去重。这意味着:
- 刚写入的重复数据在合并前仍然可见;
- 跨分区的重复行永远不会互相覆盖。
所以查询必须显式声明"我要已去重的结果":
-- 代价高:强制在读取时做一次归并
SELECT count() FROM events_rep FINAL;
-- 推荐:把去重下推到聚合,让引擎流式处理
SELECT tenant_id, count()
FROM events_rep
GROUP BY tenant_id;
FINAL 在 23.x 之后已被大幅优化(支持 do_not_merge_across_partitions_select_final 与并行处理),但仍然是全量归并语义。生产上更稳的做法是:写入侧保证幂等(固定 block_id 触发 ReplicatedMergeTree 的插入去重),查询侧用 argMax() 兜底。
SELECT user_id, argMax(revenue, version) AS latest_revenue
FROM events_rep
GROUP BY user_id;
七、预聚合的两种流派:物化视图 vs Projection
物化视图是插入触发器,写入源表时同步写一张聚合表,把成本挪到写入侧:
CREATE TABLE events_daily
(
tenant_id UInt32,
day Date,
pv SimpleAggregateFunction(sum, UInt64),
uv AggregateFunction(uniq, UInt64)
)
ENGINE = AggregatingMergeTree()
ORDER BY (tenant_id, day);
CREATE MATERIALIZED VIEW mv_events_daily TO events_daily AS
SELECT tenant_id, toDate(event_time) AS day,
count() AS pv,
uniqState(user_id) AS uv
FROM events
GROUP BY tenant_id, day;
查询时必须用对应的 -Merge 后缀函数:SELECT uniqMerge(uv) FROM events_daily。
Projection 则是 Part 内部的隐藏副结构,相当于在同一份数据里存了另一种排序或预聚合结果,优化器能自动选用:
ALTER TABLE events ADD PROJECTION proj_type
(SELECT tenant_id, event_type, count() GROUP BY tenant_id, event_type);
ALTER TABLE events MATERIALIZE PROJECTION proj_type;
取舍很清晰:物化视图灵活、可读、跨表,但要维护写入链路与一致性;Projection 对查询透明、无需改 SQL,但会显著增加合并开销与磁盘占用,且只在查询模式稳定时划算。
八、一份可落地的 MergeTree Checklist
ORDER BY就是性能本身:把等值过滤列放前、范围列次之、高基数列最后;不要指望靠加索引救回错误的排序键。- 写批 ≥ 1 万行/次,让 Part 数而不是行数成为瓶颈。
- 分区按月,除非有明确的 TTL 淘汰需求再细化。
- 慎用
FINAL,优先argMax/ 聚合下推。 - 低基数列用
LowCardinality(String),高基数列加bloom_filter跳数索引前先测命中率。 - 给数值/时间列配 codec:
CODEC(Delta, ZSTD(1))对单调递增列通常能再压 3~5 倍。 - 监控
system.merges与system.parts,Part 数是 MergeTree 的血压计,比 CPU 更早报警。
MergeTree 的优雅之处在于:它把 OLAP 的复杂性从"查询时想办法算快"转移到了"写入时把数据排好"。一旦接受了这个前提——顺序即索引、不可变即吞吐——它几乎所有反直觉的设计都会变得顺理成章。

发表评论 取消回复