ClickHouse系列 (一):为什么 ClickHouse 具备高性能分析能力

定位:认知地基。理解 ClickHouse 的速度不是"调参调出来的",而是从存储格式、执行模型到硬件利用,每一层都在为分析查询服务。


一、ClickHouse 要解决的核心问题是什么

在讨论"快"之前,先明确 ClickHouse 的战场------OLAP(联机分析处理)

OLAP 查询的典型特征:

特征 说明
读多写少 数据批量灌入,查询远多于写入
宽表少列 表可能有数百列,但单次查询只涉及 3-5 列
聚合为主 COUNTSUMAVGPERCENTILE 是主角
扫描量大 动辄扫描数亿行,但结果集很小
无事务要求 不需要行级锁、MVCC、回滚

传统 OLTP 数据库(MySQL、PostgreSQL)为行级事务 优化------每一行的所有列紧密存放在一起,方便单行读写。但当你执行 SELECT avg(amount) FROM orders WHERE date > '2024-01-01' 时,MySQL 不得不把每一行的所有列都从磁盘读出来,即使你只需要 amountdate 两列。

ClickHouse 的设计哲学很简单:把 OLAP 查询路径上的每一个环节都做到极致


二、列式存储 + 向量化执行的真实收益

2.1 列式存储:只读你需要的列

复制代码
行式存储(MySQL)磁盘布局:
┌──────────────────────────────────────────────┐
│ row1: id=1, name="Alice", age=30, amount=100 │
│ row2: id=2, name="Bob",   age=25, amount=200 │
│ row3: id=3, name="Carol", age=35, amount=150 │
└──────────────────────────────────────────────┘
→ 查询 SUM(amount) 需要读取所有列的数据

列式存储(ClickHouse)磁盘布局:
┌─────────────────────┐
│ id:     [1, 2, 3]   │  ← 一个文件
│ name:   [A, B, C]   │  ← 一个文件
│ age:    [30, 25, 35] │  ← 一个文件
│ amount: [100,200,150]│  ← 一个文件
└─────────────────────┘
→ 查询 SUM(amount) 只需要读 amount 这一列

假设一张表有 200 列,查询只涉及 3 列,列式存储的 I/O 量直接降为行式的 3/200 = 1.5%。这不是优化,这是数量级的差异。

2.2 压缩率:同类型数据天然高压缩

列式存储的另一个隐藏收益是压缩率极高。同一列的数据类型相同、值域相近,压缩算法(LZ4、ZSTD)可以获得 5-20 倍的压缩比。

sql 复制代码
-- 查看表的压缩情况
SELECT
    column,
    formatReadableSize(data_compressed_bytes) AS compressed,
    formatReadableSize(data_uncompressed_bytes) AS uncompressed,
    round(data_uncompressed_bytes / data_compressed_bytes, 2) AS ratio
FROM system.columns
WHERE table = 'my_table'
ORDER BY data_uncompressed_bytes DESC;

实际生产中,一张包含 100 亿行日志的表,原始大小 2TB,在 ClickHouse 中压缩后通常只占 150-300GB。

2.3 向量化执行:批量处理而非逐行处理

传统数据库的执行模型是火山模型(Volcano Model) ------每个算子一次处理一行,通过 next() 调用逐行传递。这意味着每处理一行都有一次虚函数调用的开销。

ClickHouse 采用向量化执行引擎:每次处理一批数据(默认 8192 行),用紧凑的列式数组在算子间传递。

复制代码
火山模型(逐行):
Filter.next() → 返回 1 行 → Aggregate.next() → 返回 1 行
  ↑ 每行一次函数调用,CPU 分支预测频繁失败

向量化模型(批量):
Filter.process(block[8192行]) → 返回 block → Aggregate.process(block)
  ↑ 一次调用处理 8192 行,CPU 流水线充分利用

向量化的收益不仅是减少函数调用次数,更关键的是让 CPU 能够使用 SIMD 指令进行并行计算。


三、列式存储如何对齐 ORDER BY 排序

列式存储把每列拆开存放,但排序需要行与行之间的对应关系------ClickHouse 通过行号(Row Number)对齐来解决这个问题。

3.1 核心机制:所有列文件共享同一个行号顺序

写入时,ClickHouse 先按 ORDER BY整行 排序,然后把排好序的结果按列拆开写入各自的 .bin 文件。每个列文件中,第 N 个值一定对应同一行。

复制代码
假设 ORDER BY (date, user_id),写入 3 行数据:

排序前:
  row0: date=2024-01-03, user_id=100, amount=50
  row1: date=2024-01-01, user_id=200, amount=30
  row2: date=2024-01-01, user_id=100, amount=80

按 (date, user_id) 排序后:
  row0: date=2024-01-01, user_id=100, amount=80   ← 行号 0
  row1: date=2024-01-01, user_id=200, amount=30   ← 行号 1
  row2: date=2024-01-03, user_id=100, amount=50   ← 行号 2

拆成列文件存储:
  date.bin:    [2024-01-01, 2024-01-01, 2024-01-03]   ← 行号 0,1,2
  user_id.bin: [100,        200,        100       ]   ← 行号 0,1,2
  amount.bin:  [80,         30,         50        ]   ← 行号 0,1,2
                ↑ 同一个行号位置,跨列文件一定是同一行的数据

3.2 物理对齐的保证:Granule 与 Mark 文件

ClickHouse 把数据按固定行数(默认 8192 行)切成 Granule ,每个 Granule 是读取的最小单位。所有列通过 .mrk2(Mark 文件)记录每个 Granule 在对应 .bin 文件中的偏移量,行号一一对应。

复制代码
一个 Part 的物理结构:

date.bin:      [granule0: 8192行] [granule1: 8192行] [granule2: 8192行] ...
user_id.bin:   [granule0: 8192行] [granule1: 8192行] [granule2: 8192行] ...
amount.bin:    [granule0: 8192行] [granule1: 8192行] [granule2: 8192行] ...
                    ↕ 对齐               ↕ 对齐              ↕ 对齐

primary.idx:   [mark0 → granule0] [mark1 → granule1] [mark2 → granule2]

date.mrk2:     [mark0: offset=0] [mark1: offset=xxx] [mark2: offset=yyy]
user_id.mrk2:  [mark0: offset=0] [mark1: offset=xxx] [mark2: offset=yyy]
amount.mrk2:   [mark0: offset=0] [mark1: offset=xxx] [mark2: offset=yyy]

Mark 3 在 date.bin 里指向的那 8192 行,和在 amount.bin 里指向的那 8192 行,一定是同一批行。

3.3 查询时的还原过程

sql 复制代码
SELECT user_id, amount FROM t WHERE date = '2024-01-01';

执行流程:

  1. 通过 primary.idx 定位:date='2024-01-01' 在 Granule 0 中
  2. 通过 date.mrk2 找到 date.bin 中 Granule 0 的偏移,读出来做过滤,得到行号 0 和 1 匹配
  3. 通过 user_id.mrk2amount.mrk2 找到对应列文件中 Granule 0 的偏移,读取行号 0 和 1 的值
  4. 拼装返回:(100, 80), (200, 30)

一句话总结:ClickHouse 是先排序、再拆列,所有列文件通过共享的行号顺序和 Mark 文件保持对齐。排序发生在写入时(以及后台 Merge 时),查询时不需要重新排序,直接按 Granule 粒度跨列文件读取即可还原出完整的行。


四、为什么 ClickHouse 不怕全表扫描

很多从 MySQL 转过来的工程师会问:"ClickHouse 没有 B-Tree 索引,全表扫描不会很慢吗?"

答案是:ClickHouse 的"全表扫描"和 MySQL 的全表扫描完全不是一回事。

3.1 稀疏索引 + 跳数索引

ClickHouse 使用稀疏索引(Sparse Index) ,而非 B-Tree。主键索引每隔 index_granularity(默认 8192)行记录一个索引条目。

复制代码
主键索引结构(假设按 date 排序):
Mark 0: date = 2024-01-01  → 指向第 0-8191 行
Mark 1: date = 2024-01-15  → 指向第 8192-16383 行
Mark 2: date = 2024-02-01  → 指向第 16384-24575 行
...

查询 WHERE date = '2024-01-20':
→ 二分查找定位到 Mark 1,只读取第 8192-16383 行的数据
→ 跳过了其他所有 granule

这种设计的代价是无法高效查找单行(不适合点查),但收益是索引极小(百亿行的索引可能只有几十 MB),完全可以常驻内存。

3.2 分区裁剪

sql 复制代码
CREATE TABLE events (
    event_date Date,
    user_id UInt64,
    event_type String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_type, user_id);

-- 查询自动裁剪分区,只扫描 2024-01 的数据
SELECT count() FROM events WHERE event_date >= '2024-01-01' AND event_date < '2024-02-01';

分区裁剪 + 稀疏索引 + 列裁剪,三者叠加后,ClickHouse 的"全表扫描"实际读取的数据量可能只有物理总量的 0.1%。


五、CPU Cache / SIMD 在 ClickHouse 中的作用

4.1 CPU Cache 友好

列式存储天然对 CPU Cache 友好。当你对一列连续的 UInt64 数组求和时,数据在内存中是连续排列的,CPU 的 L1/L2 Cache 预取机制可以高效工作。

复制代码
行式存储的内存访问模式(Cache 不友好):
[id, name, age, amount] [id, name, age, amount] [id, name, age, amount]
      ↑ 跳跃访问 amount,Cache Line 利用率低

列式存储的内存访问模式(Cache 友好):
[amount, amount, amount, amount, amount, amount, ...]
  ↑ 顺序访问,Cache Line 100% 利用

4.2 SIMD 加速

ClickHouse 大量使用 SIMD(Single Instruction, Multiple Data 单指令多数据流)是一种并行计算技术,允许 CPU 或 GPU 仅通过一条指令同时处理多组数据指令 。例如对一个 UInt32 数组求和,一条 AVX2 指令可以同时处理 8 个 32 位整数。

复制代码
标量执行:sum += a[0]; sum += a[1]; sum += a[2]; ... (逐个累加)
SIMD 执行:sum_vec = _mm256_add_epi32(sum_vec, load(a[0..7])); (8 个一起加)

ClickHouse 的源码中有大量针对不同 CPU 架构(SSE4.2、AVX2、AVX-512)的特化实现,这也是它比很多同类列式数据库更快的原因之一。


六、和 MySQL / Elasticsearch 的根本设计差异

维度 MySQL (InnoDB) Elasticsearch ClickHouse
存储模型 行式(B+Tree 聚簇索引) 倒排索引 + 列式(Doc Values) 列式(MergeTree)
优化目标 单行事务 CRUD 全文检索 + 聚合 大规模聚合分析
写入模型 随机写(WAL + Buffer Pool) 近实时索引(Refresh) 批量追加(Append-only)
压缩率 低(1-2x) 中(3-5x) 高(5-20x)
聚合 10 亿行 分钟级(甚至不可行) 秒-十秒级 亚秒-秒级
点查单行 毫秒级 毫秒级 十毫秒级(不擅长)
并发能力 高(数千 QPS) 低(数十-百级 QPS)

核心差异一句话总结:MySQL 为事务 而生,Elasticsearch 为搜索 而生,ClickHouse 为聚合而生。


七、和时序数据库(InfluxDB / TimescaleDB / TDengine)的对比

很多团队在选型时会纠结:我的数据带时间戳,到底该用时序数据库还是 ClickHouse?

6.1 时序数据库的核心设计

时序数据库(TSDB)围绕一个核心抽象:时间线(Time Series)------由一组固定标签(tag)标识的、按时间排列的数值序列。

复制代码
时间线模型:
{metric="cpu_usage", host="server-01", region="us-east"}
    → (t1, 72.5), (t2, 68.3), (t3, 75.1), ...

{metric="cpu_usage", host="server-02", region="us-west"}
    → (t1, 45.2), (t2, 51.0), (t3, 48.7), ...

TSDB 针对这种模式做了大量优化:自动按时间分片、内置降采样(downsampling)、自动过期删除(retention policy)、针对时间戳和浮点数的特殊压缩(Gorilla 编码、Delta-of-Delta 等)。

6.2 核心差异对比

维度 InfluxDB TimescaleDB TDengine ClickHouse
数据模型 时间线(measurement + tag + field) 关系表(基于 PostgreSQL) 超级表(设备→子表) 通用列式表
查询语言 InfluxQL / Flux 标准 SQL 标准 SQL 标准 SQL(扩展)
内置降采样 ✅ Continuous Query / Task ✅ 连续聚合 ✅ 流式计算 ❌ 需手动 MV + Rollup
内置 Retention ✅ Retention Policy ✅ 自动压缩策略 ✅ KEEP 参数 ✅ TTL(需手动配置)
高基数标签 ⚠️ 性能急剧下降(倒排索引膨胀) ✅ 较好(B-Tree 索引) ⚠️ 子表数量爆炸 ✅ 天然擅长(列式 + 稀疏索引)
聚合分析能力 基础(有限的 GROUP BY) 强(完整 SQL) 中等 极强(窗口函数、数组函数、近似算法)
多表 JOIN ❌ 不支持 ✅ 完整支持 ⚠️ 有限支持 ✅ 支持(大表 JOIN 需注意)
写入吞吐 中(百万点/秒级) 高(百万点/秒级) 极高(百万行/秒级)
压缩率 高(时序特化编码) 高(可配置 Codec 组合)
生态与运维 独立生态 PostgreSQL 生态 独立生态 独立生态,社区活跃

6.3 高基数问题:TSDB 的阿喀琉斯之踵

时序数据库最大的痛点是高基数(High Cardinality) 。当标签的唯一组合数量达到百万甚至千万级时(比如用 traceIduserId 作为标签),TSDB 的性能会急剧下降:

  • InfluxDB:每个时间线对应一个倒排索引条目,高基数导致索引膨胀、内存暴涨
  • TDengine:每个标签组合创建一张子表,百万级子表的元数据管理成为瓶颈

ClickHouse 没有"时间线"的概念,高基数字段只是普通的列,通过稀疏索引 + 列式扫描处理,不会因为基数增长而出现性能悬崖。

sql 复制代码
-- 在 ClickHouse 中,traceId 这种高基数字段完全没问题
SELECT
    traceId,
    min(timestamp) AS start_time,
    max(timestamp) - min(timestamp) AS duration
FROM traces
WHERE timestamp >= now() - INTERVAL 1 HOUR
GROUP BY traceId
ORDER BY duration DESC
LIMIT 10;
-- 即使 traceId 有数亿个唯一值,查询依然高效

6.4 什么时候选 TSDB,什么时候选 ClickHouse

选时序数据库的场景:

  • 纯粹的设备监控 / IoT 指标采集,标签基数低(< 10 万时间线)
  • 需要开箱即用的降采样、Retention、告警规则
  • 团队规模小,不想维护复杂的 MV + Rollup 体系
  • 数据模型高度规整(固定的 metric + tag + value 结构)

选 ClickHouse 的场景:

  • 数据包含高基数字段(traceId、userId、requestId 等)
  • 需要复杂的分析查询(多维聚合、窗口函数、子查询、JOIN)
  • 数据模型多样(Trace、Log、Metrics、业务事件混合存储)
  • 写入吞吐要求极高(百万行/秒以上)
  • 需要灵活的 SQL 能力和丰富的函数库

实际趋势: 越来越多的可观测性平台(如 Grafana Tempo、SigNoz、Uptrace)选择 ClickHouse 作为 Trace 和 Metrics 的统一存储后端,正是因为它在高基数场景下的优势和通用的 SQL 分析能力。ClickHouse 自身也在持续增强时序场景的支持(如 DateTime64 纳秒精度、Gorilla / DoubleDelta 编码等)。


八、什么场景下不应该用 ClickHouse

ClickHouse 不是银弹。以下场景请三思:

  1. 高并发点查:如果你的业务是根据主键查单行(如用户详情页),ClickHouse 的并发能力和点查延迟都不如 MySQL 或 Redis。

  2. 频繁更新/删除 :ClickHouse 的 ALTER TABLE ... UPDATEDELETE 是重量级的 Mutation 操作,会重写整个 Part。如果你的业务需要频繁更新行,请用 OLTP 数据库。

  3. 事务需求:ClickHouse 没有传统意义上的事务(无 BEGIN/COMMIT/ROLLBACK)。如果你需要跨表原子操作,它不适合。

  4. 小数据量:如果你的数据只有几十万行,MySQL 加个索引就够了,ClickHouse 的优势体现不出来。

  5. 复杂 JOIN:ClickHouse 的 JOIN 能力在持续改进,但面对多表复杂 JOIN(尤其是大表 JOIN 大表),性能不如专门的 MPP 数据库(如 Presto、Trino)。


总结

ClickHouse 的速度来自一套系统性的设计选择

复制代码
列式存储(减少 I/O)
    ↓
高压缩比(减少磁盘和内存占用)
    ↓
向量化执行(减少函数调用开销)
    ↓
SIMD 指令(单指令处理多数据)
    ↓
CPU Cache 友好(顺序内存访问)
    ↓
稀疏索引 + 分区裁剪(减少扫描范围)

每一层都在为同一个目标服务:让分析查询尽可能快地扫描尽可能少的数据。理解了这一点,你就能理解 ClickHouse 后续所有的设计决策------包括下一篇要讲的 MergeTree 引擎家族。

相关推荐
小小程序员.¥2 小时前
oracle--plsql块、存储过程、存储函数
数据库·sql·oracle
fire-flyer2 小时前
ClickHouse系列(四):压缩不是为了省磁盘,而是为了更快的查询
数据库·clickhouse
刘~浪地球2 小时前
Redis 从入门到精通(十四):内存管理与淘汰策略
数据库·redis·缓存
火山引擎开发者社区2 小时前
从监控盲区到业务洞察:深入解读 APMPlus 生产指标
大数据·人工智能·microsoft
海边的Kurisu2 小时前
MySQL | 从SQL到数据的完整路径
数据库·mysql·架构
xiaoduo AI2 小时前
客服机器人可按客户等级差异化回复吗?Agent 系统能否识别 VIP 并优先转接人工?
大数据·人工智能·机器人
G31135422733 小时前
零门槛实现 TRTC 音视频流转推各大直播 CDN
大数据·人工智能·ai·云计算
longxibo3 小时前
【Ubuntu datasophon1.2.1 二开之九:验证离线数据入湖】
大数据·linux·运维·ubuntu
rainy雨3 小时前
精益班组建设通过标准化作业解决现场管理混乱难题,推动精益班组建设落地
大数据·运维·数据挖掘·数据分析·精益工程
Ashley_Amanda3 小时前
UiPath完全指南:从入门到精通
大数据·人工智能