ClickHouse 列存引擎:MergeTree 与数据压缩深度解析


📖 目录

  1. 列式存储架构设计
  2. MergeTree 表引擎原理
  3. MergeTree 变体引擎深度解析
  4. 数据压缩算法与性能对比
  5. 索引体系与查询优化
  6. 亿级数据查询优化实战
  7. 生产环境最佳实践

1. 列式存储架构设计

1.1 为什么选择列式存储?

ClickHouse 作为 OLAP(Online Analytical Processing)数据库,核心设计目标是处理海量数据的快速分析查询。列式存储是其性能基石。

列式存储 vs 行式存储对比:

特性列式存储行式存储
适用场景分析查询、聚合统计事务处理、点查询
I/O 开销仅读取需要的列必须读取整行
压缩比极高(同类数据聚集)较低(混合数据类型)
查询性能聚合查询快 10-100 倍单行查询快
写入性能批量写入优秀单行插入优秀
典型产品ClickHouse, Vertica, KuduMySQL, PostgreSQL, Oracle

核心优势:

  1. I/O 减少:分析查询通常只需少数列(如 SELECT COUNT(status) FROM events),列式存储只需读取 status 列
  2. 压缩率高:同列数据类型相同、取值范围集中,压缩比可达 10:1 甚至 100:1
  3. 向量化执行: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 的向量化执行是其性能的关键:

流程图:向量化执行流程

SQL 查询

解析器

查询计划生成

向量化执行引擎

列数据读取

SIMD 批量处理

聚合/过滤

结果集

源码位置(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 数据写入流程

Background Disk Memory MergeTree Client Background Disk Memory MergeTree Client 达到阈值后 触发 flush 合并 Part-1 + Part-2 生成 Part-3 INSERT 数据 写入内存缓冲区 生成新的 Data Part Part-1 创建完成 INSERT 更多数据 写入缓冲区 生成 Part-2 Part-2 创建完成 后台合并任务 合并完成

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 BYPRIMARY 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)
-- 每行记录:
-- - 对应数据块的偏移量
-- - 该数据块第一行的排序键值

流程图:稀疏索引查询流程

查询: WHERE user_id = 123

加载主键索引到内存

二分查找定位相关标记

读取标记对应的数据块

在数据块中扫描过滤

返回结果集

源码位置(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_insert300数据块数量超过此值,INSERT 抛异常
parts_to_delay_insert150数据块数量超过此值,INSERT 延迟
max_bytes_to_merge_at_once150GB单次合并的最大字节数
max_bytes_to_merge_at_max_space_space1TB磁盘空间充足时的最大合并量

合并策略:

-- 手动控制合并行为
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;  -- 降低单次合并量

流程图:数据合并决策树

后台合并线程

数据块数量 > 1?

等待下次检查

有合适合并候选?

磁盘空间充足?

跳过,下次尝试

执行合并

合并成功?

删除旧数据块

记录错误日志


3. MergeTree 变体引擎深度解析

MergeTree 系列提供了多个变体引擎,针对不同场景优化。

3.1 MergeTree 变体对比表

引擎名称核心特性适用场景去重支持聚合支持
MergeTree基础引擎,无特殊逻辑通用场景
ReplacingMergeTree保留最后/第一版本需要去重的数据
SummingMergeTree数值列自动求和预聚合、物化视图
CollapsingMergeTree折叠正负记录增量更新、去重
VersionedCollapsingMergeTree版本化折叠有序的增量更新
AggregatingMergeTree任意聚合函数高级物化视图
GraphiteMergeTreeGraphite 专用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

⚠️ 重要约束:

  1. 必须按 ORDER BY 键插入:同一键的记录必须按顺序插入
  2. sign 列值必须是 1 或 -1
  3. 不能保证实时折叠:需要等待合并或使用 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;

流程图:聚合函数状态转换

-State 函数

存储

Merge 函数

-Merge 函数

原始数据

中间状态

AggregatingMergeTree

合并状态

最终结果


4. 数据压缩算法与性能对比

ClickHouse 的列式存储天然支持高压缩比,是其性能优势的重要组成部分。

4.1 压缩算法对比

压缩算法压缩比压缩速度解压速度适用场景
NONE1:1最快最快临时表、测试环境
LZ43: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;

测试结果:

压缩算法磁盘占用压缩比写入速度查询速度
NONE12.5 GB1:1100%100%
LZ43.8 GB3.3:195%98%
ZSTD(1)3.5 GB3.6:190%95%
ZSTD(3)2.8 GB4.5:185%92%
ZSTD(7)2.2 GB5.7:170%88%
ZSTD(12)1.9 GB6.6:150%80%

4.3 列类型与压缩关系

数据类型选择对压缩比的影响:

数据类型存储大小压缩友好度推荐场景
UInt8/Int81 字节⭐⭐⭐⭐⭐状态码、枚举
UInt16/Int162 字节⭐⭐⭐⭐端口、小 ID
UInt32/Int324 字节⭐⭐⭐⭐用户 ID、计数器
UInt64/Int648 字节⭐⭐⭐大 ID、时间戳
Float324 字节⭐⭐⭐精度要求低的数值
Float648 字节⭐⭐高精度数值
String变长⭐⭐⭐⭐通用文本
LowCardinality(String)字典 + 索引⭐⭐⭐⭐⭐低基数字符串
DateTime4 字节⭐⭐⭐⭐⭐时间戳
Date2 字节⭐⭐⭐⭐⭐日期

优化建议:

-- ❌ 低效:使用 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 主键索引优化

主键设计原则:

  1. 查询选择性高:主键列应该能过滤大部分数据
  2. 基数适中:避免过高基数(如 UUID)或过低基数(如性别)
  3. 长度适中:主键列数 2-4 个最佳
  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;

索引类型:

索引类型语法用途示例
minmaxTYPE minmax列最小/最大值数值范围查询
setTYPE set(max_rows)不同值集合低基数列过滤
bloom_filterTYPE bloom_filter存在性判断in 查询、join
ngrambf_v1TYPE 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;

流程图:查询优化决策树

收到 SQL 查询

能使用分区裁剪?

应用分区裁剪

全表扫描

能使用主键索引?

索引扫描

分区全扫描

能用 PREWHERE?

先过滤再读取

读取所有列

能使用物化视图?

查询物化视图

查询原始表


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/s180k rows/s80% ↑
点查询2.5s0.3s88% ↑

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 性能对比总结

查询类型优化前优化后提升倍数
漏斗分析15s0.8s18.75x
留存分析25s1.2s20.83x
Top N 查询8s0.3s26.67x
时间序列聚合12s0.5s24x

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';

数据导入流程图:

成功

失败

数据源

预处理
清洗、转换

临时表
staging

分区导入
并行

目标表
events

数据验证

完成

回滚重试


📚 总结

本文深入剖析了 ClickHouse 列存引擎的核心机制,从架构设计到生产实践,涵盖了:

核心要点回顾

  1. 列式存储:I/O 减少 + 高压缩比 + 向量化执行 = OLAP 性能基石
  2. MergeTree 系列:不同变体针对不同场景,理解其合并机制是关键
  3. 数据压缩:ZSTD(3) 是性能与压缩比的最佳平衡点
  4. 索引优化:主键设计、二级索引、稀疏索引协同工作
  5. 查询优化:分区裁剪 + 列裁剪 + 物化视图 + PREWHERE
  6. 生产实践:Schema 设计、监控指标、配置调优

性能优化黄金法则

层面优化手段提升效果
Schema 设计合理分区、LowCardinality、Enum50% 存储节省
压缩配置ZSTD(3) 热数据、ZSTD(12) 冷数据60% 压缩率提升
索引优化主键设计、二级索引80% 查询加速
物化视图预聚合常用查询95% 性能提升
查询优化PREWHERE、列裁剪、LIMIT70% I/O 减少

进阶学习路径

  1. 源码阅读src/Storages/MergeTree/ 系列
  2. 性能调优system.query_logsystem.part_log 分析
  3. 高可用架构:副本、分片集群设计
  4. 实时分析:Kafka 集成、流式处理

ClickHouse 的强大不仅在于其列式存储架构,更在于 MergeTree 系列引擎的灵活设计和极致优化。掌握这些核心机制,你就能充分发挥 ClickHouse 的性能潜力,应对亿级甚至十亿级数据的实时分析挑战。


📖 参考资料

  1. ClickHouse 官方文档
  2. MergeTree 系列引擎详解
  3. ClickHouse 源码(GitHub)
  4. ClickHouse 性能优化最佳实践

更多推荐