3. 基础表引擎:MergeTree 家族
说到 ClickHouse,绕不开的就是 MergeTree。我刚开始接触 ClickHouse 时,第一反应是:这玩意儿跟 MySQL 的 InnoDB 差别也太大了。后来用顺手了才发现,MergeTree 才是 ClickHouse 高性能的秘密武器。
说白了,MergeTree 不是一个单一的表引擎,而是一整个家族。你想想看,ClickHouse 能处理 PB 级数据,靠的就是这套机制。我个人习惯把 MergeTree 家族分成三类:基础款、聚合款、分布式款。这一章我们先啃最核心的基础款。
核心观点: MergeTree 是 ClickHouse 所有表引擎的基石。你后面学到的 ReplacingMergeTree、SummingMergeTree,都是在 MergeTree 上加了特定逻辑。所以这一章必须吃透。
3.1 MergeTree 表引擎原理
MergeTree 的核心思想,我总结为三个关键词:分区、排序、合并。咱们一个一个拆开讲。
3.1.1 数据分区
每次写入数据,ClickHouse 不会直接往一个大文件里追加。它会根据你指定的分区键,把数据写到不同的分区目录里。每个分区目录下,又会有多个数据片段(Data Part)。
举个例子:
-- 按月份分区
CREATE TABLE my_table (
event_date Date,
user_id UInt32,
event_type String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, user_id);
写入数据后,你会在磁盘上看到类似这样的目录结构:
/var/lib/clickhouse/data/default/my_table/
├── 202503_1_1_0/
├── 202504_1_1_0/
└── 202505_1_1_0/
每个目录名里的数字,分别代表:分区ID、最小块编号、最大块编号、块级别。嗯,这里要注意:块级别为 0 表示这是刚写入的原始片段,还没合并过。
3.1.2 排序键与稀疏索引
MergeTree 的排序键,不是用来做唯一约束的。它的作用是决定数据在每个分区内的物理存储顺序。
我遇到过不少新手,以为 ORDER BY 就是主键,其实不是。MergeTree 没有传统意义上的主键约束,它只有排序键和主键(可以理解为排序键的前缀)。
为什么排序这么重要?因为 ClickHouse 会基于排序键生成稀疏索引。默认每 8192 行数据生成一个索引标记。查询时,先通过稀疏索引定位到可能包含目标数据的数据块,再扫描。这比全表扫描快了几个数量级。
我的经验: 排序键的选择直接影响查询性能。我一般把最常用的过滤条件字段放在排序键最前面。比如日志分析场景,时间字段永远是第一位的。
3.1.3 后台合并机制
写入数据时,每个批次都会生成一个独立的数据片段。如果放任不管,片段数量会爆炸。MergeTree 的后台线程会定期把这些小片段合并成更大的片段。
合并过程是异步的,不会阻塞写入。合并完成后,旧片段会被标记为删除,新片段上线。这个机制保证了:
- 写入速度快:每次写入只追加一个新片段,不需要原地修改
- 查询效率高:合并后的大片段,索引粒度更粗,扫描更快
- 资源可控:合并线程数、内存大小都可以配置
我曾经在生产环境遇到过一个问题:写入量太大,合并速度跟不上,导致片段数飙到几万个。查询直接变慢。后来调整了 merge_tree.max_part_loading_threads 和 merge_tree.max_suspicious_broken_parts 才稳住。
3.2 建表与数据写入
光说不练假把式。咱们直接上手建个表,写点数据进去。
3.2.1 建表语法详解
一个完整的 MergeTree 建表语句长这样:
CREATE TABLE events (
event_date Date,
event_time DateTime,
user_id UInt32,
event_name String,
event_value Float64
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, event_time)
PRIMARY KEY (event_date, event_time)
SETTINGS index_granularity = 8192;
几个关键参数:
| 参数 | 说明 | 我的建议 |
|---|---|---|
PARTITION BY |
分区键,决定数据如何分目录存储 | 按时间分区最常用,粒度别太细 |
ORDER BY |
排序键,决定分区内数据物理顺序 | 把过滤频率最高的字段放前面 |
PRIMARY KEY |
主键,默认与排序键相同 | 一般不用单独指定,除非你想让索引更稀疏 |
index_granularity |
索引粒度,默认8192行 | 数据量大可以调大,但别超过65536 |
避坑指南: 我曾经犯过一个错——分区键粒度太细。比如按天分区,每天数据量只有几百MB。结果分区数上万,ClickHouse 打开文件句柄数直接爆了。建议每个分区至少 1GB 以上数据量。
3.2.2 数据写入方式
ClickHouse 支持多种写入方式。最常用的是 INSERT INTO ... VALUES:
INSERT INTO events VALUES
('2025-03-01', '2025-03-01 10:00:00', 1001, 'page_view', 1.0),
('2025-03-01', '2025-03-01 10:01:00', 1002, 'click', 0.5);
但生产环境很少这么干。更常见的是从文件导入:
-- 从 CSV 文件导入
INSERT INTO events FORMAT CSV
< /data/events_20250301.csv
或者用 SELECT 语句从其他表插入:
INSERT INTO events
SELECT * FROM events_staging
WHERE event_date = '2025-03-01';
你想想看,为什么 ClickHouse 的写入这么快?因为每次写入只是创建一个新片段,不需要像 MySQL 那样维护 B+ 树索引。这就是 MergeTree 的设计哲学——写入时不做复杂操作,把工作留给后台合并。
3.2.3 写入后的数据验证
写入完成后,我习惯用几个命令检查一下:
-- 查看分区信息
SELECT partition, name, rows, bytes_on_disk
FROM system.parts
WHERE table = 'events';
-- 查看数据片段数
SELECT count() AS part_count
FROM system.parts
WHERE table = 'events' AND active = 1;
如果片段数超过 100 个,说明合并压力有点大。这时候可以手动触发合并:
OPTIMIZE TABLE events FINAL;
不过要注意,OPTIMIZE 是同步操作,大表会阻塞很久。我一般只在维护窗口执行。
3.3 MergeTree 核心流程图
下面这张图,把 MergeTree 从写入到查询的完整流程串起来了。我画这张图时,特意把合并过程拆成了三个阶段,方便你理解。
3.4 常见问题与调优建议
最后,分享几个我在项目中踩过的坑,以及对应的解决方案。
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 分区数过多 | 打开文件句柄数爆增,查询变慢 | 合并小分区,或调整分区粒度 |
| 片段数过多 | 合并线程忙不过来,查询扫描大量小片段 | 调大 merge_tree.max_part_loading_threads |
| 索引粒度不合适 | 查询扫描过多数据块 | 根据数据量调整 index_granularity |
| 排序键选择不当 | 过滤条件无法利用索引 | 把高频过滤字段放排序键最前面 |
我的习惯: 每次建表前,我都会先想清楚查询模式。比如这个表主要按什么字段过滤?排序范围是时间还是ID?想清楚了再写建表语句。磨刀不误砍柴工。
好了,这一章的内容就到这里。MergeTree 的原理和建表操作,是后续所有高级引擎的基础。你只要把分区、排序、合并这三个概念吃透,后面的 ReplacingMergeTree、SummingMergeTree 学起来会轻松很多。