搜 索

ClickHouse从入门到放弃 列存原理:为什么它这么快

  • 12阅读
  • 2023年04月29日
  • 0评论
首页 / AI/大数据 / 正文

一、前言:速度与激情

先看一组对比数据:

场景MySQLHiveClickHouse
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

今天我们要回答的核心问题:

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

列存的三大优势:

优势原理效果
I/O 减少只读取需要的列查询速度提升数倍
压缩率高同类型数据连续存储存储空间减少 5-10 倍
向量化执行批量处理同类型数据CPU 缓存命中率高

2.3 压缩效果对比

graph LR subgraph "行存压缩" A["张三,25,北京,李四,30,上海..."] A --> A1["数据类型混杂
压缩算法懵逼"] 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

三、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

四、稀疏索引:以少胜多的艺术

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
01000
11821
22651
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

五、数据压缩:存储的黑科技

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

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

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

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]
格式大小压缩比
原始 CSV450GB1x
MySQL InnoDB280GB1.6x
ClickHouse LZ445GB10x
ClickHouse ZSTD28GB16x

六、向量化执行:榨干 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

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
flowchart TB subgraph "CPU指令级并行" A[256位寄存器] --> B["同时处理
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["减少分支预测失败
指令级并行"]

七、数据分片与副本

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

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["按时间分片
冷热分离方便"]

八、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["支持乱序数据
的折叠"]

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"]

十、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[优化完成✅]

十一、ClickHouse vs 传统数据库

11.1 能力对比

特性MySQLClickHouse
写入高频小批量 ✅批量追加 ✅
更新/删除高效 ✅代价大 ❌
点查询毫秒级 ✅可以但非最优
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 场景的每一个环节都做到极致,所以它快得飞起!🚀


评论区
暂无评论
avatar