一、前言:速度与激情
先看一组对比数据:
| 场景 | MySQL | Hive | ClickHouse |
|---|---|---|---|
| 10亿行数据 COUNT | 超时 | 2分钟 | 0.3秒 |
| 10亿行数据 GROUP BY | 别想了 | 5分钟 | 1.2秒 |
| 10亿行数据聚合分析 | 🤯 | 😰 | 😎 |
ClickHouse 的江湖地位:
graph LR
A[OLTP
MySQL/PostgreSQL] -->|适合| B[事务处理
增删改查] C[OLAP
ClickHouse/Doris] -->|适合| D[分析查询
统计聚合] style A fill:#ff6b6b style C fill:#4ecdc4
MySQL/PostgreSQL] -->|适合| B[事务处理
增删改查] C[OLAP
ClickHouse/Doris] -->|适合| D[分析查询
统计聚合] style A fill:#ff6b6b style C fill:#4ecdc4
今天我们要回答的核心问题:
ClickHouse 凭什么这么快?它用了什么黑科技?
二、列式存储:OLAP 的基石
2.1 行存 vs 列存
graph TB
subgraph "行式存储 Row-Based"
R1["Row1: ID=1, Name=张三, Age=25, City=北京"]
R2["Row2: ID=2, Name=李四, Age=30, City=上海"]
R3["Row3: ID=3, Name=王五, Age=28, City=广州"]
R1 --- R2 --- R3
end
graph TB
subgraph "列式存储 Column-Based"
C1["ID列: 1, 2, 3"]
C2["Name列: 张三, 李四, 王五"]
C3["Age列: 25, 30, 28"]
C4["City列: 北京, 上海, 广州"]
end
2.2 为什么列存更适合 OLAP?
flowchart LR
subgraph "场景:统计平均年龄"
direction TB
Q["SELECT AVG(Age) FROM users"]
end
subgraph "行存读取"
R["读取全部4个字段
虽然只用1个字段
浪费75%I/O"] end subgraph "列存读取" C["只读取Age列
I/O减少75%
速度起飞🚀"] end Q --> R Q --> C style R fill:#ff6b6b style C fill:#4ecdc4
虽然只用1个字段
浪费75%I/O"] end subgraph "列存读取" C["只读取Age列
I/O减少75%
速度起飞🚀"] end Q --> R Q --> C style R fill:#ff6b6b style C fill:#4ecdc4
列存的三大优势:
| 优势 | 原理 | 效果 |
|---|---|---|
| I/O 减少 | 只读取需要的列 | 查询速度提升数倍 |
| 压缩率高 | 同类型数据连续存储 | 存储空间减少 5-10 倍 |
| 向量化执行 | 批量处理同类型数据 | CPU 缓存命中率高 |
2.3 压缩效果对比
graph LR
subgraph "行存压缩"
A["张三,25,北京,李四,30,上海..."]
A --> A1["数据类型混杂
压缩算法懵逼"] A1 --> A2["压缩率: 2-3倍"] end
压缩算法懵逼"] A1 --> A2["压缩率: 2-3倍"] end
graph LR
subgraph "列存压缩"
B["25,30,28,26,32,29,27..."]
B --> B1["全是整数
delta编码"] B1 --> B2["压缩率: 10-20倍"] C["北京,北京,北京,上海,上海..."] C --> C1["重复值多
字典编码"] C1 --> C2["压缩率: 50-100倍"] end
delta编码"] B1 --> B2["压缩率: 10-20倍"] C["北京,北京,北京,上海,上海..."] C --> C1["重复值多
字典编码"] C1 --> C2["压缩率: 50-100倍"] end
三、ClickHouse 核心架构
3.1 整体架构
graph TB
subgraph "客户端层"
CLI[ClickHouse CLI]
HTTP[HTTP接口]
JDBC[JDBC/ODBC]
SDK[各语言SDK]
end
subgraph "服务层"
Parser[SQL解析器]
Analyzer[查询分析器]
Optimizer[查询优化器]
Executor[执行引擎]
end
subgraph "存储层"
MergeTree[MergeTree引擎]
Buffer[Buffer缓冲]
Parts[数据Part]
Index[稀疏索引]
end
subgraph "底层存储"
Disk[本地磁盘]
S3[S3对象存储]
HDFS[HDFS]
end
CLI & HTTP & JDBC & SDK --> Parser
Parser --> Analyzer --> Optimizer --> Executor
Executor --> MergeTree --> Buffer --> Parts
Parts --> Index
Parts --> Disk & S3 & HDFS
3.2 MergeTree 引擎:ClickHouse 的灵魂
flowchart TB
subgraph "MergeTree工作原理"
A[数据写入] --> B[内存Buffer]
B -->|达到阈值| C[生成Part文件]
C --> D[后台Merge]
D --> E[大Part]
E --> F[标记删除旧Part]
end
subgraph "Part内部结构"
P1[primary.idx
主键索引] P2[column.bin
列数据] P3[column.mrk
Mark标记] P4[checksums.txt
校验文件] end
主键索引] P2[column.bin
列数据] P3[column.mrk
Mark标记] P4[checksums.txt
校验文件] end
四、稀疏索引:以少胜多的艺术
4.1 稀疏索引 vs 稠密索引
graph TB
subgraph "稠密索引 Dense Index (如B+Tree)"
D1["每一行都有索引条目"]
D2["索引大小 ≈ 数据行数"]
D3["内存占用大"]
end
subgraph "稀疏索引 Sparse Index (ClickHouse)"
S1["每8192行一个索引条目"]
S2["索引大小 = 数据行数/8192"]
S3["索引可以全部放内存"]
end
style S1 fill:#4ecdc4
style S2 fill:#4ecdc4
style S3 fill:#4ecdc4
4.2 稀疏索引工作原理
假设有一张表按 user_id 排序,数据如下:
Granule 0 (第0-8191行): user_id = 1000 ~ 1820
Granule 1 (第8192-16383行): user_id = 1821 ~ 2650
Granule 2 (第16384-24575行): user_id = 2651 ~ 3480
...索引内容只存储每个 Granule 的起始值:
| Granule | 起始 user_id |
|---|---|
| 0 | 1000 |
| 1 | 1821 |
| 2 | 2651 |
sequenceDiagram
participant 查询
participant 索引
participant 数据
查询->>索引: WHERE user_id = 2000
索引->>索引: 二分查找:2000 在 1821~2651 之间
索引->>数据: 定位到 Granule 1
数据->>查询: 只扫描 8192 行(而不是全表)
4.3 为什么选择 8192?
graph LR
A[Granule Size] --> B{权衡}
B -->|太小| C["索引太大
占用内存"] B -->|太大| D["跳过的数据少
扫描量大"] B -->|8192| E["经验值平衡
索引小 + 过滤效果好"] style E fill:#4ecdc4
占用内存"] B -->|太大| D["跳过的数据少
扫描量大"] B -->|8192| E["经验值平衡
索引小 + 过滤效果好"] style E fill:#4ecdc4
五、数据压缩:存储的黑科技
5.1 ClickHouse 压缩流程
flowchart LR
A[原始数据] --> B[列式存储]
B --> C[编码 Encoding]
C --> D[压缩 Compression]
D --> E[磁盘存储]
subgraph "编码方式"
C1[Delta
时间序列] C2[DoubleDelta
时间戳] C3[Gorilla
浮点数] C4[LowCardinality
枚举值] end subgraph "压缩算法" D1[LZ4
速度优先] D2[ZSTD
压缩率优先] end C --> C1 & C2 & C3 & C4 D --> D1 & D2
时间序列] C2[DoubleDelta
时间戳] C3[Gorilla
浮点数] C4[LowCardinality
枚举值] end subgraph "压缩算法" D1[LZ4
速度优先] D2[ZSTD
压缩率优先] end C --> C1 & C2 & C3 & C4 D --> D1 & D2
5.2 各种编码方式详解
Delta 编码:处理递增序列
graph LR
subgraph "原始数据"
A["1000, 1001, 1002, 1003, 1004"]
end
subgraph "Delta编码后"
B["1000, 1, 1, 1, 1"]
end
subgraph "存储空间"
C["大数字 → 小数字
压缩效果显著"] end A --> B --> C
压缩效果显著"] end A --> B --> C
DoubleDelta 编码:处理时间戳
graph TD
subgraph "时间戳序列"
A["1704067200
1704067260
1704067320
1704067380"] end subgraph "一阶Delta" B["1704067200
60
60
60"] end subgraph "二阶Delta (DoubleDelta)" C["1704067200
60
0
0"] end A -->|"差值"| B -->|"差值的差值"| C
1704067260
1704067320
1704067380"] end subgraph "一阶Delta" B["1704067200
60
60
60"] end subgraph "二阶Delta (DoubleDelta)" C["1704067200
60
0
0"] end A -->|"差值"| B -->|"差值的差值"| C
LowCardinality:枚举值优化
-- 假设 status 字段只有 5 种值
-- 原始存储:每行存完整字符串
"PENDING", "PROCESSING", "COMPLETED", "FAILED", "CANCELLED"
-- LowCardinality 存储:字典 + 索引
字典:{0: "PENDING", 1: "PROCESSING", 2: "COMPLETED", 3: "FAILED", 4: "CANCELLED"}
数据:0, 0, 1, 2, 2, 2, 3, 1, 2, 4... (每个只占 1 字节)5.3 压缩效果实测
xychart-beta
title "10亿行数据压缩对比"
x-axis ["原始CSV", "MySQL InnoDB", "ClickHouse LZ4", "ClickHouse ZSTD"]
y-axis "存储大小(GB)" 0 --> 500
bar [450, 280, 45, 28]
| 格式 | 大小 | 压缩比 |
|---|---|---|
| 原始 CSV | 450GB | 1x |
| MySQL InnoDB | 280GB | 1.6x |
| ClickHouse LZ4 | 45GB | 10x |
| ClickHouse ZSTD | 28GB | 16x |
六、向量化执行:榨干 CPU 性能
6.1 传统逐行处理 vs 向量化处理
graph TB
subgraph "传统逐行处理"
A1[读取第1行] --> B1[处理第1行] --> C1[输出第1行]
C1 --> A2[读取第2行] --> B2[处理第2行] --> C2[输出第2行]
C2 --> A3["...重复N次"]
end
graph TB
subgraph "向量化处理 SIMD"
A["读取一批数据
(如1024行)"] --> B["批量处理
(CPU SIMD指令)"] --> C["批量输出"] end
(如1024行)"] --> B["批量处理
(CPU SIMD指令)"] --> C["批量输出"] end
6.2 SIMD 是什么?
SIMD = Single Instruction, Multiple Data(单指令多数据)
graph LR
subgraph "传统:4次加法"
A1["a1 + b1 = c1"]
A2["a2 + b2 = c2"]
A3["a3 + b3 = c3"]
A4["a4 + b4 = c4"]
end
graph LR
subgraph "SIMD:1次加法"
B["[a1,a2,a3,a4] + [b1,b2,b3,b4]
= [c1,c2,c3,c4]"] end
= [c1,c2,c3,c4]"] end
flowchart TB
subgraph "CPU指令级并行"
A[256位寄存器] --> B["同时处理
8个32位整数
或4个64位整数"] B --> C["一条指令完成
原本需要8条指令的工作"] end
8个32位整数
或4个64位整数"] B --> C["一条指令完成
原本需要8条指令的工作"] end
6.3 ClickHouse 向量化实现
// 伪代码:传统标量处理
for (int i = 0; i < n; i++) {
result[i] = a[i] + b[i]; // 一次处理1个
}
// 伪代码:ClickHouse向量化处理
for (int i = 0; i < n; i += 8) {
__m256i va = _mm256_loadu_si256(&a[i]); // 加载8个int
__m256i vb = _mm256_loadu_si256(&b[i]);
__m256i vr = _mm256_add_epi32(va, vb); // 一条指令加8个
_mm256_storeu_si256(&result[i], vr);
}6.4 向量化带来的收益
graph LR
A[向量化优势] --> B[减少指令数]
A --> C[CPU缓存友好]
A --> D[流水线优化]
B --> B1["原本N条指令
变成N/8条"] C --> C1["连续内存访问
预取效率高"] D --> D1["减少分支预测失败
指令级并行"]
变成N/8条"] C --> C1["连续内存访问
预取效率高"] D --> D1["减少分支预测失败
指令级并行"]
七、数据分片与副本
7.1 分布式架构
graph TB
subgraph "ClickHouse集群"
subgraph "Shard 1 分片1"
N1[Node1
Replica1] N2[Node2
Replica2] N1 -.->|复制| N2 end subgraph "Shard 2 分片2" N3[Node3
Replica1] N4[Node4
Replica2] N3 -.->|复制| N4 end subgraph "Shard 3 分片3" N5[Node5
Replica1] N6[Node6
Replica2] N5 -.->|复制| N6 end end ZK[(ZooKeeper)] --> N1 & N2 & N3 & N4 & N5 & N6
Replica1] N2[Node2
Replica2] N1 -.->|复制| N2 end subgraph "Shard 2 分片2" N3[Node3
Replica1] N4[Node4
Replica2] N3 -.->|复制| N4 end subgraph "Shard 3 分片3" N5[Node5
Replica1] N6[Node6
Replica2] N5 -.->|复制| N6 end end ZK[(ZooKeeper)] --> N1 & N2 & N3 & N4 & N5 & N6
7.2 分布式查询执行
sequenceDiagram
participant Client
participant Coordinator
participant Shard1
participant Shard2
participant Shard3
Client->>Coordinator: SELECT SUM(amount) FROM orders
Coordinator->>Shard1: 计算本地SUM
Coordinator->>Shard2: 计算本地SUM
Coordinator->>Shard3: 计算本地SUM
Shard1-->>Coordinator: SUM=100
Shard2-->>Coordinator: SUM=200
Shard3-->>Coordinator: SUM=150
Coordinator->>Coordinator: 合并: 100+200+150=450
Coordinator-->>Client: 返回 450
7.3 分片键选择
flowchart TD
A[选择分片键] --> B{数据分布}
B -->|均匀分布| C["按ID/时间分片
各节点负载均衡"] B -->|需要关联查询| D["按业务键分片
如tenant_id
减少跨节点JOIN"] B -->|时序数据| E["按时间分片
冷热分离方便"]
各节点负载均衡"] B -->|需要关联查询| D["按业务键分片
如tenant_id
减少跨节点JOIN"] B -->|时序数据| E["按时间分片
冷热分离方便"]
八、MergeTree 引擎家族
8.1 引擎类型一览
graph TB
A[MergeTree家族] --> B[MergeTree
基础引擎] A --> C[ReplacingMergeTree
去重] A --> D[SummingMergeTree
预聚合] A --> E[AggregatingMergeTree
增量聚合] A --> F[CollapsingMergeTree
折叠删除] A --> G[VersionedCollapsingMergeTree
版本折叠] B --> B1["最常用
适合大多数场景"] C --> C1["按主键去重
保留最新"] D --> D1["自动SUM数值列
实时汇总"] E --> E1["存储聚合中间态
复杂聚合"] F --> F1["通过标记实现删除
用于CDC"] G --> G1["支持乱序数据
的折叠"]
基础引擎] A --> C[ReplacingMergeTree
去重] A --> D[SummingMergeTree
预聚合] A --> E[AggregatingMergeTree
增量聚合] A --> F[CollapsingMergeTree
折叠删除] A --> G[VersionedCollapsingMergeTree
版本折叠] B --> B1["最常用
适合大多数场景"] C --> C1["按主键去重
保留最新"] D --> D1["自动SUM数值列
实时汇总"] E --> E1["存储聚合中间态
复杂聚合"] F --> F1["通过标记实现删除
用于CDC"] G --> G1["支持乱序数据
的折叠"]
8.2 ReplacingMergeTree 示例
-- 场景:用户表,需要按user_id去重,保留最新版本
CREATE TABLE users (
user_id UInt64,
name String,
age UInt8,
update_time DateTime
) ENGINE = ReplacingMergeTree(update_time) -- 按update_time保留最新
ORDER BY user_id;
-- 插入数据(假设同一用户更新多次)
INSERT INTO users VALUES (1, '张三', 25, '2024-01-01 10:00:00');
INSERT INTO users VALUES (1, '张三', 26, '2024-01-02 10:00:00'); -- 更新年龄
-- 注意:去重在后台Merge时发生,不是立即生效
-- 强制去重查询:
SELECT * FROM users FINAL WHERE user_id = 1;8.3 SummingMergeTree 示例
-- 场景:实时统计每日每商品的销售额
CREATE TABLE daily_sales (
date Date,
product_id UInt64,
sales_count UInt64,
sales_amount Decimal(18,2)
) ENGINE = SummingMergeTree() -- 自动SUM数值列
ORDER BY (date, product_id);
-- 插入多条同一天同一商品的数据
INSERT INTO daily_sales VALUES ('2024-01-15', 1001, 10, 100.00);
INSERT INTO daily_sales VALUES ('2024-01-15', 1001, 5, 50.00);
INSERT INTO daily_sales VALUES ('2024-01-15', 1001, 8, 80.00);
-- Merge后自动汇总为一条:
-- 2024-01-15, 1001, 23, 230.00九、实战:ClickHouse 建表最佳实践
9.1 完整建表示例
-- 电商订单明细表
CREATE TABLE order_detail (
-- 时间字段
order_time DateTime CODEC(DoubleDelta),
order_date Date DEFAULT toDate(order_time),
-- 业务主键
order_id UInt64,
order_no String,
-- 维度字段(低基数)
user_id UInt64,
product_id UInt64,
category_id UInt32,
province LowCardinality(String), -- 省份只有34个
city LowCardinality(String),
order_status LowCardinality(String),
pay_type LowCardinality(String),
-- 度量字段
quantity UInt32,
unit_price Decimal(10,2),
total_amount Decimal(12,2),
discount_amount Decimal(10,2),
-- 扩展字段
extra_info String CODEC(ZSTD(3)) -- JSON字符串,高压缩
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(order_date) -- 按月分区
ORDER BY (order_date, user_id, order_id) -- 排序键
SETTINGS
index_granularity = 8192, -- 索引粒度
merge_with_ttl_timeout = 86400; -- TTL合并间隔
-- 添加TTL:180天后删除
ALTER TABLE order_detail
MODIFY TTL order_date + INTERVAL 180 DAY;9.2 关键设计要点
mindmap
root((ClickHouse建表要点))
分区策略
按时间分区最常见
避免分区过多
便于数据生命周期管理
排序键
高频查询字段在前
基数低的字段在前
决定稀疏索引效果
编码选择
时间用DoubleDelta
低基数用LowCardinality
大文本用ZSTD
数据类型
能小则小Int32优于Int64
避免Nullable
String有长度限制用FixedString
9.3 常见踩坑
graph TD
A[ClickHouse踩坑集锦] --> B["坑1: 高频小批量写入"]
A --> C["坑2: 分区过多"]
A --> D["坑3: 用错排序键"]
A --> E["坑4: Nullable滥用"]
B --> B1["每次INSERT产生一个Part
后台Merge忙不过来
解决:Buffer表/批量写入"] C --> C1["每个分区都有开销
几万个分区性能崩溃
解决:按月/周分区"] D --> D1["查询条件与排序键不匹配
全表扫描
解决:排序键=高频查询条件"] E --> E1["Nullable需要额外存储标记位
查询多一次判断
解决:用默认值代替NULL"]
后台Merge忙不过来
解决:Buffer表/批量写入"] C --> C1["每个分区都有开销
几万个分区性能崩溃
解决:按月/周分区"] D --> D1["查询条件与排序键不匹配
全表扫描
解决:排序键=高频查询条件"] E --> E1["Nullable需要额外存储标记位
查询多一次判断
解决:用默认值代替NULL"]
十、ClickHouse 查询优化
10.1 EXPLAIN 分析
EXPLAIN SYNTAX -- 查看改写后的SQL
SELECT * FROM order_detail WHERE order_date = '2024-01-15';
EXPLAIN PLAN -- 查看执行计划
SELECT user_id, SUM(total_amount)
FROM order_detail
WHERE order_date >= '2024-01-01'
GROUP BY user_id;
EXPLAIN PIPELINE -- 查看执行流水线
SELECT * FROM order_detail WHERE user_id = 12345;10.2 常用优化技巧
-- 1. 利用PREWHERE提前过滤(比WHERE更早执行)
SELECT * FROM order_detail
PREWHERE order_status = 'COMPLETED' -- 先按此条件过滤
WHERE total_amount > 100;
-- 2. 使用物化视图预聚合
CREATE MATERIALIZED VIEW mv_daily_sales
ENGINE = SummingMergeTree()
ORDER BY (order_date, product_id)
AS SELECT
order_date,
product_id,
count() AS order_count,
sum(total_amount) AS total_sales
FROM order_detail
GROUP BY order_date, product_id;
-- 3. 使用投影(Projection)
ALTER TABLE order_detail
ADD PROJECTION proj_by_product (
SELECT product_id, order_date, sum(total_amount)
GROUP BY product_id, order_date
);10.3 查询性能优化清单
flowchart TD
A[查询优化检查清单] --> B{排序键命中?}
B -->|否| B1["调整WHERE条件
或重建排序键"] B -->|是| C{分区裁剪?} C -->|否| C1["WHERE加分区条件"] C -->|是| D{数据类型合理?} D -->|否| D1["用更小的类型
用LowCardinality"] D -->|是| E{需要预聚合?} E -->|是| E1["创建物化视图"] E -->|否| F[优化完成✅]
或重建排序键"] B -->|是| C{分区裁剪?} C -->|否| C1["WHERE加分区条件"] C -->|是| D{数据类型合理?} D -->|否| D1["用更小的类型
用LowCardinality"] D -->|是| E{需要预聚合?} E -->|是| E1["创建物化视图"] E -->|否| F[优化完成✅]
十一、ClickHouse vs 传统数据库
11.1 能力对比
| 特性 | MySQL | ClickHouse |
|---|---|---|
| 写入 | 高频小批量 ✅ | 批量追加 ✅ |
| 更新/删除 | 高效 ✅ | 代价大 ❌ |
| 点查询 | 毫秒级 ✅ | 可以但非最优 |
| OLAP分析 | 性能差 ❌ | 极强 ✅ |
| 实时性 | 实时 ✅ | 准实时(秒级) |
| JOIN | 高效 ✅ | 支持但不推荐 |
| 事务 | 完整ACID ✅ | 不支持 ❌ |
11.2 适用场景
graph TB
subgraph "ClickHouse适合"
A1[日志分析]
A2[用户行为分析]
A3[实时报表]
A4[时序数据]
A5[大宽表查询]
end
subgraph "ClickHouse不适合"
B1[高频更新删除]
B2[事务处理]
B3[Key-Value查询]
B4[复杂多表JOIN]
B5[高并发点查]
end
style A1 fill:#4ecdc4
style A2 fill:#4ecdc4
style A3 fill:#4ecdc4
style A4 fill:#4ecdc4
style A5 fill:#4ecdc4
style B1 fill:#ff6b6b
style B2 fill:#ff6b6b
style B3 fill:#ff6b6b
style B4 fill:#ff6b6b
style B5 fill:#ff6b6b
十二、总结:ClickHouse 为什么快?
mindmap
root((ClickHouse快的秘密))
列式存储
只读需要的列
压缩率高
缓存友好
稀疏索引
索引小可放内存
快速定位数据
跳过大量无关数据
数据压缩
智能编码Delta/Gorilla
多种压缩算法
10倍以上压缩
向量化执行
SIMD指令
批量处理
榨干CPU性能
分布式架构
水平扩展
并行查询
副本容错
MergeTree引擎
LSM思想
后台合并优化
灵活的引擎选择
一句话总结:
ClickHouse = 列式存储 + 稀疏索引 + 极致压缩 + 向量化执行 + 分布式并行
把 OLAP 场景的每一个环节都做到极致,所以它快得飞起!🚀