2. ClickHouse核心原理
各位同学,咱们今天聊点硬核的。ClickHouse 为什么能这么快?很多人第一反应是「列式存储」。没错,但这只是冰山一角。我做了这么多年 OLAP,见过太多人把 ClickHouse 当 MySQL 用,结果查询慢得怀疑人生。其实,它的核心秘密藏在四个关键词里:列式存储、向量化执行、MergeTree 引擎、分区与排序键。咱们一个一个拆开看。
2.1 列式存储:为什么它比行式快 100 倍?
先问个问题:你做一个分析查询,通常要读多少数据?
比如「统计 2024 年每个月的销售额」。在行式数据库里,哪怕你只关心 amount 和 date 两列,它也得把整行数据(包括 50 个无关字段)从磁盘拖出来。这不是浪费吗?
列式存储的思路很简单:按列存,按列读。每列单独一个文件,查询时只加载需要的列。我曾在项目里把一个 200 列的宽表查询,从 30 秒优化到 0.8 秒——其实就是把行存换成了列存。
列式存储的核心收益:
- IO 大幅降低:只读需要的列,磁盘扫描量减少 90% 以上
- 压缩率极高:同一列数据类型相同,LZ4 或 ZSTD 压缩后,数据量能再缩 3-5 倍
- CPU 缓存友好:连续内存访问,预取效率远高于行式随机跳转
举个例子,一个 10 亿行的表,行式存储可能占 500GB,列式压缩后可能只有 50GB。你想想看,磁盘读取速度从 500GB 降到 50GB,这差距有多大。
2.2 向量化执行引擎:不只是「批处理」
列式存储解决了 IO 问题,但 CPU 计算呢?传统数据库是一行一行处理的——取一行,算一下,再取下一行。这叫「标量执行」。每次循环都有函数调用开销、分支预测开销,CPU 利用率很低。
ClickHouse 的做法是向量化执行。它不是处理一行,而是一次处理一批(比如 1024 行)。这一批数据在内存里是连续数组,CPU 可以用 SIMD 指令(单指令多数据流)并行计算。
我个人的理解:
标量执行就像你一个人搬砖,一次搬一块。向量化执行就是叫来 8 个人排成一排,一次搬 8 块。CPU 的 SIMD 指令就是那 8 个人,只要数据排好队,效率翻倍。
我在一个实时大屏项目里遇到过这种情况:同样的聚合查询,PostgreSQL 跑了 12 秒,ClickHouse 只用了 0.3 秒。差距在哪?除了列存,向量化执行至少贡献了 3-5 倍的加速。
ClickHouse 的向量化执行引擎,核心是 Expression 和 Pipeline。每个算子(过滤、聚合、排序)都操作列式数据块,中间结果也是列式。这避免了行式数据库里频繁的「行转列、列转行」开销。
2.3 MergeTree 引擎家族:不止一种选择
说到 ClickHouse 的表引擎,MergeTree 是的主角。但很多人不知道,MergeTree 其实是一个家族,不同场景要选不同成员。
| 引擎 | 适用场景 | 核心特点 |
|---|---|---|
| MergeTree | 通用场景,时序数据 | 基础版,支持分区、排序、TTL |
| ReplacingMergeTree | 去重场景(如订单状态更新) | 合并时按排序键去重,保留最后一条 |
| SummingMergeTree | 预聚合场景(如统计报表) | 合并时按排序键聚合数值列 |
| AggregatingMergeTree | 复杂聚合(如留存分析) | 合并时保留聚合函数状态 |
| CollapsingMergeTree | 实时修改(如点击流修正) | 通过 Sign 字段实现增量修改 |
| VersionedCollapsingMergeTree | 多版本实时修改 | 带版本号的折叠机制 |
我刚开始用 ClickHouse 时,犯过一个错:用普通的 MergeTree 做订单去重,结果数据越查越多。后来换成 ReplacingMergeTree,在合并时自动去重,问题就解决了。嗯,这里要注意:去重是最终一致性的,合并完成前可能还有重复。
避坑指南:
我曾经在生产环境里用 SummingMergeTree 做实时聚合,以为合并后数据就精确了。结果发现合并是异步的,查询时还有未合并的部分,导致数据翻倍。后来我改用 AggregatingMergeTree + 物化视图,才解决了实时性和准确性的矛盾。
2.4 分区与排序键设计:性能的「最后一公里」
很多人建表时随便选个分区键和排序键,觉得差不多就行。其实,这是 ClickHouse 性能调优里最容易被忽视、但收益最大的一环。
2.4.1 分区键:减少扫描范围
分区键决定了数据在磁盘上的物理分片。查询时,ClickHouse 会先根据分区键裁剪掉不相关的分区,只扫描需要的那几个。
举个例子,一个日志表按 toYYYYMM(date) 分区。查询某一天的数据时,只需要扫描一个分区(约 30 个文件),而不是全表扫描。我见过有人按 toYYYYMMDD(date) 分区,结果分区数太多(每天一个),小文件泛滥,合并线程忙不过来。后来改成按月分区,性能反而提升了 40%。
分区键设计原则:
- 粒度适中:分区数控制在 100-1000 之间,太多或太少都不好
- 查询过滤常用:分区键最好是查询 WHERE 条件里最常用的字段
- 避免跨分区查询:如果查询经常跨月,按月分区比按天分区更合适
2.4.2 排序键:加速索引与数据局部性
排序键(ORDER BY)决定了数据在分区内的物理顺序。它有两个作用:
- 生成稀疏索引:每 8192 行记录一个 min/max,查询时快速跳过不相关的数据块
- 数据局部性:相同排序键值的数据物理上相邻,压缩率更高,扫描更快
我做过一个测试:同样的 1 亿行数据,排序键设计合理时,范围查询耗时 0.2 秒;排序键设计不合理时,耗时 8 秒。差了 40 倍。
排序键的设计有个小技巧:把高基数的字段放前面,低基数的放后面。比如 (user_id, date) 比 (date, user_id) 好,因为 user_id 的基数高,能更快地定位到具体数据块。
建表示例:
CREATE TABLE events (
event_date Date,
user_id UInt64,
event_type String,
amount Float64
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (user_id, event_date)
SETTINGS index_granularity = 8192;
这个表里,分区键按月,排序键按用户 ID + 日期。查询某个用户某段时间的数据时,先裁剪分区,再通过稀疏索引快速定位,性能极佳。
2.5 一张图看懂 ClickHouse 核心架构
说了这么多,咱们用一张图把整个流程串起来。从数据写入到查询返回,ClickHouse 内部经历了什么?
这张图把整个流程分成了两条线:写入线(左)和查询线(右)。写入时数据先按列存,再按分区和排序键组织;查询时先裁剪分区,再通过稀疏索引定位数据块,最后用向量化引擎计算。后台还有 MergeTree 不断合并优化。说白了,ClickHouse 的每一层设计都是为了减少数据扫描量、提高 CPU 利用率。
2.6 小结:理解原理才能用好工具
这一章的内容有点多,但都是硬通货。列式存储解决 IO 瓶颈,向量化执行榨干 CPU,MergeTree 家族提供不同场景的存储策略,分区和排序键则是调优的「最后一公里」。我见过太多人把 ClickHouse 当黑盒用,出了问题只会加机器。其实,理解了这些核心原理,你就能用更少的资源,跑更快的查询。