01 ClickHouse 全局架构——列式存储与 MPP 执行引擎

摘要

ClickHouse 是 Yandex 开发的开源列式存储 OLAP 数据库,以极致的查询性能在实时分析领域树立了标杆。本文从 OLAP 场景的本质需求出发,剖析行存与列存的根本差异,揭示 ClickHouse 的三大核心设计——列式物理存储、SIMD 向量化执行和单机多线程 MPP——如何协同实现百亿行数据秒级聚合查询。同时厘清 ClickHouse 与 DorisTrino、Hive 在定位上的本质差异,帮助读者建立准确的技术选型判断。


第 1 章 为什么需要 ClickHouse——OLAP 场景的特殊性

1.1 OLTP 与 OLAP 的根本差异

在讨论 ClickHouse 之前,必须先理解它所解决的问题——OLAP 场景的查询特征与 OLTP 有根本差异。

OLTP(在线事务处理):MySQL、PostgreSQL 等传统关系数据库的设计目标。典型查询是”按主键找一行”或”按索引找少量行”,每次查询读取的行数极少(通常 1-100 行),但并发量高(每秒数千个事务)。行存储对这类查询非常高效——一行数据的所有列存储在一起,取一行只需一次磁盘寻址。

OLAP(在线分析处理):数据仓库、实时报表的设计目标。典型查询是”对过去 30 天的销售数据按地区分组求和”,需要扫描数十亿行,但每次查询只读取表的少数几列(如 dateregionamount),并发量低(每秒几个到几十个查询)。

OLAP 查询对行存储极为不利:

  • 行存储中每行的所有列紧邻存储,读取 amount 列时,磁盘必须同时读取同一行的 user_idproduct_namedescription 等完全不需要的列
  • 对于宽表(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;
}

向量化的优势:

  1. SIMD 并行:AVX2 指令可以一次处理 4 个 64-bit 浮点数(或 8 个 32-bit 整数),AVX-512 可以处理 8 个 64-bit 浮点数。对于求和这类操作,理论上比标量执行快 4-8 倍
  2. CPU 缓存友好:8192 个 Float64 = 64KB,恰好适合 CPU L1/L2 缓存,整批数据计算期间不需要内存访问
  3. 分支预测优化:处理一批数据时,过滤条件(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;  -- 10MB

ClickHouse 对并行度的控制非常细粒度,可以在 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 与同类系统的定位对比

维度ClickHouseDorisTrinoHive
架构列存+MPP列存+MPP内存计算+MPP列存+MR/Spark
查询延迟亚秒到秒级秒级秒到分钟级分钟级
实时写入好(批量 Insert)好(Stream Load)不支持写入
大表 JOIN一般(本地优先)好(Shuffle)好(完整 Shuffle)好(MapReduce)
并发查询中(多线程消耗大)一般
更新/删除差(Mutation 重写)好(Unique 模型)不适合
适用场景单表大数据量聚合多表关联 OLAP跨数据源联邦查询大规模离线批处理

第 6 章 小结

ClickHouse 的性能优势来自三个层次的协同设计:

  1. 物理存储层:列存储大幅减少 IO 量(只读需要的列),高压缩率进一步降低 IO;Granule 设计使稀疏索引能完全驻留内存
  2. 计算执行层:向量化执行(8192 行一批)充分利用 CPU 缓存和 SIMD 指令,避免逐行处理的开销
  3. 并行调度层:单机多线程 MPP 充分利用多核 CPU;分布式时广播到各 Shard 并行执行

这三层共同作用,使 ClickHouse 在”扫描大量数据做聚合计算”的 OLAP 场景中达到极致性能。但这种极致优化是有代价的——在它不擅长的场景(高频更新、高并发点查、复杂多表 JOIN),ClickHouse 并不是最优选择。


延伸阅读


思考题

  1. ClickHouse 的列式存储将同一列的数据连续存放在磁盘上。对于分析查询(如 SELECT avg(amount) FROM orders WHERE date > '2024-01-01'),只需要读取 amountdate 两列——而非整行数据。在什么查询模式下列式存储的优势最大?如果查询需要返回所有列(SELECT *),列式存储是否比行式存储更慢?为什么?
  2. 向量化执行引擎一次处理一批数据(Block,默认 65536 行)而非逐行处理。这使得 CPU 可以利用 SIMD 指令和流水线并行。在一个 WHERE amount > 100 的过滤操作中,向量化执行如何利用 AVX2 指令一次比较 8 个 int32 值?列式存储为什么是向量化执行的前提条件?
  3. ClickHouse 是无共享架构(Shared-Nothing)——每个节点存储自己的数据分片,查询时在各节点上并行执行后合并结果。与共享存储架构(如 Snowflake、Trino + S3)相比,无共享架构在弹性扩缩容方面有什么劣势?ClickHouse Cloud 是如何解决这个问题的?