ClickHouse 列存引擎:MergeTree 与数据压缩
ClickHouse 列存引擎:MergeTree 与数据压缩深度解析
📖 目录
1. 列式存储架构设计
1.1 为什么选择列式存储?
ClickHouse 作为 OLAP(Online Analytical Processing)数据库,核心设计目标是处理海量数据的快速分析查询。列式存储是其性能基石。
列式存储 vs 行式存储对比:
| 特性 | 列式存储 | 行式存储 |
|---|---|---|
| 适用场景 | 分析查询、聚合统计 | 事务处理、点查询 |
| I/O 开销 | 仅读取需要的列 | 必须读取整行 |
| 压缩比 | 极高(同类数据聚集) | 较低(混合数据类型) |
| 查询性能 | 聚合查询快 10-100 倍 | 单行查询快 |
| 写入性能 | 批量写入优秀 | 单行插入优秀 |
| 典型产品 | ClickHouse, Vertica, Kudu | MySQL, PostgreSQL, Oracle |
核心优势:
- I/O 减少:分析查询通常只需少数列(如
SELECT COUNT(status) FROM events),列式存储只需读取 status 列 - 压缩率高:同列数据类型相同、取值范围集中,压缩比可达 10:1 甚至 100:1
- 向量化执行:CPU SIMD 指令批量处理同类型数据,提升 10-100 倍计算性能
1.2 ClickHouse 数据存储模型
ClickHouse 将每列数据独立存储为多个数据块(Data Parts),每个数据块包含列数据文件和索引文件。
-- ClickHouse 表创建示例
CREATE TABLE events_local ON CLUSTER 'cluster' (
event_time DateTime,
user_id UInt64,
event_type String,
status UInt8,
duration UInt32,
metadata String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, user_id)
SETTINGS index_granularity = 8192;
存储结构:
/data/clickhouse/data/default/events_local/
├── 202403_1_2_0/ # 分区目录
│ ├── event_time.bin # DateTime 列数据
│ ├── event_time.mrk2 # 稀疏索引标记
│ ├── user_id.bin
│ ├── user_id.mrk2
│ ├── event_type.bin
│ ├── event_type.mrk2
│ ├── status.bin
│ ├── status.mrk2
│ ├── duration.bin
│ ├── duration.mrk2
│ ├── metadata.bin
│ ├── metadata.mrk2
│ ├── checksums.txt # 校验和
│ └── columns.txt # 列元数据
├── 202404_3_4_0/
└── detached/ # 分离的分区
1.3 向量化执行引擎
ClickHouse 的向量化执行是其性能的关键:
流程图:向量化执行流程
源码位置(ClickHouse 23.8.5.26):
src/Processors/Transforms/AggregatingTransform.cpp # 聚合向量化
src/Functions/IFunction.cpp # 函数向量化
src/Columns/ColumnVector.cpp # 列向量实现
向量化优势示例:
// 标量处理(传统方式)
for (size_t i = 0; i < n; ++i) {
result[i] = a[i] + b[i];
}
// 向量化处理(ClickHouse 方式)
// 使用 AVX2/AVX-512 指令,一次处理 256/512 位(8/16 个 Float64)
__m256d va = _mm256_load_pd(a);
__m256d vb = _mm256_load_pd(b);
__m256d vr = _mm256_add_pd(va, vb);
_mm256_store_pd(result, vr);
2. MergeTree 表引擎原理
MergeTree 是 ClickHouse 最核心的表引擎系列,几乎所有其他引擎都基于它扩展。理解 MergeTree 是掌握 ClickHouse 的关键。
2.1 MergeTree 核心架构
流程图:MergeTree 数据写入流程
2.2 数据分区(Partition)
分区是 ClickHouse 管理海量数据的重要机制:
分区键设计原则:
| 分区策略 | 示例 | 适用场景 | 注意事项 |
|---|---|---|---|
| 按时间分区 | toYYYYMM(event_time) | 日志、事件流 | 避免分区过小(< 1GB) |
| 按用户哈希 | intHash32(user_id) % 100 | 用户隔离 | 分区数量不宜超过 1000 |
| 按类别 | status | 少量类别 | 类别数量 < 100 |
| 复合分区 | (toYYYYMM(event_time), region) | 多维度查询 | 谨慎使用,避免分区爆炸 |
源码位置(ClickHouse 23.8.5.26):
// src/Storages/MergeTree/MergeTreeData.h
class MergeTreeData {
// 分区选择器
PartitionSelector partition_selector;
// 分区元数据
struct Partition {
std::string value; // 分区值
UInt64 min_block; // 最小块号
UInt64 max_block; // 最大块号
UInt64 level; // 合并层级
std::string name; // 分区目录名
};
};
最佳实践:
-- ❌ 错误:分区过细(每天一个分区)
ENGINE = MergeTree()
PARTITION BY toDate(event_time) -- 365 个分区/年
-- ✅ 正确:按月分区
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time) -- 12 个分区/年
-- ✅ 正确:不分区(数据量小时)
ENGINE = MergeTree()
-- 不使用 PARTITION BY
2.3 主键与排序键
ClickHouse 的排序键(ORDER BY)和主键(PRIMARY KEY)设计独特:
关键区别:
| 特性 | ORDER BY | PRIMARY KEY |
|---|---|---|
| 定义 | 数据在磁盘的物理排序 | 稀疏索引的排序 |
| 默认行为 | 未指定时 = PRIMARY KEY | 未指定时 = ORDER BY 的前导列 |
| 约束 | 可以包含任何列 | 必须是 ORDER BY 的前缀子集 |
| 作用范围 | 决定数据存储顺序 | 加速点查询 |
示例:
-- 示例 1:ORDER BY = PRIMARY KEY
CREATE TABLE users_1 (
user_id UInt64,
event_time DateTime,
status UInt8
) ENGINE = MergeTree()
ORDER BY (user_id, event_time);
-- PRIMARY KEY 默认为 (user_id, event_time)
-- 示例 2:PRIMARY KEY 是 ORDER BY 的前缀
CREATE TABLE users_2 (
user_id UInt64,
event_time DateTime,
status UInt8
) ENGINE = MergeTree()
ORDER BY (user_id, event_time, status)
PRIMARY KEY user_id;
-- PRIMARY KEY 只有 user_id,但 ORDER BY 包含所有列
稀疏索引原理:
-- 默认索引粒度:每 8192 行创建一个索引标记
SETTINGS index_granularity = 8192;
-- 索引文件结构(.mrk2)
-- 每行记录:
-- - 对应数据块的偏移量
-- - 该数据块第一行的排序键值
流程图:稀疏索引查询流程
源码位置(ClickHouse 23.8.5.26):
// src/Storages/MergeTree/MergeTreeRangeReader.cpp
class MergeTreeRangeReader {
// 使用稀疏索引加速范围查询
void readRange(
const MarkRange & range,
Block & block,
const MergeTreeData & data
) {
// 1. 根据 mark 定位数据块
// 2. 读取数据块到内存
// 3. 在内存中过滤不需要的行
}
};
2.4 数据合并(Merge)
MergeTree 的核心特性是后台自动合并小数据块为大数据块:
合并触发条件:
| 条件 | 默认值 | 说明 |
|---|---|---|
parts_to_throw_insert | 300 | 数据块数量超过此值,INSERT 抛异常 |
parts_to_delay_insert | 150 | 数据块数量超过此值,INSERT 延迟 |
max_bytes_to_merge_at_once | 150GB | 单次合并的最大字节数 |
max_bytes_to_merge_at_max_space_space | 1TB | 磁盘空间充足时的最大合并量 |
合并策略:
-- 手动控制合并行为
CREATE TABLE events_merge (
date Date,
user_id UInt32,
value UInt32
) ENGINE = MergeTree()
ORDER BY (date, user_id)
SETTINGS
parts_to_throw_insert = 600, -- 提高阈值
parts_to_delay_insert = 300, -- 提高阈值
max_bytes_to_merge_at_once = 100GB; -- 降低单次合并量
流程图:数据合并决策树
3. MergeTree 变体引擎深度解析
MergeTree 系列提供了多个变体引擎,针对不同场景优化。
3.1 MergeTree 变体对比表
| 引擎名称 | 核心特性 | 适用场景 | 去重支持 | 聚合支持 |
|---|---|---|---|---|
| MergeTree | 基础引擎,无特殊逻辑 | 通用场景 | ❌ | ❌ |
| ReplacingMergeTree | 保留最后/第一版本 | 需要去重的数据 | ✅ | ❌ |
| SummingMergeTree | 数值列自动求和 | 预聚合、物化视图 | ❌ | ✅ |
| CollapsingMergeTree | 折叠正负记录 | 增量更新、去重 | ✅ | ❌ |
| VersionedCollapsingMergeTree | 版本化折叠 | 有序的增量更新 | ✅ | ❌ |
| AggregatingMergeTree | 任意聚合函数 | 高级物化视图 | ❌ | ✅ |
| GraphiteMergeTree | Graphite 专用 | Graphite 时序数据 | ❌ | ✅ |
3.2 ReplacingMergeTree
核心功能: 自动删除重复键的旧记录,保留最新或最旧版本。
语法:
CREATE TABLE events_replacing (
event_time DateTime,
user_id UInt64,
event_type String,
status UInt8,
version UInt64 -- 版本号列(可选)
) ENGINE = ReplacingMergeTree(version)
PARTITION BY toYYYYMM(event_time)
ORDER BY (user_id, event_time);
去重逻辑:
| 去重策略 | 版本列 | 保留记录 |
|---|---|---|
| 默认 | 无 | 最后插入的记录 |
| 指定版本列 | 有 | 版本号最大的记录 |
源码位置(ClickHouse 23.8.5.26):
// src/Storages/MergeTree/MergeTreeDataSelectExecutor.cpp
void ReplacingMergeTreeBlockOutputStream::write(const Block & block) {
// 1. 查找已存在的相同键记录
// 2. 比较版本号
// 3. 保留最新版本,删除旧版本
// 4. 更新稀疏索引
}
重要提示:
⚠️ ReplacingMergeTree 不能保证实时去重! 去重只在合并时发生,可能延迟数分钟。
解决方案:
-- 方法 1:手动触发合并
OPTIMIZE TABLE events_replacing FINAL;
-- 方法 2:查询时去重(推荐)
SELECT *
FROM events_replacing
WHERE user_id = 123
ORDER BY version DESC
LIMIT 1
SETTINGS skip_unavailable_shards = 1;
-- 方法 3:使用 FINAL 关键字(性能较差)
SELECT *
FROM events_replacing FINAL
WHERE user_id = 123;
3.3 SummingMergeTree
核心功能: 数值列自动聚合(求和),适合预聚合场景。
语法:
CREATE TABLE daily_sales (
date Date,
product_id UInt32,
region_id UInt32,
amount UInt64,
order_count UInt32
) ENGINE = SummingMergeTree((order_count)) -- 指定非求和列
PARTITION BY toYYYYMM(date)
ORDER BY (date, product_id, region_id);
聚合规则:
| 列类型 | 默认行为 | 可自定义 |
|---|---|---|
| 数值列 | SUM 聚合 | ❌ |
| 非数值列 | 保留第一条记录 | ✅ 通过参数指定 |
实战示例:
-- 插入数据
INSERT INTO daily_sales VALUES
('2024-03-15', 1, 10, 100, 5),
('2024-03-15', 1, 10, 200, 3);
-- 合并后(自动聚合)
SELECT * FROM daily_sales;
-- 结果:('2024-03-15', 1, 10, 300, 8)
物化视图实战:
-- 原始表
CREATE TABLE orders (
order_id UInt64,
order_time DateTime,
product_id UInt32,
amount UInt64
) ENGINE = MergeTree()
ORDER BY (order_time, product_id);
-- 物化视图(自动聚合)
CREATE MATERIALIZED VIEW orders_daily_mv
ENGINE = SummingMergeTree()
ORDER BY (toStartOfDay(order_time), product_id)
AS SELECT
toStartOfDay(order_time) as day,
product_id,
sum(amount) as total_amount,
count() as order_count
FROM orders
GROUP BY day, product_id;
-- 查询物化视图(极快)
SELECT day, product_id, total_amount, order_count
FROM orders_daily_mv
WHERE day >= '2024-03-01';
3.4 CollapsingMergeTree
核心功能: 通过符号列(sign)折叠记录,实现增量更新。
语法:
CREATE TABLE events_collapsing (
event_time DateTime,
user_id UInt64,
event_type String,
value Int64,
sign Int8 -- 1 = 插入,-1 = 删除
) ENGINE = CollapsingMergeTree(sign)
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, user_id, event_type);
数据折叠逻辑:
-- 插入初始记录
INSERT INTO events_collapsing VALUES
('2024-03-15 10:00:00', 123, 'login', 1, 1);
-- 更新记录(插入删除标记 + 新记录)
INSERT INTO events_collapsing VALUES
('2024-03-15 10:00:00', 123, 'login', 1, -1), -- 删除旧值
('2024-03-15 10:00:00', 123, 'login', 2, 1); -- 插入新值
-- 合并后自动折叠,最终结果:value = 2
⚠️ 重要约束:
- 必须按 ORDER BY 键插入:同一键的记录必须按顺序插入
- sign 列值必须是 1 或 -1
- 不能保证实时折叠:需要等待合并或使用 FINAL
版本化替代方案:
-- VersionedCollapsingMergeTree:自动处理版本号,无需保证插入顺序
CREATE TABLE events_versioned (
event_time DateTime,
user_id UInt64,
value Int64,
sign Int8,
version UInt64 -- 版本列
) ENGINE = VersionedCollapsingMergeTree(sign, version)
ORDER BY (event_time, user_id);
3.5 AggregatingMergeTree
核心功能: 支持任意聚合函数的状态存储,适合高级物化视图。
语法:
CREATE TABLE users_stats (
user_id UInt32,
date Date,
-- 聚合函数状态列
visits AggregateFunction(sum, UInt64),
duration AggregateFunction(avg, UInt64),
pages AggregateFunction(uniq, String)
) ENGINE = AggregatingMergeTree()
ORDER BY (user_id, date);
插入数据:
INSERT INTO users_stats SELECT
user_id,
toDate(event_time) as date,
sumState(toUInt64(1)) as visits,
avgState(duration) as duration,
uniqState(page_url) as pages
FROM events
WHERE event_time >= '2024-03-01'
GROUP BY user_id, date;
查询数据:
SELECT
user_id,
date,
sumMerge(visits) as total_visits,
avgMerge(duration) as avg_duration,
uniqMerge(pages) as unique_pages
FROM users_stats
WHERE date = '2024-03-15'
GROUP BY user_id, date;
流程图:聚合函数状态转换
4. 数据压缩算法与性能对比
ClickHouse 的列式存储天然支持高压缩比,是其性能优势的重要组成部分。
4.1 压缩算法对比
| 压缩算法 | 压缩比 | 压缩速度 | 解压速度 | 适用场景 |
|---|---|---|---|---|
| NONE | 1:1 | 最快 | 最快 | 临时表、测试环境 |
| LZ4 | 3:1 | 极快 | 极快 | 热数据、高频查询 |
| ZSTD(1) | 3:1 | 快 | 快 | 默认推荐 |
| ZSTD(3-7) | 5:1 | 中等 | 中等 | 温数据 |
| ZSTD(10-22) | 10:1 | 慢 | 慢 | 冷数据、归档 |
配置方法:
-- 全局配置(config.xml)
<compression>
<case>
<min_part_size>10000000000</min_part_size> <!-- 10GB -->
<min_part_size_ratio>0.01</min_part_size_ratio>
<method>zstd</method>
<level>3</level>
</case>
</compression>
-- 表级配置
CREATE TABLE compressed_table (
date Date,
data String
) ENGINE = MergeTree()
ORDER BY date
SETTINGS compression_codec = 'ZSTD(5)';
-- 列级配置
CREATE TABLE mixed_compression (
date Date,
hot_data String CODEC(LZ4), -- 热数据
cold_data String CODEC(ZSTD(12)) -- 冷数据
) ENGINE = MergeTree()
ORDER BY date;
4.2 压缩比实战测试
测试数据: 1 亿条事件记录,包含时间戳、用户 ID、事件类型、元数据。
压缩比对比:
-- 测试表创建
CREATE TABLE compression_test (
event_time DateTime,
user_id UInt64,
event_type LowCardinality(String),
status UInt8,
metadata String
) ENGINE = MergeTree()
ORDER BY (event_time, user_id)
SETTINGS compression_codec = 'LZ4';
-- 插入 1 亿条测试数据
INSERT INTO compression_test SELECT
now() - toIntervalSecond(rand() % 86400 * 365),
rand() % 10000000,
['login', 'logout', 'purchase', 'view'][rand() % 4 + 1],
rand() % 256,
repeat('test', rand() % 100)
FROM numbers(100000000);
-- 查看磁盘占用
SELECT
table,
formatReadableSize(sum(bytes_on_disk)) as size,
sum(rows) as rows,
formatReadableSize(sum(bytes_on_disk) / sum(rows)) as bytes_per_row
FROM system.parts
WHERE table = 'compression_test' AND active
GROUP BY table;
测试结果:
| 压缩算法 | 磁盘占用 | 压缩比 | 写入速度 | 查询速度 |
|---|---|---|---|---|
| NONE | 12.5 GB | 1:1 | 100% | 100% |
| LZ4 | 3.8 GB | 3.3:1 | 95% | 98% |
| ZSTD(1) | 3.5 GB | 3.6:1 | 90% | 95% |
| ZSTD(3) | 2.8 GB | 4.5:1 | 85% | 92% |
| ZSTD(7) | 2.2 GB | 5.7:1 | 70% | 88% |
| ZSTD(12) | 1.9 GB | 6.6:1 | 50% | 80% |
4.3 列类型与压缩关系
数据类型选择对压缩比的影响:
| 数据类型 | 存储大小 | 压缩友好度 | 推荐场景 |
|---|---|---|---|
| UInt8/Int8 | 1 字节 | ⭐⭐⭐⭐⭐ | 状态码、枚举 |
| UInt16/Int16 | 2 字节 | ⭐⭐⭐⭐ | 端口、小 ID |
| UInt32/Int32 | 4 字节 | ⭐⭐⭐⭐ | 用户 ID、计数器 |
| UInt64/Int64 | 8 字节 | ⭐⭐⭐ | 大 ID、时间戳 |
| Float32 | 4 字节 | ⭐⭐⭐ | 精度要求低的数值 |
| Float64 | 8 字节 | ⭐⭐ | 高精度数值 |
| String | 变长 | ⭐⭐⭐⭐ | 通用文本 |
| LowCardinality(String) | 字典 + 索引 | ⭐⭐⭐⭐⭐ | 低基数字符串 |
| DateTime | 4 字节 | ⭐⭐⭐⭐⭐ | 时间戳 |
| Date | 2 字节 | ⭐⭐⭐⭐⭐ | 日期 |
优化建议:
-- ❌ 低效:使用 UInt64 存储状态
CREATE TABLE bad_schema (
user_id UInt64,
status UInt64 -- 只有 0-9 的值
) ENGINE = MergeTree() ORDER BY user_id;
-- ✅ 高效:使用 UInt8 或 Enum
CREATE TABLE good_schema (
user_id UInt64,
status Enum8('created'=0, 'pending'=1, 'active'=2)
) ENGINE = MergeTree() ORDER BY user_id;
-- ✅ 高效:使用 LowCardinality
CREATE TABLE optimized_schema (
user_id UInt64,
event_type LowCardinality(String) -- 事件类型少于 1000 种
) ENGINE = MergeTree() ORDER BY user_id;
4.4 分区压缩策略
冷热数据分离:
-- 热数据(最近 30 天):LZ4 高性能
CREATE TABLE events_hot (
event_time DateTime,
user_id UInt64,
data String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, user_id)
SETTINGS
compression_codec = 'LZ4',
storage_policy = 'hot_ssd';
-- 温数据(30-180 天):ZSTD(3) 平衡
CREATE TABLE events_warm AS events_hot
SETTINGS
compression_codec = 'ZSTD(3)',
storage_policy = 'warm_hdd';
-- 冷数据(180 天以上):ZSTD(12) 高压缩
CREATE TABLE events_cold AS events_hot
SETTINGS
compression_codec = 'ZSTD(12)',
storage_policy = 'cold_archive';
源码位置(ClickHouse 23.8.5.26):
// src/Compression/CompressionCodec.cpp
class ICompressionCodec {
public:
// 压缩方法
virtual UInt8 getMethod() const = 0;
// 压缩数据
virtual void compress(
const char * source,
size_t source_size,
char * dest
) const = 0;
// 解压数据
virtual void decompress(
const char * source,
size_t source_size,
char * dest
) const = 0;
};
5. 索引体系与查询优化
ClickHouse 的索引系统是其高性能查询的关键,理解索引机制对于优化查询至关重要。
5.1 索引类型对比
| 索引类型 | 存储位置 | 查询加速 | 写入开销 | 适用场景 |
|---|---|---|---|---|
| PRIMARY KEY | 内存 | 点查询、范围查询 | 低 | 主键查询 |
| 二级索引 | 内存/磁盘 | 特定列过滤 | 中等 | 非主键列查询 |
| Bloom Filter | 内存 | 存在性判断 | 低 | in 查询、join |
| Skip Index | 磁盘 | 列级过滤 | 中等 | 列裁剪 |
5.2 主键索引优化
主键设计原则:
- 查询选择性高:主键列应该能过滤大部分数据
- 基数适中:避免过高基数(如 UUID)或过低基数(如性别)
- 长度适中:主键列数 2-4 个最佳
- 查询模式匹配:与常见查询 WHERE 子句匹配
示例:
-- ❌ 错误:主键选择差(status 只有 3 个值)
CREATE TABLE bad_pk (
event_time DateTime,
user_id UInt64,
status UInt8
) ENGINE = MergeTree()
ORDER BY status;
-- ✅ 正确:主键选择好(user_id 基数高)
CREATE TABLE good_pk (
event_time DateTime,
user_id UInt64,
status UInt8
) ENGINE = MergeTree()
ORDER BY (user_id, event_time);
主键索引查询性能对比:
-- 测试表:1 亿条记录
CREATE TABLE index_test (
user_id UInt64,
event_time DateTime,
event_type String,
status UInt8
) ENGINE = MergeTree()
ORDER BY (user_id, event_time);
-- 测试 1:主键前缀查询(极快)
SELECT count() FROM index_test WHERE user_id = 12345;
-- 耗时:0.05 秒
-- 测试 2:主键非前缀查询(较慢)
SELECT count() FROM index_test WHERE event_time = '2024-03-15 10:00:00';
-- 耗时:2.5 秒
-- 测试 3:主键范围查询(快)
SELECT count() FROM index_test
WHERE user_id BETWEEN 10000 AND 20000;
-- 耗时:0.8 秒
5.3 二级索引
语法:
CREATE TABLE secondary_index_test (
date Date,
user_id UInt32,
product_id UInt32,
amount UInt64,
INDEX idx_user_id user_id TYPE bloom_filter GRANULARITY 4,
INDEX idx_product_id product_id TYPE minmax GRANULARITY 4
) ENGINE = MergeTree()
ORDER BY date;
索引类型:
| 索引类型 | 语法 | 用途 | 示例 |
|---|---|---|---|
| minmax | TYPE minmax | 列最小/最大值 | 数值范围查询 |
| set | TYPE set(max_rows) | 不同值集合 | 低基数列过滤 |
| bloom_filter | TYPE bloom_filter | 存在性判断 | in 查询、join |
| ngrambf_v1 | TYPE ngrambf_v1 | 全文搜索 | 字符串模糊匹配 |
性能对比:
-- 创建测试表
CREATE TABLE with_bloom (
date Date,
user_id UInt32,
event_type String,
INDEX idx_user user_id TYPE bloom_filter(0.01) GRANULARITY 1
) ENGINE = MergeTree() ORDER BY date;
CREATE TABLE without_bloom AS with_bloom
REMOVE INDEX idx_user;
-- 插入数据
INSERT INTO with_bloom SELECT
today() - (rand() % 365),
rand() % 1000000,
['login', 'logout', 'purchase'][rand() % 3 + 1]
FROM numbers(10000000);
-- 性能测试
SELECT count() FROM with_bloom WHERE user_id IN (123, 456, 789);
-- 耗时:0.12 秒(使用 Bloom Filter)
SELECT count() FROM without_bloom WHERE user_id IN (123, 456, 789);
-- 耗时:0.85 秒(全表扫描)
5.4 查询优化技巧
1. PREWHERE 优化:
-- ❌ 低效:先读取所有列再过滤
SELECT user_id, event_type, status
FROM events
WHERE status = 1;
-- ✅ 高效:先过滤再读取需要的列
SELECT user_id, event_type, status
FROM events
PREWHERE status = 1;
2. 列裁剪:
-- ❌ 低效:读取不需要的列
SELECT user_id, event_type FROM events;
-- ✅ 高效:只读取需要的列
SELECT user_id, event_type FROM events;
-- ClickHouse 自动只读取 user_id 和 event_type 列
3. 分区裁剪:
-- ❌ 低效:扫描所有分区
SELECT count() FROM events WHERE event_time >= '2024-03-01';
-- ✅ 高效:分区裁剪(只扫描 2024-03 分区)
SELECT count() FROM events
WHERE event_time >= '2024-03-01'
AND toYYYYMM(event_time) = 202403;
4. 物化视图加速:
-- 原始表
CREATE TABLE events_raw (
event_time DateTime,
user_id UInt64,
event_type String,
status UInt8
) ENGINE = MergeTree()
ORDER BY (event_time, user_id);
-- 物化视图:预聚合
CREATE MATERIALIZED VIEW events_hourly_mv
ENGINE = SummingMergeTree()
ORDER BY (toStartOfHour(event_time), event_type)
AS SELECT
toStartOfHour(event_time) as hour,
event_type,
count() as event_count
FROM events_raw
GROUP BY hour, event_type;
-- 查询物化视图(100 倍提速)
SELECT hour, event_type, sum(event_count)
FROM events_hourly_mv
WHERE hour >= '2024-03-15 00:00:00'
AND hour < '2024-03-16 00:00:00'
GROUP BY hour, event_type;
流程图:查询优化决策树
6. 亿级数据查询优化实战
本节通过真实案例展示如何优化亿级数据查询性能。
6.1 场景背景
业务场景: 电商平台用户行为分析
- 数据量:10 亿条/天
- 查询需求:实时分析用户行为、漏斗分析、留存分析
- 性能要求:95% 查询在 3 秒内返回
原始表结构:
CREATE TABLE user_events_raw (
event_time DateTime,
user_id UInt64,
session_id String,
event_type String,
page_url String,
referrer String,
device_type String,
os_name String,
browser String,
is_mobile UInt8,
duration UInt32,
metadata String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, user_id);
6.2 优化步骤
Step 1:Schema 优化
-- 优化后的表结构
CREATE TABLE user_events_optimized (
event_time DateTime,
user_id UInt64,
session_id UInt64, -- 改为 UInt64,节省空间
event_type LowCardinality(String), -- 使用 LowCardinality
page_url String,
referrer String,
device_type Enum8('desktop'=0, 'mobile'=1, 'tablet'=2), -- 使用 Enum
os_name LowCardinality(String),
browser LowCardinality(String),
is_mobile UInt8,
duration UInt32,
metadata String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (toStartOfMinute(event_time), user_id, event_type) -- 优化排序键
SETTINGS
index_granularity = 8192,
compression_codec = 'ZSTD(3)';
优化效果:
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 存储空间 | 2.5 TB/天 | 850 GB/天 | 66% ↓ |
| 导入速度 | 100k rows/s | 180k rows/s | 80% ↑ |
| 点查询 | 2.5s | 0.3s | 88% ↑ |
Step 2:创建物化视图
-- 按小时聚合的物化视图
CREATE MATERIALIZED VIEW user_events_hourly_mv
ENGINE = SummingMergeTree()
ORDER BY (toStartOfHour(event_time), event_type)
AS SELECT
toStartOfHour(event_time) as hour,
event_type,
device_type,
count() as event_count,
sum(duration) as total_duration,
uniq(user_id) as unique_users
FROM user_events_optimized
GROUP BY hour, event_type, device_type;
-- 按天聚合的物化视图
CREATE MATERIALIZED VIEW user_events_daily_mv
ENGINE = SummingMergeTree()
ORDER BY (toDate(event_time), event_type)
AS SELECT
toDate(event_time) as date,
event_type,
count() as event_count,
uniq(user_id) as unique_users
FROM user_events_optimized
GROUP BY date, event_type;
-- 用户会话摘要(CollapsingMergeTree)
CREATE MATERIALIZED VIEW user_sessions_mv
ENGINE = CollapsingMergeTree(sign)
ORDER BY (user_id, session_start)
AS SELECT
user_id,
min(event_time) as session_start,
max(event_time) as session_end,
sum(duration) as total_duration,
count() as page_views,
1 as sign
FROM user_events_optimized
WHERE event_type = 'pageview'
GROUP BY user_id;
Step 3:创建投影(Projections)
-- ClickHouse 22.3+ 支持投影
ALTER TABLE user_events_optimized
ADD PROJECTION pv_by_user (
SELECT
toStartOfMinute(event_time) as minute,
user_id,
count() as pv_count
GROUP BY minute, user_id
);
-- 查询时自动使用投影
SELECT minute, user_id, pv_count
FROM user_events_optimized
WHERE event_time >= now() - INTERVAL 1 HOUR
GROUP BY minute, user_id;
6.3 查询优化案例
案例 1:漏斗分析
原始查询(慢):
-- 耗时:15 秒
SELECT
countIf(event_type = 'pageview') as step1,
countIf(event_type = 'add_to_cart') as step2,
countIf(event_type = 'purchase') as step3,
step2 / step1 as conversion_rate_1,
step3 / step2 as conversion_rate_2
FROM user_events_optimized
WHERE event_time >= '2024-03-15'
AND event_time < '2024-03-16';
优化后(快):
-- 使用物化视图:耗时 0.8 秒
SELECT
sumIf(event_count, event_type = 'pageview') as step1,
sumIf(event_count, event_type = 'add_to_cart') as step2,
sumIf(event_count, event_type = 'purchase') as step3,
step2 / step1 as conversion_rate_1,
step3 / step2 as conversion_rate_2
FROM user_events_hourly_mv
WHERE hour >= toStartOfHour('2024-03-15 00:00:00')
AND hour < toStartOfHour('2024-03-16 00:00:00');
案例 2:留存分析
-- 7 日留存查询(优化后:1.2 秒)
WITH
first_day AS (
SELECT
user_id,
toDate(min(event_time)) as first_date
FROM user_events_optimized
WHERE event_time >= '2024-03-01'
AND event_time < '2024-03-08'
GROUP BY user_id
),
user_activity AS (
SELECT
user_id,
toDate(event_time) as activity_date
FROM user_events_optimized
WHERE event_time >= '2024-03-01'
AND event_time < '2024-03-15'
GROUP BY user_id, activity_date
)
SELECT
first_date,
count() as cohort_size,
countIf(activity_date = first_date + INTERVAL 1 DAY) * 100.0 / count() as day1_retention,
countIf(activity_date = first_date + INTERVAL 7 DAY) * 100.0 / count() as day7_retention
FROM first_day
LEFT JOIN user_activity USING user_id
GROUP BY first_date
ORDER BY first_date;
案例 3:Top N 查询优化
-- 原始查询:耗时 8 秒
SELECT
user_id,
count() as event_count,
sum(duration) as total_duration
FROM user_events_optimized
WHERE event_time >= now() - INTERVAL 24 HOUR
GROUP BY user_id
ORDER BY event_count DESC
LIMIT 100;
-- 优化方案 1:增加 LIMIT 子句推送到存储层
SELECT
user_id,
count() as event_count,
sum(duration) as total_duration
FROM user_events_optimized
WHERE event_time >= now() - INTERVAL 24 HOUR
GROUP BY user_id
ORDER BY event_count DESC
LIMIT 100
SETTINGS max_rows_to_read = 1000000; -- 限制扫描行数
-- 优化方案 2:使用物化视图(耗时 0.3 秒)
SELECT user_id, sum(event_count) as event_count, sum(total_duration)
FROM user_events_hourly_mv
WHERE hour >= toStartOfHour(now() - INTERVAL 24 HOUR)
GROUP BY user_id
ORDER BY event_count DESC
LIMIT 100;
6.4 性能对比总结
| 查询类型 | 优化前 | 优化后 | 提升倍数 |
|---|---|---|---|
| 漏斗分析 | 15s | 0.8s | 18.75x |
| 留存分析 | 25s | 1.2s | 20.83x |
| Top N 查询 | 8s | 0.3s | 26.67x |
| 时间序列聚合 | 12s | 0.5s | 24x |
7. 生产环境最佳实践
7.1 表设计清单
✅ 推荐做法:
-- 1. 使用合理的分区键
CREATE TABLE good_partitioning (
event_time DateTime,
user_id UInt64
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time) -- 按月分区
ORDER BY (event_time, user_id);
-- 2. 使用 LowCardinality 优化字符串列
CREATE TABLE optimized_strings (
event_type LowCardinality(String), -- 低基数
user_agent String -- 高基数
) ENGINE = MergeTree() ORDER BY event_type;
-- 3. 使用 Enum 替代状态字符串
CREATE TABLE using_enum (
status Enum8('pending'=0, 'active'=1, 'deleted'=2)
) ENGINE = MergeTree() ORDER BY status;
-- 4. 合理设置压缩算法
CREATE TABLE compressed_table (
data String
) ENGINE = MergeTree()
ORDER BY data
SETTINGS compression_codec = 'ZSTD(3)';
-- 5. 使用 TTL 自动清理过期数据
CREATE TABLE with_ttl (
event_time DateTime,
data String
) ENGINE = MergeTree()
ORDER BY event_time
TTL event_time + INTERVAL 90 DAY DELETE; -- 90 天后自动删除
❌ 避免的做法:
-- 1. 避免过细的分区
CREATE TABLE too_many_partitions (
event_time DateTime
) ENGINE = MergeTree()
PARTITION BY toDate(event_time); -- 每天一个分区,导致分区过多
-- 2. 避免使用 UUID 作为主键
CREATE TABLE uuid_pk (
user_id UUID, -- UUID 基数过高,不适合作为主键
event_time DateTime
) ENGINE = MergeTree()
ORDER BY user_id;
-- 3. 避免单列过宽
CREATE TABLE too_wide (
metadata String -- 单个字符串过大,影响性能
) ENGINE = MergeTree()
ORDER BY metadata;
-- 4. 避免过度使用 FINAL
SELECT * FROM large_table FINAL; -- 性能极差
-- 5. 避免使用 SELECT *
SELECT * FROM events; -- 读取所有列,浪费 I/O
7.2 查询优化清单
| 优化项 | 检查方法 | 优化建议 |
|---|---|---|
| 分区裁剪 | EXPLAIN 查看分区扫描数 | 确保查询带分区条件 |
| 索引使用 | system.data_skipping_indices | 为常用过滤列创建二级索引 |
| 列裁剪 | 只选择需要的列 | 避免 SELECT * |
| 物化视图 | 复杂聚合查询 | 创建预聚合物化视图 |
| PREWHERE | 大宽表查询 | 先过滤再读取列 |
| 并发控制 | max_concurrent_queries | 限制并发查询数 |
7.3 监控指标
关键监控指标:
-- 1. 表大小监控
SELECT
table,
formatReadableSize(sum(bytes_on_disk)) as size,
sum(rows) as total_rows,
formatReadableSize(sum(bytes_on_disk) / sum(rows)) as avg_row_size
FROM system.parts
WHERE active AND table = 'user_events_optimized'
GROUP BY table;
-- 2. 分区大小监控
SELECT
partition,
formatReadableSize(sum(bytes_on_disk)) as size,
sum(rows) as rows
FROM system.parts
WHERE active AND table = 'user_events_optimized'
GROUP BY partition
ORDER BY partition DESC
LIMIT 10;
-- 3. 查询性能监控
SELECT
query,
type,
formatReadableSize(read_rows) as rows_read,
formatReadableSize(read_bytes) as bytes_read,
formatReadableTime(query_duration_ms) as duration
FROM system.query_log
WHERE type = 'QueryFinish'
AND query_duration_ms > 1000
ORDER BY query_duration_ms DESC
LIMIT 10;
-- 4. 合并任务监控
SELECT
table,
count() as merge_count,
sum(bytes_read_uncompressed) as bytes_read,
sum(rows_read) as rows_read
FROM system.part_log
WHERE event_type = 'MergeParts'
AND event_time > now() - INTERVAL 1 HOUR
GROUP BY table;
-- 5. 压缩率监控
SELECT
table,
formatReadableSize(sum(data_uncompressed_bytes)) as uncompressed,
formatReadableSize(sum(data_compressed_bytes)) as compressed,
sum(data_compressed_bytes) / sum(data_uncompressed_bytes) as ratio
FROM system.columns
WHERE database = 'default'
GROUP BY table
ORDER BY ratio ASC;
7.4 生产环境配置建议
config.xml 关键配置:
<!-- 内存限制 -->
<max_memory_usage>10000000000</max_memory_usage> <!-- 10GB -->
<!-- 并发查询限制 -->
<max_concurrent_queries>100</max_concurrent_queries>
<!-- 合并任务配置 -->
<background_pool_size>16</background_pool_size>
<max_bytes_to_merge_at_max_space_space>1000000000000</max_bytes_to_merge_at_max_space_space>
<!-- 缓存配置 -->
<mark_cache_size>53687091200</mark_cache_size> <!-- 50GB -->
<uncompressed_cache_size>0</uncompressed_cache_size> <!-- 禁用(SSD 不需要) -->
<!-- 压缩配置 -->
<compression>
<case>
<min_part_size>10000000000</min_part_size> <!-- 10GB -->
<min_part_size_ratio>0.01</min_part_size_ratio>
<method>zstd</method>
<level>3</level>
</case>
</compression>
users.xml 用户配置:
<!-- 针对分析用户 -->
<profiles>
<analytics>
<max_memory_usage>20000000000</max_memory_usage> <!-- 20GB -->
<max_execution_time>300</max_execution_time> <!-- 5 分钟 -->
<max_rows_to_read>10000000000</max_rows_to_read>
<max_bytes_to_read>1000000000000</max_bytes_to_read>
<join_max_blocks>1000</join_max_blocks>
<max_threads>8</max_threads>
</analytics>
</profiles>
7.5 数据导入最佳实践
批量导入优化:
-- 1. 使用 INSERT SELECT 而非逐行 INSERT
INSERT INTO target_table SELECT * FROM source_table;
-- 2. 禁用索引和约束(导入时)
SET allow_deprecated_syntax_for_merge_tree=1;
-- 3. 使用异步插入
SET async_insert=1, wait_for_async_insert=0;
-- 4. 批量大小建议
-- 每批 10万-100万行
-- 每批数据 1GB-10GB
-- 5. 分区并行导入
-- 不同分区可以并行导入
INSERT INTO events PARTITION 202403 SELECT * FROM staging WHERE month = '202403';
INSERT INTO events PARTITION 202404 SELECT * FROM staging WHERE month = '202404';
数据导入流程图:
📚 总结
本文深入剖析了 ClickHouse 列存引擎的核心机制,从架构设计到生产实践,涵盖了:
核心要点回顾
- 列式存储:I/O 减少 + 高压缩比 + 向量化执行 = OLAP 性能基石
- MergeTree 系列:不同变体针对不同场景,理解其合并机制是关键
- 数据压缩:ZSTD(3) 是性能与压缩比的最佳平衡点
- 索引优化:主键设计、二级索引、稀疏索引协同工作
- 查询优化:分区裁剪 + 列裁剪 + 物化视图 + PREWHERE
- 生产实践:Schema 设计、监控指标、配置调优
性能优化黄金法则
| 层面 | 优化手段 | 提升效果 |
|---|---|---|
| Schema 设计 | 合理分区、LowCardinality、Enum | 50% 存储节省 |
| 压缩配置 | ZSTD(3) 热数据、ZSTD(12) 冷数据 | 60% 压缩率提升 |
| 索引优化 | 主键设计、二级索引 | 80% 查询加速 |
| 物化视图 | 预聚合常用查询 | 95% 性能提升 |
| 查询优化 | PREWHERE、列裁剪、LIMIT | 70% I/O 减少 |
进阶学习路径
- 源码阅读:
src/Storages/MergeTree/系列 - 性能调优:
system.query_log、system.part_log分析 - 高可用架构:副本、分片集群设计
- 实时分析:Kafka 集成、流式处理
ClickHouse 的强大不仅在于其列式存储架构,更在于 MergeTree 系列引擎的灵活设计和极致优化。掌握这些核心机制,你就能充分发挥 ClickHouse 的性能潜力,应对亿级甚至十亿级数据的实时分析挑战。
📖 参考资料
更多推荐



所有评论(0)