Clickhouse原理剖析
文章目录
- 前言
- 设计哲学
- 列式存储
- 向量化执行
- MergeTree引擎
- 执行流水线
- 拓扑架构
- 最佳实践
- 拓展
前言
在现代化的BI领域,大部分企业内部具备大规模的数据,而传统的关系型数据库难以满足OLAP系统中对于大规模的数据高效的分析和处理的需要,同时传统基于Hadoop的生态,因为其组件化的设计导致系统的变得复杂和臃肿,但是现在化系统中对于实效性的要求越来越高,Hadoop在海量数据和高实效性的双重压力面前显得有些力不从心,而轻量高效的ClickHouse出现使这种痛点得以解决
设计哲学
// Clickhouse设计哲学
type ClickHouseDesign struct {Storage ColumnarStorage // 列式存储Execution VectorizedEngine // 向量化执行Indexing SparseIndexing // 稀疏索引
}
列式存储
- 数据按列存储:每个列的数据单独存储在磁盘的连续区域中
- 查询时只读取所需列:避免读取整行中无关的数据,大幅减少 I/O
- 极致的数据压缩:
- 同列数据类型一致,压缩效率极高(如LZ4)
- 数据压缩比通常可达 5-10 倍,显著降低存储成本和 I/O 压力
- CPU 缓存友好:连续读取同类型数据,能更高效地利用 CPU 缓存
适合在宽表中获取小部分行数据
- 行存结构
[RowID, CreatedDate, UserID, Action, Country ][1, 2025-6-15, 1001, Click, US ][2, 2025-6-16, 1002, View, CN ]
- 列存结构
CreatedDate: [2025-6-15, 2025-6-16]UserIDs: [1001, 1002]Actions: [Click, View]Countries: [US, CN]
向量化执行
- 批量处理(Array-at-a-time):不再逐行处理,而是一次性处理一整列数据块(Block)。
- 利用 SIMD 指令:
- Single Instruction Multiple Data,单指令流多数据流。
- 现代CPU支持一条指令同时处理多个数据(如 16 个整数相加)。
- 向量化引擎自动将操作编译为SIMD指令,极大提升聚合、过滤等计算密集型操作速度。
- 减少函数调用/分支预测开销:处理一个数据块只需少量函数调用,避免逐行处理的高昂开销。
- 传统行处理:
[Row 1001] [Row 1002] [Row 1003]↓ ↓ ↓[处理] [处理] [处理]↓ ↓ ↓[结果1] [结果2] [结果3]
- ClickHouse向量化:
[1001,1002,1003] │ ← 列内数据批量载入CPU缓存↓
[SIMD指令批量处理]↓
[结果1,结果2,结果3]
MergeTree引擎
Partition 202506 # 分区目录(按分区键生成)
├─ Part1 # 数据片段目录(有序) 命名格式:分区ID_最小块号_最大块号_合并层级
│ ├─ primary.idx # 稀疏索引(index_granularity行为一块,抽取一块中的第一行记录到文件中)
│ ├─ UserID.bin # 列数据
│ ├─ EventTime.bin
│ ├─ UserID.mrk2 # 列标记文件(连接索引和数据)
│ └─ EventTime.mrk2
│
└─ Part2├─ primary.idx└─ ...# 主键索引结构示例
Granule 0: (2025-06-01, 1000)
Granule 1: (2025-06-01, 2000) ← 每8192行一个标记
Granule 2: (2025-06-02, 1500)
-
核心思想
写入时追加,后台合并优化 -
数据写入流程
1. 数据片段写入流程:
- 每个 Part 包含数据文件(bin)、索引文件(idx)和列标记文件(mrk)写入的数据首先在内存中积累,达到阈值后生成新的数据片段(Part)2. 后台数据片段合并过程:
- 按排序键(Sorting Key)对数据重新排序
- 应用去重逻辑(如有)
- 优化存储空间定期将小 Part 合并为大 Part,减少文件数量
- 数据查询流程
根据条件过滤出相关的Partition
在Partition 中的 primary.idx 通过二分查找定位到目标数据所在的 Granule 索引号 (Granule Number)
读取该列对应的 .mrk2 文件,找到该 Granule Number 对应的记录
根据 offset_in_compressed_file 定位到 .bin 文件中的压缩块位置
读取该压缩块并解压
在解压后的数据块中,根据 offset_in_decompressed_block 找到目标 Granule 的起始位置
扫描该 Granule (约 8192 行) 以找到满足查询条件的行Partition → primary.idx → .mrk2 → .bin
执行流水线
func executePipeline(query Query) Result {// 阶段1:数据读取reader := make(chan DataBlock)go func() {defer close(reader)for part := range locateParts(query) {reader <- readColumnData(part, query.Columns)}}()// 阶段2:向量化处理processor := make(chan ResultBlock)go func() {defer close(processor)for block := range reader {result := vectorProcess(block)processor <- result}}()// 阶段3:结果归并var finalResult Resultfor res := range processor {finalResult = mergeResults(finalResult, res)}return finalResult
}
拓扑架构
CREATE TABLE my_db.my_table ON CLUSTER cluster1
(...
)
ENGINE = MergeTree()
ORDER BY (event_date, user_id)
PARTITION BY toYYYYMM(event_date);CREATE TABLE my_db.my_table ON CLUSTER cluster1
(...
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/my_table/{shard}', '{replica}')
ORDER BY (event_date, user_id)
PARTITION BY toYYYYMM(event_date);
# 拥有相同ClickhouseKeeper路径的不同副本之间会自动相互复制,不同副本都可以读写-- 创建分布式表时指定分片键
CREATE TABLE distributed_table (id Int32,name String,age In32,date Date
) ENGINE = Distributed('my_cluster', -- 集群名称'my_db', -- 数据库名'my_table', -- 本地表名id -- 分片键,可以是某个列(如id)、多列组合(如name, age)、任意表达式(如hash(id))、复合键分片(id、toYYYYMM(date))、按写入顺序依次分配到各个分片rand()
)
当一个副本写入新数据时,会在ClickhouseKeeper中放入一个复制日志条目,所有副本会定期向Clickhouse相应的路径中拉取复制日志,发现新条目后,会通过http协议从源副本下载新数据
最佳实践
- 分区剪枝,查询时,先根据分区键过滤无关分区,大幅减少扫描数据量。
-- 仅扫描2025年06月的分区
SELECT * FROM my_table WHERE date >= '2025-06-01' AND date < '2025-06-12';
- 索引加速,通过稀疏索引快速定位数据块,结合排序键避免全表扫描。
-- 利用排序键(id)快速定位
SELECT * FROM my_table WHERE id = 1000;
拓展
- 跳数索引
创建时添加
CREATE TABLE user_activity (event_date Date,user_id UInt32,event_type String,device String,INDEX idx_device device TYPE minmax GRANULARITY 3,
) ENGINE = MergeTree()
ORDER BY (event_date, user_id)
PARTITION BY toYYYYMM(event_date);
创建后添加
ALTER TABLE user_activity ADD INDEX idx_device device TYPE minmax GRANULARITY 3;
跳数索引是用来补充主键索引的
通过合理配置跳数索引,可以将特定查询性能提升10-100倍