2. ClickHouse 核心原理:列式存储、向量化执行引擎、MergeTree 引擎家族、分区与排序键设计

各位同学,咱们今天聊点硬核的。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)决定了数据在分区内的物理顺序。它有两个作用:

  1. 生成稀疏索引:每 8192 行记录一个 min/max,查询时快速跳过不相关的数据块
  2. 数据局部性:相同排序键值的数据物理上相邻,压缩率更高,扫描更快

我做过一个测试:同样的 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 内部经历了什么?

ClickHouse 核心架构与数据流 数据写入 列式存储 分区裁剪 稀疏索引 SQL 查询 向量化执行 MergeTree 合并 返回结果 数据块读取 核心流程说明 1. 写入时:列式存储 → 分区 → 排序键索引 2. 查询时:分区裁剪 → 稀疏索引 → 向量化执行 → 合并结果 3. 后台:MergeTree 异步合并,优化存储与索引

这张图把整个流程分成了两条线:写入线(左)和查询线(右)。写入时数据先按列存,再按分区和排序键组织;查询时先裁剪分区,再通过稀疏索引定位数据块,最后用向量化引擎计算。后台还有 MergeTree 不断合并优化。说白了,ClickHouse 的每一层设计都是为了减少数据扫描量、提高 CPU 利用率。

2.6 小结:理解原理才能用好工具

这一章的内容有点多,但都是硬通货。列式存储解决 IO 瓶颈,向量化执行榨干 CPU,MergeTree 家族提供不同场景的存储策略,分区和排序键则是调优的「最后一公里」。我见过太多人把 ClickHouse 当黑盒用,出了问题只会加机器。其实,理解了这些核心原理,你就能用更少的资源,跑更快的查询。