2. ClickHouse 核心原理:列式存储、向量化执行、MergeTree 引擎家族、分区与排序键设计
ClickHouse 为什么能这么快?很多人第一反应是「列式存储」。没错,这是基础。但光有列式存储,还撑不起它那恐怖的查询速度。真正让它脱颖而出的,是列式存储 + 向量化执行 + MergeTree 引擎这三板斧。
我刚开始接触 ClickHouse 时,也犯过傻——拿它当普通数据库用,建表时随便选了个引擎,结果查询慢得离谱。后来才明白,理解 MergeTree 家族,才是用好 ClickHouse 的门槛。
2.1 列式存储:为什么它比行式快 10 倍?
传统行式存储,比如 MySQL 的 InnoDB,数据是一行一行存的。你要查某个字段的平均值,它得把整行数据都读出来,再扔掉不需要的列。磁盘 I/O 浪费严重。
列式存储正好反过来。每一列单独存成一个文件。查询时只读需要的列,I/O 量直接降到原来的十分之一甚至更低。
| 对比维度 | 行式存储 | 列式存储 |
|---|---|---|
| 数据组织 | 按行连续存储 | 按列独立存储 |
| 查询 I/O | 读取整行,浪费带宽 | 只读需要的列,I/O 高效 |
| 压缩率 | 低,同一行数据类型混杂 | 高,同列数据类型一致,压缩比可达 5-10 倍 |
| 典型场景 | OLTP,频繁增删改 | OLAP,批量查询、聚合分析 |
举个例子。一张 100 列的表,你只查其中 3 列。行式存储要读 100 列的数据,列式存储只读 3 列。磁盘读取量差了 30 多倍。再加上列式存储天然适合压缩——同一列的数据类型一样,值重复率高,用 LZ4 或 ZSTD 一压,磁盘占用又少一大截。
核心结论:列式存储不是魔法,但它把「读少点、压狠点」做到了极致。这是 ClickHouse 高性能的基石。
2.2 向量化执行:CPU 的潜力被榨干了
列式存储解决了 I/O 问题,但数据读进内存后,怎么处理?传统数据库是一条一条循环处理,每次处理一行,CPU 大部分时间花在指令解析和分支预测上。
向量化执行不一样。它一次处理一批数据,比如 1024 行。利用 CPU 的 SIMD(单指令多数据流)指令集,一条指令同时对多个数据执行相同操作。说白了,就是让 CPU 用「批处理」代替「逐行处理」。
我曾在项目中对比过:同样的聚合查询,向量化执行比逐行循环快了 5-8 倍。为什么?因为 CPU 的缓存命中率更高,分支预测失败更少,指令流水线更顺畅。
小提示:ClickHouse 的向量化执行是自动的,你不需要手动写 SIMD 指令。但建表时选择合适的数据类型(比如用 Int32 代替 String 存储枚举值),能让向量化效果更好。
2.3 MergeTree 引擎家族:核心中的核心
ClickHouse 有几十种表引擎,但 90% 的场景你只需要 MergeTree 家族。它才是 ClickHouse 的「灵魂」。
MergeTree 的核心思想是:数据先写入内存,后台异步合并。写入时先写到一个小的「parts」里,后台线程定期把这些小 parts 合并成大 parts。合并过程中可以做排序、去重、聚合。
我见过有人把 MergeTree 比作「LSM-Tree 的变种」,其实不完全准确。MergeTree 的合并策略更灵活,而且每个 part 内部是排序的,查询时可以快速定位。
2.3.1 常用引擎一览
| 引擎 | 特点 | 适用场景 |
|---|---|---|
| MergeTree | 基础引擎,支持分区、排序、主键 | 通用 OLAP 查询 |
| ReplacingMergeTree | 合并时去重,保留最后一条 | 数据有重复,需要幂等写入 |
| SummingMergeTree | 合并时预聚合,按排序键求和 | 汇总统计,减少存储 |
| AggregatingMergeTree | 合并时执行自定义聚合函数 | 物化视图、增量聚合 |
| CollapsingMergeTree | 通过 sign 字段实现增量更新 | 实时更新、状态快照 |
| VersionedCollapsingMergeTree | 带版本的折叠引擎 | 多版本状态管理 |
我个人最常用的是 ReplacingMergeTree 和 SummingMergeTree。前者解决数据重复问题,后者在写入时就做预聚合,查询时几乎秒出。
注意:MergeTree 家族的合并是异步的,不保证实时一致性。如果你写入后立刻查询,可能看到多个小 parts 的数据。ClickHouse 查询时会合并这些小 parts 的结果,但性能会受影响。建议写入后等几秒再查,或者手动执行 OPTIMIZE TABLE 触发合并。
2.4 分区与排序键设计:性能差距就在这里
同样的数据,分区和排序键设计得好不好,查询性能能差 10 倍以上。这不是夸张,是我在项目中踩过的坑。
2.4.1 分区键:数据裁剪的第一道防线
分区键决定了数据在物理上如何分割。查询时,ClickHouse 会先根据分区键过滤掉不需要的分区,这叫「分区裁剪」。分区粒度太粗,裁剪效果差;太细,小文件太多,合并压力大。
我建议:分区键选在 WHERE 条件中最常用的时间字段。比如按天分区 toYYYYMMDD(date),或者按月分区 toYYYYMM(date)。不要用秒级或毫秒级字段做分区键,否则分区数爆炸。
-- 按天分区,适合实时数据
CREATE TABLE events (
event_date Date,
event_time DateTime,
user_id UInt32,
event_type String,
value Float64
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(event_date)
ORDER BY (event_date, event_type, user_id);
2.4.2 排序键:查询加速的利器
排序键决定了数据在每个 part 内部的物理顺序。ClickHouse 会为排序键建立稀疏索引,查询时通过索引快速定位到数据块,跳过无关数据。
排序键的设计原则很简单:把 WHERE 条件中最常用的字段放在前面。比如查询经常按 event_date 和 event_type 过滤,排序键就设为 (event_date, event_type)。
我曾经接手过一个项目,排序键设成了 (user_id, event_date),但查询全是按 event_date 范围过滤的。结果每次查询都要扫全表,慢得让人崩溃。改成 (event_date, user_id) 后,查询时间从 30 秒降到了 0.5 秒。
避坑指南:排序键不是主键,不要跟 MySQL 的主键概念混淆。排序键可以包含多个字段,而且可以重复。它的唯一作用是控制数据物理顺序和建立稀疏索引。
2.4.3 分区 + 排序键的协同设计
分区键和排序键不是孤立的。它们要配合起来,才能发挥最大效果。
- 分区键:粗粒度裁剪,减少扫描的分区数。
- 排序键:细粒度过滤,在每个分区内快速定位数据块。
举个例子。如果分区键是 toYYYYMM(event_date)(按月),排序键是 (event_date, event_type)。查询某一天的数据时,先通过分区键定位到对应月份的分区,再通过排序键的稀疏索引找到当天的数据块。两层过滤下来,扫描的数据量可能只有全表的千分之一。
2.5 实战建议:从建表开始就做对
说了这么多,总结几条我自己的实战经验:
- 引擎选型:90% 场景用 MergeTree 或 ReplacingMergeTree。需要预聚合用 SummingMergeTree。
- 分区键:按天或按月,不要按秒。时间字段最常用。
- 排序键:把 WHERE 条件中最常用的字段放前面。不要超过 3-4 个字段。
- 数据类型:能用 Int32 不用 String,能用 DateTime 不用 String。向量化执行对数值类型更友好。
- 避免频繁小批量写入:ClickHouse 适合批量写入,每次至少 1000 行以上。小批量写入会产生大量小 parts,增加合并压力。
我的习惯:建表前先想清楚查询模式。把最常用的 WHERE 条件列出来,分区键和排序键就围绕它们设计。不要贪多,排序键字段多了反而降低插入性能。
嗯,ClickHouse 的核心原理就这些。理解列式存储、向量化执行、MergeTree 引擎、分区与排序键设计,你就掌握了 ClickHouse 性能调优的钥匙。剩下的,就是在实践中不断打磨了。