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)。查询某一天的数据时,先通过分区键定位到对应月份的分区,再通过排序键的稀疏索引找到当天的数据块。两层过滤下来,扫描的数据量可能只有全表的千分之一。

ClickHouse 查询加速流程 SQL 查询 步骤 1:分区裁剪 根据分区键过滤,跳过无关分区 步骤 2:稀疏索引过滤 在分区内通过排序键定位数据块 步骤 3:向量化执行 批量处理数据,利用 SIMD 指令加速 返回结果 三层加速:分区裁剪 → 稀疏索引 → 向量化执行,缺一不可 排序键设计直接影响步骤 2 的效果,是性能优化的关键

2.5 实战建议:从建表开始就做对

说了这么多,总结几条我自己的实战经验:

  1. 引擎选型:90% 场景用 MergeTree 或 ReplacingMergeTree。需要预聚合用 SummingMergeTree。
  2. 分区键:按天或按月,不要按秒。时间字段最常用。
  3. 排序键:把 WHERE 条件中最常用的字段放前面。不要超过 3-4 个字段。
  4. 数据类型:能用 Int32 不用 String,能用 DateTime 不用 String。向量化执行对数值类型更友好。
  5. 避免频繁小批量写入:ClickHouse 适合批量写入,每次至少 1000 行以上。小批量写入会产生大量小 parts,增加合并压力。

我的习惯:建表前先想清楚查询模式。把最常用的 WHERE 条件列出来,分区键和排序键就围绕它们设计。不要贪多,排序键字段多了反而降低插入性能。

嗯,ClickHouse 的核心原理就这些。理解列式存储、向量化执行、MergeTree 引擎、分区与排序键设计,你就掌握了 ClickHouse 性能调优的钥匙。剩下的,就是在实践中不断打磨了。