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 ... 时,执行过程是这样的:

  1. 在 primary.idx 上二分查找,定位可能命中的 Granule 区间;
  2. 用 .mrk2 把这些 Granule 的偏移解出来,只读取对应的压缩块;
  3. 解压后向量化扫描,过滤出真实命中行。

注意第 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 的复杂性从"查询时想办法算快"转移到了"写入时把数据排好"。一旦接受了这个前提——顺序即索引、不可变即吞吐——它几乎所有反直觉的设计都会变得顺理成章。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部