第2章:ClickHouse深度剖析——列式存储、向量化执行与MergeTree家族

ClickHouse 是我在实时OLAP领域用得最顺手的工具之一。说实话,第一次接触它时,我被它的查询速度惊到了——几亿行数据,聚合查询秒级返回。这背后靠的是什么?就是今天我们要聊的三个核心:列式存储、向量化执行引擎,以及MergeTree存储引擎家族。

2.1 列式存储:为什么它比行式存储快10倍?

传统关系型数据库用行式存储,一行数据物理上挨着放。你想想看,如果我只想查某个列的SUM值,行式存储却要把整行数据都读出来,这显然浪费IO。

ClickHouse 采用列式存储,同一列的数据连续存放在一起。这样做有几个明显好处:

  • 高压缩比:同一列数据类型相同,重复值多,压缩率能达到5-10倍。我在项目中见过时间列压缩到原始大小的1/20。
  • 查询只读需要的列:比如SELECT COUNT(DISTINCT user_id),只需要读取user_id这一列,IO量大幅减少。
  • 向量化执行友好:列式存储天然适合批量处理,一次读取一批数据,CPU缓存命中率高。

个人经验:我曾经接手一个项目,原来用MySQL做用户行为分析,查询要30秒。换成ClickHouse后,同样的查询降到0.3秒。核心原因就是列式存储把IO降了两个数量级。

2.2 向量化执行引擎:CPU的极致压榨

向量化执行是ClickHouse的另一个杀手锏。传统数据库逐行处理,每行都要经历一次函数调用、条件判断,CPU大部分时间花在指令调度上。

ClickHouse的做法是:一次处理一批数据(比如1024行),用SIMD指令集并行计算。说白了,就是让CPU一次性处理多个数据,而不是一个个来。

-- 传统逐行处理伪代码
for each row:
    result = row.a + row.b

-- 向量化处理伪代码
batch_a = load_column('a', 1024 rows)
batch_b = load_column('b', 1024 rows)
result = SIMD_add(batch_a, batch_b)  -- 一次处理1024个元素

为什么会快这么多?因为CPU的向量寄存器可以同时处理多个整数或浮点数,减少了指令发射次数和分支预测失败的开销。

避坑指南:我曾经遇到一个场景,查询中用了大量字符串操作,向量化效果大打折扣。因为字符串长度不一,没法用SIMD统一处理。所以,能用数值类型就别用字符串,这是发挥向量化优势的关键。

2.3 MergeTree引擎家族:存储引擎的核心

MergeTree 是ClickHouse最核心的存储引擎,其他变体都是它的扩展。我习惯把它比作「LSM-Tree的列存版本」——数据先写入内存,然后定期合并成更大的文件。

2.3.1 MergeTree核心原理

  • 分区:按指定列(通常是时间)将数据分成多个目录,查询时可以跳过无关分区。
  • 排序键:数据在每个分区内按指定列排序,形成稀疏索引,快速定位数据块。
  • 主键索引:每8192行记录一个主键值,生成索引文件,查询时二分查找。
  • 后台合并:多个小分区定期合并成大分区,减少文件数量,优化查询性能。
-- 创建MergeTree表
CREATE TABLE events (
    event_date Date,
    user_id UInt32,
    event_type String,
    value Float64
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, user_id)
SETTINGS index_granularity = 8192;

2.3.2 引擎家族对比

引擎 适用场景 特点
MergeTree 通用场景 支持分区、排序键、主键索引
ReplacingMergeTree 去重场景 合并时按排序键去重,保留最后一条
SummingMergeTree 预聚合场景 合并时自动SUM数值列,减少存储
AggregatingMergeTree 物化聚合 存储聚合函数中间状态,支持增量聚合
CollapsingMergeTree 实时更新 通过正负行实现增量更新
VersionedCollapsingMergeTree 带版本的实时更新 支持多版本,按版本号折叠

注意:ReplacingMergeTree的去重只在合并时发生,不是实时的。如果你需要查询时立即去重,得用FINAL关键字或聚合函数。我曾经踩过这个坑,以为写入后查询就自动去重了,结果数据翻倍了几天才发现。

2.4 分布式表与本地表:水平扩展的关键

ClickHouse的分布式能力靠两层表实现:

  • 本地表:实际存储数据的表,建在每个节点上,引擎通常是MergeTree家族。
  • 分布式表:逻辑表,不存数据,只做路由转发。查询时把SQL分发到所有分片,然后汇总结果。
-- 在每个节点上创建本地表
CREATE TABLE events_local ON CLUSTER my_cluster (
    event_date Date,
    user_id UInt32,
    value Float64
) ENGINE = ReplicatedMergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, user_id);

-- 创建分布式表
CREATE TABLE events_distributed ON CLUSTER my_cluster
AS events_local
ENGINE = Distributed(my_cluster, default, events_local, rand());

这里有个关键点:分布式表本身不保证数据一致性。写入时,它根据分片键(上面例子是rand())把数据发到对应节点。查询时,它把SQL发到所有分片,等所有分片返回结果后再做最终聚合。

我的建议:生产环境中,本地表一定要用ReplicatedMergeTree系列,配合ZooKeeper或ClickHouse Keeper实现数据副本。否则节点挂了,数据就丢了。我见过有人用MergeTree做分布式表底层,结果一个节点宕机,数据永久丢失。

2.5 物化视图实战:预计算的艺术

物化视图是ClickHouse提升查询性能的利器。它本质上是「查询结果的预计算和存储」,数据写入源表时,自动同步更新到物化视图。

2.5.1 物化视图的两种模式

  • TO表模式:物化视图的数据写入指定目标表,目标表可以有自己的引擎和分区策略。
  • POPULATE模式:创建时自动填充源表已有数据,但要注意,创建期间新写入的数据可能丢失。
-- 创建物化视图:按天统计用户事件数
CREATE MATERIALIZED VIEW events_daily_mv
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(day)
ORDER BY (day, user_id)
AS SELECT
    toDate(event_date) AS day,
    user_id,
    count() AS event_count
FROM events
GROUP BY day, user_id;

这个物化视图创建后,每次往events表写入数据,ClickHouse会自动把数据按天和用户聚合后写入events_daily_mv。查询时直接查物化视图,不用再扫描原始数据。

2.5.2 实战避坑

  • 不要用POPULATE:我建议创建物化视图时不要加POPULATE,而是先创建,再手动导入历史数据。POPULATE在创建期间可能丢数据。
  • 物化视图不是索引:它存储的是预计算结果,不是原始数据的索引。查询物化视图时,如果条件不匹配,不会回退到源表。
  • 多级物化视图:可以在物化视图上再建物化视图,实现多层预聚合。比如先按小时聚合,再按天聚合。

个人经验:我曾经用物化视图把一个10亿行表的查询从5秒降到50毫秒。核心思路是:识别业务中最常用的聚合维度(时间+用户ID),建好物化视图,查询直接命中预计算结果。

2.6 本章知识体系总览

下面这张图总结了ClickHouse的核心技术栈,从底层存储到上层查询的完整链路:

ClickHouse核心技术栈 ClickHouse核心技术栈 列式存储引擎 按列存储 · 高压缩比 · 只读所需列 · 减少IO 向量化执行引擎 SIMD指令集 · 批量处理 · 高CPU缓存命中率 MergeTree引擎家族 分区 · 排序键 · 主键索引 · 后台合并 分布式表 + 本地表 水平扩展 · 分片路由 · 副本容灾 物化视图 预计算 · 增量聚合 · 查询加速 亚秒级实时OLAP查询 IO优化 CPU优化 存储优化

从这张图可以看出,ClickHouse的每一层都在解决一个核心问题:列式存储解决IO瓶颈,向量化执行解决CPU瓶颈,MergeTree解决存储和索引问题,分布式表和物化视图解决扩展性和查询加速问题。四层叠加,才有了我们看到的极致性能。