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 从写入到查询的完整流程串起来了。我画这张图时,特意把合并过程拆成了三个阶段,方便你理解。

MergeTree 数据写入与合并流程 INSERT 写入 创建数据片段 (Part) 写入磁盘目录 后台合并线程持续运行 扫描小片段 合并排序数据 生成新大片段 查询时:稀疏索引定位 → 扫描目标数据块 → 返回结果 关键说明: • 写入不阻塞查询,查询不阻塞写入 • 合并完成后,旧片段标记删除,新片段上线 • 稀疏索引基于排序键,默认每8192行一个索引标记

3.4 常见问题与调优建议

最后,分享几个我在项目中踩过的坑,以及对应的解决方案。

问题 现象 解决方案
分区数过多 打开文件句柄数爆增,查询变慢 合并小分区,或调整分区粒度
片段数过多 合并线程忙不过来,查询扫描大量小片段 调大 merge_tree.max_part_loading_threads
索引粒度不合适 查询扫描过多数据块 根据数据量调整 index_granularity
排序键选择不当 过滤条件无法利用索引 把高频过滤字段放排序键最前面

我的习惯: 每次建表前,我都会先想清楚查询模式。比如这个表主要按什么字段过滤?排序范围是时间还是ID?想清楚了再写建表语句。磨刀不误砍柴工。

好了,这一章的内容就到这里。MergeTree 的原理和建表操作,是后续所有高级引擎的基础。你只要把分区、排序、合并这三个概念吃透,后面的 ReplacingMergeTree、SummingMergeTree 学起来会轻松很多。