01 ClickHouse 全局架构——列式存储与 MPP 执行引擎
摘要
ClickHouse 是 Yandex 开发的开源列式存储 OLAP 数据库,以极致的查询性能在实时分析领域树立了标杆。本文从 OLAP 场景的本质需求出发,剖析行存与列存的根本差异,揭示 ClickHouse 的三大核心设计——列式物理存储、SIMD 向量化执行和单机多线程 MPP——如何协同实现百亿行数据秒级聚合查询。同时厘清 ClickHouse 与 Doris、Trino、Hive 在定位上的本质差异,帮助读者建立准确的技术选型判断。
第 1 章 为什么需要 ClickHouse——OLAP 场景的特殊性
1.1 OLTP 与 OLAP 的根本差异
在讨论 ClickHouse 之前,必须先理解它所解决的问题——OLAP 场景的查询特征与 OLTP 有根本差异。
OLTP(在线事务处理):MySQL、PostgreSQL 等传统关系数据库的设计目标。典型查询是”按主键找一行”或”按索引找少量行”,每次查询读取的行数极少(通常 1-100 行),但并发量高(每秒数千个事务)。行存储对这类查询非常高效——一行数据的所有列存储在一起,取一行只需一次磁盘寻址。
OLAP(在线分析处理):数据仓库、实时报表的设计目标。典型查询是”对过去 30 天的销售数据按地区分组求和”,需要扫描数十亿行,但每次查询只读取表的少数几列(如 date、region、amount),并发量低(每秒几个到几十个查询)。
OLAP 查询对行存储极为不利:
- 行存储中每行的所有列紧邻存储,读取
amount列时,磁盘必须同时读取同一行的user_id、product_name、description等完全不需要的列 - 对于宽表(100+ 列),只读其中 3 列意味着 97% 的 IO 是无效的
- 聚合计算(SUM/AVG/COUNT)需要连续处理大量数值,行存储的数据布局无法利用 CPU 的缓存和 SIMD 指令
一个具体的数字:对于一张 100 列、1 亿行的用户行为表,OLAP 查询可能只需要读取其中 3 列。行存储需要读取 100 × 1亿 × 平均列大小 的数据,而列存储只需要读取 3 × 1亿 × 平均列大小——IO 量减少 97%。这不是优化,这是数量级的差距。
1.2 ClickHouse 的诞生背景
ClickHouse 诞生于 Yandex 的日志分析需求。2008 年,Yandex 的网站流量分析系统(Metrica)需要实时分析用户点击流数据:每天数十亿次点击事件,需要支持多维度(地区、设备、来源等)的实时聚合查询,且查询延迟必须在秒级以内。
当时的解决方案(Hadoop MapReduce、传统行存数据库)都无法同时满足”海量数据 + 实时响应”的需求。Yandex 从 2009 年开始自研,2016 年开源,这就是 ClickHouse。
ClickHouse 的设计从一开始就针对这个特定场景深度优化,因此在 OLAP 查询性能上远超通用数据库,但也有其明确的局限:不支持事务、更新/删除效率低、不适合高并发点查。
设计哲学
ClickHouse 的设计哲学是”为最常见的分析查询场景极致优化,而不是做一个通用的数据库”。这种专注带来了极致的性能,但也意味着它不应该用在需要频繁更新、高并发事务处理的场景。了解它的边界,比了解它的功能更重要。
第 2 章 列式存储——物理存储的根本革命
2.1 行存 vs 列存的物理布局
行存储的物理布局(MySQL/PostgreSQL 的页面结构):
Page:
[row1: id=1, name="Alice", age=28, city="Beijing", amount=1000.0, ...]
[row2: id=2, name="Bob", age=35, city="Shanghai", amount=2500.0, ...]
[row3: id=3, name="Carol", age=22, city="Shenzhen", amount=800.0, ...]
...
同一行的所有列数据紧邻存储在一个物理页面中。读取任意一列时,必须把整行数据加载到内存。
列存储的物理布局(ClickHouse 的列文件):
amount.bin: [1000.0, 2500.0, 800.0, 3200.0, ...] // 只有 amount 列
age.bin: [28, 35, 22, 41, ...] // 只有 age 列
city.bin: ["Beijing", "Shanghai", ...] // 只有 city 列
每列独立存储在一个文件中,查询只读取需要的列文件,完全不碰其他列。
2.2 列存储对压缩的影响
列存储的另一个巨大优势是压缩率极高。
在行存储中,同一个数据页面包含不同类型的混杂数据(整数、字符串、浮点数交替出现),压缩算法难以找到规律,压缩率通常只有 2-5x。
在列存储中,同一文件只包含相同类型的数据:
- 数值列(如
amount):相邻值往往接近(时序相关性),用 Delta 编码 + LZ4 压缩,压缩率可达 10-20x - 低基数字符串列(如
city,只有几十个值):用字典编码,将字符串映射为整数,压缩率极高 - 高基数字符串列(如
user_id):相邻值如果有排序(按用户排序的数据),LZ4 仍然能达到 3-8x
实际生产中,ClickHouse 的数据压缩率通常在 10-30x,意味着 1TB 原始数据在 ClickHouse 中可能只占 50-100GB 磁盘空间。这不仅节省存储成本,还显著减少了查询时的磁盘 IO 量(读取的是压缩数据,解压在内存中进行)。
ClickHouse 支持的压缩编码(可在列定义时选择):
| 编码方式 | 适用场景 | 压缩效果 |
|---|---|---|
| LZ4(默认) | 通用场景,压缩/解压速度极快 | 3-10x |
| ZSTD | 需要更高压缩率,CPU 开销略高 | 5-20x |
| Delta | 单调递增的数值序列(时间戳、自增 ID) | 配合 LZ4/ZSTD 效果极佳 |
| DoubleDelta | 缓慢变化的数值(如定时采集的传感器数据) | 极佳 |
| T64 | 小范围整数列 | 好 |
| Gorilla | 浮点数时序数据 | 好 |
2.3 Granule——ClickHouse 最小的 IO 单元
ClickHouse 的列文件不是逐行随机访问的,而是以 Granule(颗粒) 为单位进行 IO。一个 Granule 默认包含 8192 行(由 index_granularity 参数控制)。
这个设计的意义:
索引粒度:ClickHouse 的 Primary Key 索引是稀疏索引——不是每行都有索引条目,而是每 8192 行(1个 Granule)有一个索引条目。对于数十亿行的表,索引条目数量是亿/8192 ≈ 百万级别,整个索引可以完全放在内存中,查询时无需磁盘 IO 即可定位数据范围。
向量化执行的基础:每次从磁盘读取一个 Granule(8192 行的一列),这就是一个向量(vector)。后续的所有计算都以这个向量为单位进行,天然匹配 CPU 的 SIMD 指令宽度,实现向量化计算。
第 3 章 向量化执行——CPU 的极致利用
3.1 标量执行 vs 向量化执行
传统数据库的查询执行模型(如 Volcano Model)是逐行处理(Row-at-a-time):每个算子每次调用 next() 返回一行数据,算子之间通过函数调用传递控制权。
这种模型的问题:
- 每处理一行都有一次函数调用开销
- CPU 分支预测、指令流水线无法充分利用(每次只处理一行,难以批量预测分支)
- 无法利用 SIMD(单指令多数据)指令——SIMD 需要连续的同类型数据
向量化执行(Vectorized Execution):每次处理一批数据(一个 Granule,8192 行):
// 伪代码:向量化的 SUM 计算
// column_data 是 8192 个 Float64 值的连续内存
double vectorized_sum(const double* column_data, size_t size) {
double sum = 0.0;
// 编译器可以自动向量化这个循环,利用 AVX2 指令一次处理 4 个 double
for (size_t i = 0; i < size; ++i) {
sum += column_data[i];
}
return sum;
}向量化的优势:
- SIMD 并行:AVX2 指令可以一次处理 4 个 64-bit 浮点数(或 8 个 32-bit 整数),AVX-512 可以处理 8 个 64-bit 浮点数。对于求和这类操作,理论上比标量执行快 4-8 倍
- CPU 缓存友好:8192 个 Float64 = 64KB,恰好适合 CPU L1/L2 缓存,整批数据计算期间不需要内存访问
- 分支预测优化:处理一批数据时,过滤条件(WHERE)可以提前计算成一个位图(bitmask),避免逐行判断分支
3.2 SIMD 指令的实际效果
ClickHouse 的核心计算函数(哈希、过滤、聚合)都针对 SSE4.2、AVX2、AVX-512 进行了手写汇编或 intrinsic 优化。
以最常见的 COUNT(DISTINCT user_id) 为例,ClickHouse 使用 HyperLogLog 算法估算基数(允许误差),底层的哈希计算用 SIMD 指令并行处理:
- 标量版本:每次处理 1 个 64-bit 值,1 个 CPU 周期计算 1 个哈希
- SSE4.2 版本:每次处理 2 个 64-bit 值,同等周期处理 2 个哈希(CRC32 指令)
- AVX2 版本:每次处理 4 个 64-bit 值
对于百亿行数据的 COUNT(DISTINCT) 查询,SIMD 优化带来 3-4 倍的吞吐提升。
核心概念:SIMD(Single Instruction Multiple Data)
SIMD 是现代 CPU 提供的并行计算能力:一条指令可以同时对多个数据元素执行相同操作。
- SSE2/SSE4.2:128-bit 宽,可以同时处理 2 个 64-bit 或 4 个 32-bit 元素
- AVX2:256-bit 宽,可以同时处理 4 个 64-bit 或 8 个 32-bit 元素
- AVX-512:512-bit 宽,可以同时处理 8 个 64-bit 元素
SIMD 的前提是数据必须连续存储且类型相同——这正是列存储的天然优势。行存储中不同列的类型不同,无法批量 SIMD 处理。
第 4 章 MPP 架构——单机多线程的极致并行
4.1 ClickHouse 的”MPP”与传统 MPP 的区别
ClickHouse 常被描述为 MPP 架构,但与传统意义上的 MPP(如 Trino、Greenplum)有重要区别。
传统 MPP(以 Trino/Presto 为代表):多台机器各自持有部分数据,查询时在各机器上并行执行(Distributed Execution),通过网络交换中间结果(Shuffle/Exchange)完成跨节点的聚合和 JOIN。计算资源水平扩展,适合超大规模数据集。
ClickHouse 的 MPP:首先是单机内的多线程并行——同一台服务器上,一个查询会并发启动多个线程,每个线程处理一部分数据(按 Granule 粒度划分),最后合并结果。利用多核 CPU 的全部算力。
在分布式部署时,ClickHouse 也支持跨节点并行:查询广播到所有 Shard,每个 Shard 内部再多线程执行,最后由发起查询的节点(Initiator)汇总。
ClickHouse 的并行模型更接近”单机 SMP + 分布式广播”,而不是 Trino 那种完全对等的 MPP 计划生成与 Shuffle。这带来的差异:
- ClickHouse 的 JOIN 和聚合在大多数情况下不需要 Shuffle(数据直接在本地完成),因此延迟极低
- 但当需要跨 Shard 的大表 JOIN 时,ClickHouse 的分布式 JOIN 性能不如 Trino(Trino 有完整的 Distributed Hash Join)
4.2 单机并行度的配置
-- 查询使用的线程数(默认等于 CPU 核心数)
SET max_threads = 16;
-- 一次读取的 Granule 数量(影响预读效率)
SET max_read_buffer_size = 10485760; -- 10MBClickHouse 对并行度的控制非常细粒度,可以在 user profile 层面限制不同用户的最大线程数,防止单个分析查询耗尽所有 CPU 资源,影响其他查询。
第 5 章 ClickHouse 的技术定位与选型
5.1 ClickHouse 的强项
- 高吞吐扫描:百亿行单表聚合查询,秒级响应(依赖 MergeTree 主键剪枝 + 向量化计算)
- 实时数据写入 + 近实时查询:数据写入后(秒级到分钟级 Part 合并后)即可查询
- 高压缩率:列存 + 编码,存储成本低
- SQL 友好:标准 SQL + 丰富的分析函数(window function、array function 等)
5.2 ClickHouse 的弱项(选型时必须知道)
- 不支持事务:没有 ACID 保证,不适合需要事务的场景
- 更新/删除代价高:
ALTER TABLE UPDATE/DELETE是异步 Mutation,会重写整个 Part,不适合高频更新 - 高并发点查差:同时执行数百个 SQL 时,每个 SQL 占用多个线程,CPU 竞争严重;行存 + 索引的数据库(MySQL)在点查场景完胜
- 不适合高基数小表的频繁 JOIN:对于复杂的多表 JOIN,Trino/Spark 的分布式 Shuffle 更胜任
5.3 与同类系统的定位对比
| 维度 | ClickHouse | Doris | Trino | Hive |
|---|---|---|---|---|
| 架构 | 列存+MPP | 列存+MPP | 内存计算+MPP | 列存+MR/Spark |
| 查询延迟 | 亚秒到秒级 | 秒级 | 秒到分钟级 | 分钟级 |
| 实时写入 | 好(批量 Insert) | 好(Stream Load) | 不支持写入 | 差 |
| 大表 JOIN | 一般(本地优先) | 好(Shuffle) | 好(完整 Shuffle) | 好(MapReduce) |
| 并发查询 | 中(多线程消耗大) | 好 | 好 | 一般 |
| 更新/删除 | 差(Mutation 重写) | 好(Unique 模型) | 不适合 | 差 |
| 适用场景 | 单表大数据量聚合 | 多表关联 OLAP | 跨数据源联邦查询 | 大规模离线批处理 |
第 6 章 小结
ClickHouse 的性能优势来自三个层次的协同设计:
- 物理存储层:列存储大幅减少 IO 量(只读需要的列),高压缩率进一步降低 IO;Granule 设计使稀疏索引能完全驻留内存
- 计算执行层:向量化执行(8192 行一批)充分利用 CPU 缓存和 SIMD 指令,避免逐行处理的开销
- 并行调度层:单机多线程 MPP 充分利用多核 CPU;分布式时广播到各 Shard 并行执行
这三层共同作用,使 ClickHouse 在”扫描大量数据做聚合计算”的 OLAP 场景中达到极致性能。但这种极致优化是有代价的——在它不擅长的场景(高频更新、高并发点查、复杂多表 JOIN),ClickHouse 并不是最优选择。
延伸阅读:
思考题
- ClickHouse 的列式存储将同一列的数据连续存放在磁盘上。对于分析查询(如
SELECT avg(amount) FROM orders WHERE date > '2024-01-01'),只需要读取amount和date两列——而非整行数据。在什么查询模式下列式存储的优势最大?如果查询需要返回所有列(SELECT *),列式存储是否比行式存储更慢?为什么?- 向量化执行引擎一次处理一批数据(Block,默认 65536 行)而非逐行处理。这使得 CPU 可以利用 SIMD 指令和流水线并行。在一个
WHERE amount > 100的过滤操作中,向量化执行如何利用 AVX2 指令一次比较 8 个 int32 值?列式存储为什么是向量化执行的前提条件?- ClickHouse 是无共享架构(Shared-Nothing)——每个节点存储自己的数据分片,查询时在各节点上并行执行后合并结果。与共享存储架构(如 Snowflake、Trino + S3)相比,无共享架构在弹性扩缩容方面有什么劣势?ClickHouse Cloud 是如何解决这个问题的?