1. ClickHouse 是什么?
ClickHouse 是一个快速开源的 OLAP(联机分析处理)数据库管理系统,由俄罗斯的 Yandex 开发并于 2016 年开源。它以极快的查询速度著称,在合理硬件配置下,单节点 ClickHouse 可以达到每秒处理数十亿行的性能。
ClickHouse 的核心特性:
- 列式存储:数据按列存储,分析查询只需读取涉及的列,大幅减少 I/O
- 向量化执行(Vectorized Execution):使用 SIMD 指令批量处理数据,而非逐行处理
- MPP 架构(Massively Parallel Processing):查询在多个 CPU 核心和服务器上并行执行
- 高效的压缩:列式存储使得同类型数据集中存储,压缩率远高于行式数据库
- 实时数据摄入:支持每秒处理数百万行的数据写入
- SQL 接口:兼容标准 SQL,降低学习成本
2. OLTP vs OLAP:场景的本质差异
理解 ClickHouse 的设计需要从 OLTP(事务处理)和 OLAP(分析处理)的区别说起:
| 维度 | OLTP(MySQL/PostgreSQL) | OLAP(ClickHouse) |
|---|---|---|
| 典型查询 | SELECT * WHERE id = ? | SELECT sum(), avg() GROUP BY |
| 数据修改 | 频繁 UPDATE/DELETE | 批量写入,极少更新 |
| 数据规模 | GB 到 TB | TB 到 PB |
| 查询模式 | 点查、短事务 | 全表扫描、聚合计算 |
| 优化目标 | 事务一致性、低延迟 | 高吞吐、快速聚合 |
| 存储模型 | 行式存储 | 列式存储 |
形象地说,如果是管理订单的电商数据库(OLTP),我们会经常查询"订单 12345 的详情"。如果是分析用户行为的日志系统(OLAP),我们会查询"昨天每个地区的平均页面加载时间"。前者需要的是快速定位一行数据,后者需要的是扫描数十亿行并做聚合计算。
3. 列式存储的优势
3.1 存储模型对比
行式存储:
Row 1: [id=1, name="Alice", age=30, city="Beijing"]
Row 2: [id=2, name="Bob", age=25, city="Shanghai"]
Row 3: [id=3, name="Carol", age=35, city="Beijing"]
磁盘上的物理布局(紧凑排列):
[1,"Alice",30,"Beijing"][2,"Bob",25,"Shanghai"][3,"Carol",35,"Beijing"]
列式存储:
Column "id": [1, 2, 3]
Column "name": ["Alice", "Bob", "Carol"]
Column "age": [30, 25, 35]
Column "city": ["Beijing", "Shanghai", "Beijing"]
磁盘上的物理布局(每列独立存储):
[id块]: 1,2,3 [name块]: "Alice","Bob","Carol"
[age块]: 30,25,35 [city块]: "Beijing","Shanghai","Beijing"
3.2 列式存储的查询优势
当执行 SELECT AVG(age) FROM users WHERE city = 'Beijing' 时:
- 行式数据库:必须加载所有行的所有列(id、name、age、city),筛选 city 为 Beijing 的行,再计算 age 的平均值
- 列式数据库:只需加载 age 列和 city 列,city 列扫描找出北京用户的行号,age 列直接对这些行号求平均值
在 100 列、10 亿行的表中,行式数据库需要读取 10 亿 × 100 列的数据,ClickHouse 只需读取 2 列的数据。
3.3 压缩效率
列式存储的第二个巨大优势是压缩率。因为同一列的数据类型相同,值域通常也相近,压缩算法可以发挥到极致:
- 整数列:相邻行的值差异通常很小,Delta 编码后压缩率极高
- 枚举列(如 city、status):字典编码后只需存储整数索引
- 时间戳列:有序的时间序列数据经过 Delta-of-Delta 编码后体积大幅减少
ClickHouse 的压缩率通常为 1:5 到 1:10,意味着 10TB 的原始数据在 ClickHouse 中仅需 1-2TB 存储。这直接转化为:更少的磁盘 I/O → 更快的查询速度 → 更低的存储成本。
4. 向量化执行引擎
4.1 传统逐行执行 vs 向量化执行
传统数据库的执行方式被称为"火山模型(Volcano Model)":
for each row in table:
check WHERE condition
if true:
add to result set
result = aggregate(result set)
这种方式的问题是 CPU 大部分时间花在了循环控制和分支预测上,而不是实际计算。
ClickHouse 的向量化执行每次处理一批(通常是 65536 行)数据:
data = read_column_batch(65536)
filtered = SIMD_compare(data.city, "Beijing") # SIMD 并行比较
ages = SIMD_gather(data.age, filtered) # SIMD 并行取数
result = SIMD_sum(ages) / SIMD_count(filtered) # SIMD 并行计算
SIMD(Single Instruction Multiple Data) 是 CPU 的向量指令集(如 SSE、AVX),可以在一条指令中同时对多个数据执行相同操作。
4.2 为什么向量化执行快很多?
| 因素 | 逐行执行 | 向量化执行 |
|---|---|---|
| CPU 分支预测 | 频繁分支,预测失败率高 | 批量处理,分支极少 |
| 缓存利用 | 每行随机访问,缓存失效 | 连续内存访问,缓存命中率高 |
| SIMD 利用 | 无 | 充分使用 AVX-512 |
| 函数调用 | 每行一次 | 每批一次 |
| 内存带宽 | 浪费在无关数据上 | 只读需要的列 |
在理想情况下,向量化执行配合 AVX-512 可以同时处理 512/64 = 8 个 64 位整数的数据,理论加速比可达 8 倍。实际中由于内存带宽、分支处理和数据对齐等因素,通常可以获得 3-5 倍 的加速。
5. MergeTree 引擎家族
MergeTree 是 ClickHouse 最核心的表引擎。它的设计灵感来自 Google 的 LevelDB 和 Bigtable 的 LSM-Tree。
5.1 基本写入流程
写入数据 → 内存中的数据块(part)→ 按主键排序 → 异步合并(merge)
-- 创建 MergeTree 表
CREATE TABLE events (
event_date Date,
user_id UInt64,
event_type String,
value Float64
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(event_date)
ORDER BY (user_id, event_type);
当数据写入时:
- 数据首先按
ORDER BY定义的键排序 - 数据被写入一个按
PARTITION BY规则命名的新 part - 后台进程周期性地将小的 part 合并成更大的 part(这个过程叫 merge)
- 合并后的 part 保持有序,且合并是后台异步进行的,不影响前台查询
5.2 Part 合并策略
初始写入: [Part 1: 1000 rows] [Part 2: 1000 rows] [Part 3: 1000 rows]
第一次合并:[Part 4: 3000 rows]
新写入: [Part 4: 3000 rows] [Part 5: 1000 rows] [Part 6: 1000 rows]
第二次合并:[Part 7: 5000 rows]
合并策略遵循分层规则:相同层级的 part 达到一定数量后合并成下一层级。这确保了 part 数量不会无限增长,同时也保证了新写入的数据能够快速查询(不需要等待大合并完成)。
5.3 主键与稀疏索引
ClickHouse 的主键和传统数据库的索引完全不同:
- ClickHouse 主键:不保证唯一性,主要用于数据排序和范围剪枝
- 稀疏索引(Sparse Index):每 8192 行存储一个索引标记(mark),而非每行一个索引项
数据(按 user_id 排序): [1,1,1,1,2,2,2,3,3,4,4,4,...]
↑ ↑ ↑
mark 1 mark 2 mark 3
user_id∈[1,2] user_id∈[2,3] user_id∈[3,4]
查询 WHERE user_id = 3:
1. 检查 marks:mark2 的范围是 [2,3],mark3 的范围是 [3,4]
2. 只需读取 mark2 和 mark3 对应的数据块
3. 其他 marks 直接跳过
稀疏索引极大地减少了索引体积(只有每 8192 行一个条目,而非每行一个),同时仍能有效剪枝不需要的数据块。
6. 分布式架构
6.1 分片与副本
ClickHouse 的分布式架构涉及两个核心概念:
- 分片(Shard):数据水平分割到不同服务器,每个分片包含数据的一个子集
- 副本(Replica):每个分片的冗余拷贝,用于高可用和负载均衡
集群架构示例(2 分片 × 2 副本):
Shard 1: Shard 2:
┌─────────────┐ ┌─────────────┐
│ Replica 1A │ │ Replica 2A │
│ (192.168.1.1)│ │ (192.168.1.3)│
└─────────────┘ └─────────────┘
┌─────────────┐ ┌─────────────┐
│ Replica 1B │ │ Replica 2B │
│ (192.168.1.2)│ │ (192.168.1.4)│
└─────────────┘ └─────────────┘
数据分布:ID % 2 == 0 → Shard 1 ID % 2 == 1 → Shard 2
6.2 分布式表
分布式表本身不存储数据,而是作为代理将查询路由到实际的分片:
-- 在每个分片上创建本地表
CREATE TABLE events_local ON CLUSTER my_cluster (
event_date Date,
user_id UInt64,
event_type String
) ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/events', '{replica}')
PARTITION BY toYYYYMM(event_date)
ORDER BY (user_id, event_type);
-- 创建分布式表(代理层)
CREATE TABLE events_distributed ON CLUSTER my_cluster AS events_local
ENGINE = Distributed(my_cluster, default, events_local, rand());
-- 用户查询分布式表
SELECT event_type, count() FROM events_distributed
WHERE event_date > today() - 7
GROUP BY event_type;
-- ClickHouse 自动将查询分发到所有分片,聚合结果后返回
7. ClickHouse 的局限性
了解 ClickHouse 的适用边界同样重要:
- 不支持事务:没有 BEGIN/COMMIT/ROLLBACK,不适合需要 ACID 的场景
- 不支持点更新/删除:虽然支持,但效率远低于行式数据库(异步后台操作)
- JOIN 性能有限:跨分片 JOIN 性能较差,大表 JOIN 需要谨慎设计
- 没有二级索引:只有主键的稀疏索引,点查需要扫描较多数据
- 内存要求高:复杂查询需要大量内存用于聚合和排序
适用场景:日志分析、时序数据、数据仓库、监控指标、BI 报表
不适用场景:事务型业务、高频更新的用户资料、需要强一致性的订单系统
8. 总结
ClickHouse 通过以下设计在 OLAP 领域实现了极致性能:
| 设计决策 | 收益 |
|---|---|
| 列式存储 | 只读需要的列,减少 I/O |
| 高效压缩 | 降低存储成本,提高内存利用率 |
| 向量化执行 | 充分利用 CPU SIMD,提高计算效率 |
| 稀疏索引 | 小索引体积实现大范围剪枝 |
| 数据分区 | 快速删除过期数据,分区级并行 |
| 异步合并 | 写操作不阻塞,后台优化存储 |
| MPP 架构 | 查询水平扩展到多节点 |
对于需要从海量数据中提取洞察的业务——无论是网站用户行为分析、物联网设备监控、金融交易审计还是广告效果追踪——ClickHouse 都是值得深入研究的利器。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。