ClickHouse 列式存储的查询性能优势
什么是列式存储
列式存储是一种将数据按列而非按行组织在磁盘上的存储模式。传统关系型数据库(如 MySQL、PostgreSQL)普遍采用行式存储,同一行的所有字段值在物理上相邻排列。而在列式存储中,同一列的所有值被连续存储,不同列的数据则分别独立存放。
以一张用户行为日志表为例:
| 时间戳 | 用户ID | 事件类型 | 省份 |
|---|---|---|---|
| 10:00 | 1001 | 点击 | 北京 |
| 10:01 | 1002 | 浏览 | 上海 |
| 10:02 | 1003 | 购买 | 广州 |
- 行式存储:
[10:00, 1001, 点击, 北京] [10:01, 1002, 浏览, 上海] [10:02, 1003, 购买, 广州] - 列式存储:时间戳列文件
[10:00, 10:01, 10:02],用户ID列文件[1001, 1002, 1003],事件类型列文件[点击, 浏览, 购买],省份列文件[北京, 上海, 广州]
ClickHouse 的列式存储实现
ClickHouse 将每一列的数据独立存储为 .bin 数据文件,同时为每列生成对应的 .mrk 标记文件,记录数据块在文件中的偏移位置及压缩编码信息。这种设计天然支持以下能力:
- 查询时可以只读取涉及到的列,其他列完全不被扫描
- 每一列可以使用最适合其数据特征的编码与压缩算法(如 LZ4、ZSTD、Delta、Gorilla 等)
- 按列压缩大幅提升压缩比,相同数据量占用更少的磁盘空间和 IO 带宽
查询性能优势的核心原理
1. 消除不必要的 I/O
分析型查询通常只关注部分列,例如统计各省份的事件总量:
SELECT 省份, count() FROM logs GROUP BY 省份;
行式存储会读取整行全部字段,包括时间戳、用户ID、事件类型。而 ClickHouse 列式存储仅需扫描“省份”这一列的压缩文件,数据读取量减少 80%~90%。在高基数、宽表(上百列)的场景下,这种优势呈倍数放大。
2. 极高的数据压缩率
同一列的数据类型相同且值域集中,存在大量重复或相似模式。ClickHouse 利用此特性极大压缩数据,典型做法包括:
- Delta 编码:存储相邻值的差值,适用于递增或变化缓慢的时序数据
- 字典编码:为低基数枚举值(如省份、事件类型)构建全局字典,列内仅存字典索引
- 通用压缩:在编码后的数据上再次应用 LZ4 或 ZSTD 无锁压缩
实际案例中,日志表的省份列压缩比可达 10:1 到 50:1,时间戳列借助 Delta 编码后的压缩比常超过 100:1。更小的数据体积直接减少了磁盘 IO 和内存占用,使查询更快。
3. 向量化执行与 CPU 高效利用
现代 CPU 支持单指令多数据(SIMD)操作,一次处理小块连续数据。列式存储让同一列的大量数据在内存中连续排列,ClickHouse 能直接以批量向量方式处理:
- 一次加载数百个省份值到 CPU 寄存器
- 用 SIMD 指令批量比较、聚合,避免逐行解释执行的开销
- 配合数据块压缩,解压过程也被向量化,充分利用 CPU 缓存及流水线
这使得 ClickHouse 在处理数十亿行数据的聚合查询时,CPU 效率远高于传统行存引擎。
4. 避免全表扫描的索引机制
列式存储结合 稀疏索引 进一步降低扫描成本。ClickHouse 默认按排序键进行数据排序和分块存储,每个数据块(Granule,默认 8192 行)记录其键列的最小最大值。执行带有条件过滤的查询时:
SELECT avg(金额) FROM 订单表 WHERE 日期 = '2025-03-31';
ClickHouse 会读取日期列对应的标记文件,快速跳过不包含目标日期的数据块,只解压并处理相关块。这种索引仅依赖排序键列,无需像 B-Tree 那样为每行建立索引指针,写入和查询成本都极其低廉。
典型场景性能对比
在标准的星形分析查询测试(TPC-H Q1)中,ClickHouse 单机比行式数据库快 100~1000 倍不等,核心差距正来源于列式存储带来的 IO 减少与向量化计算的结合。
一个 1TB 的营销事件宽表(包含 200 列),执行以下查询:
SELECT 渠道, countDistinct(用户ID)
FROM 事件表
WHERE 日期 BETWEEN '2025-03-01' AND '2025-03-31'
AND 事件类型 = '下单'
GROUP BY 渠道;
- 行式数据库:需要完整扫描整个 1TB 数据(即使索引存在,仍需回表读取其他列),查询耗时超过 10 分钟
- ClickHouse:仅读取
日期、事件类型、渠道、用户ID四列,压缩后实际读取量可能不到 50GB,配合块跳过和向量化,查询往往在 2~5 秒 内完成
为何列式存储不适合 OLTP 事务
列式存储的优势集中于读密集型分析,但在频繁单行更新的场景中会暴露劣势:
- 更新一行需要修改多个列文件,产生大量随机 IO
- 同一行的数据物理分离,无法在单次 IO 中完整取出
- 行级事务和 MVCC 实现复杂
因此 ClickHouse 被设计为近乎无更新的追加写分析引擎,而 OLTP 场景仍需选择行存数据库。
上手验证:观察列式存储的实际行为
你可以用以下步骤在 ClickHouse 中直观感受列存特性。
- 创建测试表并写入数据:
CREATE TABLE test_wide (
id UInt32,
name String,
age UInt8,
score Float64,
remark String
) ENGINE = MergeTree()
ORDER BY id;
-
插入数亿行随机数据(可使用
numbers表函数快速生成)。 -
执行查询并观察系统表
system.query_log中read_rows和read_bytes的变化:
-- 仅查两列
SELECT avg(score), max(age) FROM test_wide;
-- 再查全部列
SELECT * FROM test_wide LIMIT 10;
你会明确看到:前者读取的字节数远小于后者,证明只读所需列的事实。
总结
ClickHouse 的列式存储通过“按列读取、极限压缩、向量化处理、稀疏索引” 四大机制,将分析查询的性能推向极致。理解其原理后,建表时应遵循以下最佳实践:
- 按查询需求设排序键,让稀疏索引发挥最大过滤效果
- 避免在宽表中读取不需要的列
- 适当选用编码方式进一步减小磁盘占用量
- 将同一业务的明细与聚合分离(物化视图),减少查询扫描量
掌握列式存储本质,你就可以在设计数据模型和 SQL 时做出对查询效率更友好的决策。