定位:认知地基。理解 ClickHouse 的速度不是"调参调出来的",而是从存储格式、执行模型到硬件利用,每一层都在为分析查询服务。
一、ClickHouse 要解决的核心问题是什么
在讨论"快"之前,先明确 ClickHouse 的战场------OLAP(联机分析处理)。
OLAP 查询的典型特征:
| 特征 | 说明 |
|---|---|
| 读多写少 | 数据批量灌入,查询远多于写入 |
| 宽表少列 | 表可能有数百列,但单次查询只涉及 3-5 列 |
| 聚合为主 | COUNT、SUM、AVG、PERCENTILE 是主角 |
| 扫描量大 | 动辄扫描数亿行,但结果集很小 |
| 无事务要求 | 不需要行级锁、MVCC、回滚 |
传统 OLTP 数据库(MySQL、PostgreSQL)为行级事务 优化------每一行的所有列紧密存放在一起,方便单行读写。但当你执行 SELECT avg(amount) FROM orders WHERE date > '2024-01-01' 时,MySQL 不得不把每一行的所有列都从磁盘读出来,即使你只需要 amount 和 date 两列。
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';
执行流程:
- 通过
primary.idx定位:date='2024-01-01'在 Granule 0 中 - 通过
date.mrk2找到date.bin中 Granule 0 的偏移,读出来做过滤,得到行号 0 和 1 匹配 - 通过
user_id.mrk2和amount.mrk2找到对应列文件中 Granule 0 的偏移,读取行号 0 和 1 的值 - 拼装返回:
(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) 。当标签的唯一组合数量达到百万甚至千万级时(比如用 traceId、userId 作为标签),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 不是银弹。以下场景请三思:
-
高并发点查:如果你的业务是根据主键查单行(如用户详情页),ClickHouse 的并发能力和点查延迟都不如 MySQL 或 Redis。
-
频繁更新/删除 :ClickHouse 的
ALTER TABLE ... UPDATE和DELETE是重量级的 Mutation 操作,会重写整个 Part。如果你的业务需要频繁更新行,请用 OLTP 数据库。 -
事务需求:ClickHouse 没有传统意义上的事务(无 BEGIN/COMMIT/ROLLBACK)。如果你需要跨表原子操作,它不适合。
-
小数据量:如果你的数据只有几十万行,MySQL 加个索引就够了,ClickHouse 的优势体现不出来。
-
复杂 JOIN:ClickHouse 的 JOIN 能力在持续改进,但面对多表复杂 JOIN(尤其是大表 JOIN 大表),性能不如专门的 MPP 数据库(如 Presto、Trino)。
总结
ClickHouse 的速度来自一套系统性的设计选择:
列式存储(减少 I/O)
↓
高压缩比(减少磁盘和内存占用)
↓
向量化执行(减少函数调用开销)
↓
SIMD 指令(单指令处理多数据)
↓
CPU Cache 友好(顺序内存访问)
↓
稀疏索引 + 分区裁剪(减少扫描范围)
每一层都在为同一个目标服务:让分析查询尽可能快地扫描尽可能少的数据。理解了这一点,你就能理解 ClickHouse 后续所有的设计决策------包括下一篇要讲的 MergeTree 引擎家族。