2、ClickHouse深度剖析(上):列式存储原理、向量化执行引擎、MergeTree家族表引擎、分区与排序键设计
ClickHouse 这章,我打算分上下两篇来讲。上篇聚焦它的核心底层能力——列式存储、向量化执行、MergeTree 引擎体系,以及分区和排序键的设计。这些是理解 ClickHouse 为什么快的关键,也是你后续做表结构设计的基础。
说实话,我第一次接触 ClickHouse 时,也被它的查询速度惊到了。但深入源码和实际调优后,我发现它的快不是魔法,而是每一层都做了极致的设计取舍。咱们一层层拆开看。
2.1 列式存储:为什么分析查询快 10-100 倍?
行式存储和列式存储的区别,说白了就是数据在磁盘上的摆放方式不同。
- 行式存储:一行数据的所有字段连续存放。适合 OLTP 的点查、频繁更新。
- 列式存储:同一列的数据连续存放。适合 OLAP 的批量扫描、聚合分析。
举个例子,一个表有 100 列,你只想查其中 3 列。行存得把 100 列全读出来,再丢弃 97 列;列存只读那 3 列的数据块。磁盘 I/O 直接差了一个数量级。
列式存储带来的三大优势:
- 高压缩比:同一列数据类型相同,重复值多,压缩率可达 5-10 倍甚至更高。我在项目中见过时间戳列用 Delta 编码后,压缩比超过 20:1。
- 延迟物化:只读取需要的列,减少内存和 CPU 开销。
- 向量化友好:连续内存布局让 SIMD 指令能批量处理数据。
我的经验:列式存储不是银弹。如果你需要频繁的逐行更新或点查(比如按 ID 查单条记录),ClickHouse 并不擅长。它天生是为「扫描大量行、返回少量列」的分析场景设计的。
2.2 向量化执行引擎:CPU 的极致压榨
ClickHouse 的向量化执行,本质上就是把「逐行处理」变成「批量处理」。每次处理一批数据(通常 1024 行),利用 CPU 的 SIMD 指令集(如 AVX2)一次操作多个数据元素。
我刚开始理解这个概念时,觉得不就是批处理吗?但实际效果差异巨大。传统数据库的火山模型,每处理一行就要调用一次虚函数,CPU 的分支预测和缓存命中率都很差。向量化执行把循环展开,让 CPU 流水线跑满。
看一个简化对比:
-- 传统逐行处理(伪代码)
for each row:
if row.value > 100:
sum += row.value
-- 向量化处理(伪代码)
load batch of 1024 values into SIMD register
compare with 100 using SIMD instruction
mask out values that don't match
accumulate using SIMD add
ClickHouse 的向量化执行引擎覆盖了大部分算子:过滤、聚合、排序、Join。每个算子都针对 SIMD 做了优化。
注意:向量化执行依赖 CPU 对 SIMD 指令集的支持。如果集群中混用不同代际的 CPU,ClickHouse 会自动降级到标量执行,性能会打折扣。我建议统一硬件配置,至少支持 AVX2。
2.3 MergeTree 家族:不止一种引擎
MergeTree 是 ClickHouse 最核心的表引擎家族。它不是一个引擎,而是一套基于「合并树」思想的引擎体系。
我画了一张图,帮你理清 MergeTree 家族的核心成员和关系:
每个引擎在 MergeTree 基础上增加了特定的合并逻辑:
| 引擎 | 核心能力 | 适用场景 |
|---|---|---|
| MergeTree | 基础分区、排序、TTL | 日志、时序数据原始存储 |
| ReplacingMergeTree | 合并时按排序键去重 | 幂等写入、去重需求 |
| SummingMergeTree | 合并时预聚合数值列 | 降采样、预统计 |
| AggregatingMergeTree | 合并时执行自定义聚合函数 | 物化视图、复杂预聚合 |
| CollapsingMergeTree | 通过 Sign 列实现逻辑删除/更新 | 实时变更、状态快照 |
我的建议:不要一上来就用 ReplacingMergeTree 或 SummingMergeTree。先问自己:我真的需要合并时的去重或预聚合吗?合并是异步的,数据在合并前可能不符合预期。我见过不少团队因为用了 ReplacingMergeTree 但没等合并完成,导致查询结果重复,排查了半天。
2.4 分区键与排序键:表结构设计的灵魂
分区键和排序键是 ClickHouse 表设计中最重要的两个决策。选对了,查询快 10 倍;选错了,全表扫描。
2.4.1 分区键(PARTITION BY)
分区键决定了数据在物理上的目录划分。每个分区对应一个独立的目录,查询时通过分区裁剪跳过无关目录。
常见的分区策略:
- 按时间分区:toYYYYMM(date) 或 toStartOfMonth(date)。最常用,适合时序数据。
- 按业务维度分区:比如按城市、按租户 ID。适合数据有明显业务隔离的场景。
- 不分区:小表(百万级以下)可以不设分区键,避免过多小文件。
避坑指南:我曾经在一个项目中,把分区键设成了毫秒级时间戳。结果每个分区只有几条数据,产生了上万个分区目录。合并线程根本忙不过来,查询反而变慢了。分区粒度要适中,建议每个分区数据量在 1GB 以上。
2.4.2 排序键(ORDER BY)
排序键决定了数据在每个分区内的物理存储顺序。它直接影响查询的索引效率。
排序键的设计原则:
- 高频过滤字段放前面:查询中 WHERE 条件最常用的字段,放在排序键的最左侧。
- 基数低的字段优先:枚举值少的字段(如状态码、类型)适合做前缀,能快速过滤大量数据。
- 不要超过 3-4 个字段:排序键越长,写入时的排序开销越大,索引的稀疏性也会下降。
看一个实际例子:
-- 好的设计:高频查询按时间范围 + 用户ID
CREATE TABLE events (
event_time DateTime,
user_id UInt64,
event_type String,
detail String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, user_id);
-- 差的设计:排序键与查询条件不匹配
-- 如果查询 WHERE user_id = xxx,但排序键以 event_time 开头,索引无法有效过滤
核心要点:排序键就是 ClickHouse 的主索引。它不保证唯一性,但决定了数据在磁盘上的排列顺序。查询时,ClickHouse 通过稀疏索引(每 8192 行记录一个 min/max)快速定位数据块,跳过不相关的块。
2.4.3 分区键 vs 排序键的协同
分区键和排序键不是独立的。它们共同决定了查询的裁剪效率。
举个例子:
- 分区键按月份分区,排序键按 (event_time, user_id)。
- 查询 WHERE event_time BETWEEN '2024-01-01' AND '2024-01-31' AND user_id = 123。
- 第一步:分区裁剪,只扫描 2024-01 这一个分区目录。
- 第二步:索引过滤,在分区内通过排序键的稀疏索引,快速定位到 user_id=123 的数据块。
两层裁剪叠加,可能只需要扫描整个表的 0.1% 数据。
我的经验:设计表结构时,先列出业务中最频繁的 3-5 个查询模式。然后针对这些模式,倒推分区键和排序键。不要试图用一个排序键覆盖所有查询,那不现实。覆盖 80% 的核心查询就够了。
好了,上篇的核心内容就这些。列式存储、向量化执行、MergeTree 引擎、分区与排序键,这四个点构成了 ClickHouse 高性能的基石。下篇我会深入 MergeTree 的合并机制、索引原理、以及写入和查询的完整链路,到时候再聊。