ClickHouse中的MergeTree:引擎如何将分析数据切分为颗粒并合并分区
七年的痛苦、三次生产事故,以及一块架构牌匾
当我第一次听说MergeTree时,心想:“又是一个花哨名字的引擎。”后来在生产环境中,一张包含5亿条投注记录的表开始拖慢原本飞快的查询。我们查看EXPLAIN,看到Read 250000 granules。那时,我还不知道granule是什么。
结果发现,我创建表时用了错误的ORDER BY。每次查询扫描了80%的数据,尽管只过滤了一个列。
MergeTree不仅仅是一个引擎。它是一种架构,决定了数据如何存储在磁盘上、如何压缩,最重要的是——ClickHouse如何决定读取哪些数据块、跳过哪些。理解其内部机制拯救了我的三个项目。下面是我五年来一直遵循的路线图。
1. 分区、颗粒、标记:磁盘上的套娃
ClickHouse不会将表存储为单个文件。它将数据拆分为分区,每个分区内再拆分为颗粒,并通过标记进行导航。
表bets的磁盘结构:
/var/lib/clickhouse/data/betting/bets/
├── 202401_1_1_0/ # 分区 #1(2024年1月)
│ ├── user_id.bin # user_id列(二进制数据)
│ ├── user_id.mrk # user_id的标记
│ ├── created_at.bin
│ ├── created_at.mrk
│ ├── amount.bin
│ ├── amount.mrk
│ └── ...
├── 202401_2_2_0/ # 分区 #2
└── 202402_3_3_0/ # 分区 #3(2月)
分区——MergeTree管理的最小单元。每次插入都会创建一个分区,然后在后台与相邻分区合并。
颗粒——index_granularity行(默认8192)的数据块。ClickHouse按整个颗粒读取数据。你不能读取单行——只能读取整个颗粒。
标记——指向.bin文件中颗粒位置的指针。.mrk文件包含偏移量:颗粒在磁盘上的起始位置及其偏移量。
为什么这很重要: 当你运行SELECT amount FROM bets WHERE user_id = 123时,ClickHouse使用稀疏索引确定哪些颗粒可能包含这个user_id,并只读取这些颗粒。它甚至不会打开其他颗粒。
2. 合并分区:为什么ClickHouse不会被百万次小插入搞垮
每次INSERT都会在磁盘上创建一个新分区。如果你插入100条记录10,000次——你会有10,000个分区。这是一场灾难:一个查询必须打开10,000个文件。
ClickHouse如何拯救:
后台的合并过程将小分区粘合为更大的分区。例如:
- 10个1GB的分区 → 1个10GB的分区
我在生产环境中调整的参数:
<merge_tree>
<min_rows_for_wide_part>100000</min_rows_for_wide_part>
<max_bytes_for_merge>100000000000</max_bytes_for_merge> <!-- 100 GB -->
<merge_with_ttl_timeout>3600</merge_with_ttl_timeout>
</merge_tree>
专业提示: 如果你进行大插入(100万行以上),该分区不会与其他分区合并,直到出现相邻分区。ClickHouse按键的升序存储分区,所以INSERT ... ORDER BY会有所帮助。
我踩过的坑: 我们通过Kafka以每秒10-50条记录的速度流式传输投注数据。一周后,我们有30万个分区。查询变慢,因为每个查询都要打开所有文件。我们通过将min_rows_for_wide_part提高到50万,并将max_insert_block_size增加到100万来修复。数据流必须在Kafka中缓冲,但分区数量减少到了500个。
3. PRIMARY KEY vs ORDER BY:最常见的初学者错误
在MySQL中,PRIMARY KEY是唯一标识符。在ClickHouse中,并非如此。
-- 我经常看到这个
CREATE TABLE bets (
user_id UInt64,
created_at DateTime,
amount Decimal(18,2)
) ENGINE = MergeTree()
PRIMARY KEY (user_id) -- ← 错误
ORDER BY (user_id); -- ← 也是错误
真相:
- ORDER BY 决定了行在磁盘上的物理顺序。必填。
- PRIMARY KEY 如果未指定,则与ORDER BY相同。但它可以是ORDER BY的前缀。
正确做法:
ORDER BY (created_at, user_id) -- 时间优先,然后用户
PRIMARY KEY (created_at) -- 仅对时间建立索引
发生了什么: ClickHouse基于ORDER BY构建稀疏索引。PRIMARY KEY只告诉它使用ORDER BY的哪一部分进行过滤。
我们生产环境中的真实示例:
-- 错误(慢)
ORDER BY (user_id, created_at)
-- 查询:查找过去一小时的投注。索引没有帮助,我们扫描所有数据。
-- 正确(快)
ORDER BY (created_at, user_id)
-- 查询:通过索引跳转到所需日期,然后在其中按user_id过滤
4. 稀疏索引:8192行如何变成一个索引条目
ClickHouse不会为每一行建立索引。它取一个颗粒(8192行),并在索引中写入:
- 该颗粒中ORDER BY的最小值
- 最大值
仅此而已。不是B树,不是哈希表——只是一个简单的min-max对数组。
查询如何加速:
-- 查找5分钟内的投注
SELECT * FROM bets WHERE created_at BETWEEN '2024-03-15 14:00:00' AND '2024-03-15 14:05:00';
-- (稀疏)索引检查每个颗粒:
-- 颗粒1:min='2024-03-15 13:00:00' max='2024-03-15 14:00:00' → 不匹配(max < 14:05?)
-- 颗粒2:min='2024-03-15 14:00:00' max='2024-03-15 15:00:00' → 匹配(min <= 14:05)
-- 颗粒3:min='2024-03-15 15:00:00' max='2024-03-15 16:00:00' → 不匹配(min > 14:05)
为什么快: 索引占用(颗粒数) * 16字节。对于10亿行,大约是190万个颗粒 → 30MB的索引。整个索引可以放入内存。
5. 分区:跳转到正确的月份
PARTITION BY是ClickHouse将分区放置到磁盘上不同目录的规则。
PARTITION BY toYYYYMM(created_at) -- 按月分区
在磁盘上:
/var/lib/clickhouse/data/betting/bets/
├── 202401/ # 2024年1月
├── 202402/ # 2024年2月
└── 202403/ # 2024年3月
如何加速查询:
SELECT * FROM bets WHERE created_at >= '2024-02-01' AND created_at < '2024-03-01';
-- ClickHouse直接进入文件夹202402/,甚至不打开其他分区
分区何时无帮助:
- 小分区(按天分区,每天1亿行 → 365个分区,每个300MB → 大量文件)
- 过滤条件不在分区键上
我的选择: 每月1000万到1亿行使用toYYYYMM(),每天10亿行以上使用toYYYYMMDD()(但那时你需要集群)。
6. .bin和.mrk格式:数据如何存储在磁盘上
我曾经查看过一个表目录,看到:
$ ls -la /var/lib/clickhouse/data/betting/bets/202401_1_1_0/
-rw-r----- 1 clickhouse clickhouse 1.2G user_id.bin
-rw-r----- 1 clickhouse clickhouse 12M user_id.mrk
-rw-r----- 1 clickhouse clickhouse 900M created_at.bin
-rw-r----- 1 clickhouse clickhouse 12M created_at.mrk
-rw-r----- 1 clickhouse clickhouse 2.1G amount.bin
-rw-r----- 1 clickhouse clickhouse 12M amount.mrk
- .bin — 实际的列数据,使用LZ4(或ZSTD,如果配置)压缩
- .mrk — 标记:每个颗粒在.bin中的位置
如何读取:
- 查询需要
user_id=123的amount列 - 稀疏索引说:这个user_id可能在颗粒#45、#46、#47中
- ClickHouse打开
user_id.mrk,获取颗粒#45的偏移量 - 跳转到
user_id.bin的该偏移量,读取8192个值 - 找到所需user_id的行,记住行号
- 使用行号,计算
amount.mrk中的位置,只从amount.bin读取需要的字节
结论: 物理上,只读取所需列和所需颗粒的数据。其他都是元数据。
7. 投注表的正确ORDER BY示例
错误的ORDER BY(我犯过的错):
CREATE TABLE betting.bets_wrong
(
user_id UInt64,
created_at DateTime64(3),
amount Decimal(18,2)
)
ENGINE = MergeTree()
ORDER BY (user_id, created_at); -- 先按用户索引
问题:我们项目中90%的查询是“显示过去一小时的投注”(按时间过滤)。索引没有帮助,因为user_id变化比时间快。ClickHouse扫描所有分区。
正确的ORDER BY:
CREATE TABLE betting.bets_correct
(
user_id UInt64,
created_at DateTime64(3),
amount Decimal(18,2),
sport LowCardinality(String),
outcome Enum8('win'=1,'loss'=2)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(created_at)
ORDER BY (created_at, user_id); -- 先按时间索引
现在:
- 按日期范围查询:立即跳转到正确的颗粒
- 在日期内,可以按user_id过滤
- 可选地,可以添加
SECONDARY INDEX(但那是另一个故事)
8. EXPLAIN indexes = 1:查看实际读取了多少颗粒
最有用的调试工具:
EXPLAIN indexes = 1
SELECT user_id, sum(amount)
FROM betting.bets
WHERE created_at >= '2024-03-01' AND created_at < '2024-04-01'
AND user_id = 100500
GROUP BY user_id;
输出:
Expression (Projection)
Aggregating
Expression
ReadFromMergeTree (betting.bets)
Indexes:
Partition key: partition_idx (1/3 partitions, 1 read)
Primary key: created_at (42/500 granules, 42 read)
MinMax: created_at (0 skipped, 1 read)
我们看到: 分区中500个颗粒,只读取了42个。过滤程度——8%。如果没有正确的ORDER BY,将是500/500。
9. 管理分区的命令
查看所有分区:
SELECT
partition,
name,
rows,
bytes_on_disk,
modification_time
FROM system.parts
WHERE table = 'bets' AND active = 1;
删除旧分区(比DELETE快):
ALTER TABLE betting.bets DROP PARTITION '202401';
立即清除磁盘。DELETE FROM逐行删除,然后合并——相差数小时。
分离分区(不删除数据):
ALTER TABLE betting.bets DETACH PARTITION '202402';
-- 数据移动到detached/
重新附加:
ALTER TABLE betting.bets ATTACH PARTITION '202402';
将分区复制到另一个表(真实案例):
ALTER TABLE betting.bets_archive REPLACE PARTITION '202401' FROM betting.bets;
10. OPTIMIZE TABLE——何时不需要(以及何时突然需要)
OPTIMIZE TABLE强制手动合并分区。
坏消息: 大多数文章建议定期运行它。好消息: 在99%的情况下,你不需要。ClickHouse在后台自动合并。
我实际使用OPTIMIZE的情况:
- 加载大量历史数据后(一次插入1亿行)——这样其他分区不必等待计划合并
- 备份前,减少表中的文件数量
- 测试——查看压缩后的实际大小
如何安全执行:
OPTIMIZE TABLE betting.bets PARTITION '202403' FINAL;
FINAL将该分区的所有部分合并为一个。没有FINAL——只合并已经准备好的部分。
我的建议: 不要在自动化脚本中使用OPTIMIZE。后台合并已经调优得很好。如果分区没有合并,检查max_bytes_to_merge和可用磁盘空间。
下一步
MergeTree是ClickHouse的心脏。现在你知道它如何跳动了。下一篇文章——关于高级索引:跳过索引、物化列和投影。
← 上一篇: ClickHouse:博彩分析完整数据类型参考(我踩过的坑)
→ 下一篇: 将数据加载到 ClickHouse:如何告别逐行插入,将数据摄入速度提升 500 倍
— Editorial Team
暂无评论。