数据库基础体系 · 第 46/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

ClickHouse 列式建模:MergeTree、排序键、分区和数据跳过

在 ClickHouse 中,表结构设计很大程度上不是“选择几个字段并建立索引”,而是设计以下几件事:

  1. 数据以什么物理结构落盘;
  2. 每个数据片段内部如何排序;
  3. 查询可以先排除哪些分区;
  4. 在仍需访问的分区中,还可以跳过哪些数据块;
  5. 写入、合并和后台维护如何改变这些物理结构。

MergeTree、排序键、分区和数据跳过索引分别处于不同层次。把它们混为一谈,是 ClickHouse 建模中最常见的错误之一。


一、先建立一个物理模型:表不是一份持续追加的行文件

ClickHouse 是列式 OLAP 数据库。对一张 MergeTree 家族表执行一次 INSERT 时,数据通常不会直接追加到一个全局文件中,而是经历类似下面的过程:

INSERT 数据块
    ↓
按排序键排序
    ↓
写出一个或多个数据 part
    ↓
后台合并相邻或相关 part
    ↓
形成更大的有序 part

1. Part 是 MergeTree 的基本物理单元

一个 part 是表中一批数据的物理存储单元。它通常包含:

  • 各列的数据文件;
  • 按排序键生成的稀疏主索引;
  • 分区信息;
  • 可能存在的数据跳过索引;
  • 行数、时间范围等元数据。

新写入的数据首先形成新的 part。后台线程随后选择若干 part 进行 merge,生成一个更大的 part,并在成功后替换旧 part。

因此,MergeTree 表在运行过程中通常不是“一个文件”,而是:

多个 active parts
    ↓ 后台合并
较少但更大的 active parts

旧 part 不会在新 part 成功生成前立即消失。合并失败时,原 part 仍然可以继续提供查询服务。

2. Merge 不等于重新生成一张逻辑表

普通 MergeTree 的 merge 主要做物理整理和排序合并,不会自动:

  • 去重相同主键的行;
  • 执行 GROUP BY
  • 让不同批次的重复数据只保留一份;
  • 把多个表的写入变成一个事务。

如果需要在合并时改变行语义,应使用相应的引擎,例如 ReplacingMergeTreeSummingMergeTreeAggregatingMergeTree。这些引擎又有各自的查询语义和 FINAL 成本,不能把它们的行为泛化到普通 MergeTree


二、MergeTree 的核心特征

1. MergeTree 适合什么数据

MergeTree 家族主要面向:

  • 日志;
  • 事件流;
  • 指标;
  • 明细事实表;
  • 时间序列;
  • 批量导入的分析数据。

它的典型特点是:

  • 写入以追加和批量写入为主;
  • 查询扫描大量行,但只读取需要的列;
  • 数据写入后很少逐行更新;
  • 通过排序键、分区和数据跳过减少扫描范围;
  • 后台 merge 异步整理数据。

2. 一个可运行的表定义

下面定义一张事件明细表:

CREATE TABLE events
(
    tenant_id   LowCardinality(String),
    event_time  DateTime64(3, 'UTC'),
    event_type  LowCardinality(String),
    user_id     UInt64,
    value       Float64,
    payload     String
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (tenant_id, event_time, user_id)
PRIMARY KEY (tenant_id, event_time);

这个定义包含三种不同含义:

  • ENGINE = MergeTree:使用 MergeTree 的 part、merge 和列式存储机制;
  • PARTITION BY toYYYYMM(event_time):按事件月份划分物理分区;
  • ORDER BY (tenant_id, event_time, user_id):每个 part 内按这个元组的字典序排序;
  • PRIMARY KEY (tenant_id, event_time):主索引使用排序键的前缀,而不是把它当成唯一约束。

这里的 PRIMARY KEY 不是 OLTP 数据库中的唯一主键。向表中插入两行完全相同的数据,MergeTree 不会因为主键重复而拒绝写入。


三、排序键:决定 part 内的数据排列方式

1. 排序键是一个字典序元组

对于:

ORDER BY (tenant_id, event_time, user_id)

ClickHouse 使用字典序比较这些列:

先比较 tenant_id
tenant_id 相同,再比较 event_time
前两者都相同,再比较 user_id

假设有以下数据:

tenant_id event_time user_id
acme 2025-01-01 09:00:00 10
acme 2025-01-01 09:00:00 12
acme 2025-01-02 10:00:00 8
beta 2025-01-01 08:00:00 3

排序后的顺序正是上表顺序。如果原始批次顺序不同,写入 part 时也会按排序键重排。

2. 排序键决定哪些查询具有连续范围

设排序键为:

K=(k1,k2,,kn)K = (k_1, k_2, \dots, k_n)

对于两行 aabb,按字典序比较:

  1. 先比较 a.k1a.k_1b.k1b.k_1
  2. 若不同,结果由 k1k_1 决定;
  3. 若相同,再比较 k2k_2
  4. 依此类推。

因此,数据在排序键空间中形成连续范围。查询条件越接近排序键的左前缀,越容易排除大块数据。

例如排序键为:

(tenant_id, event_time, user_id)

以下条件通常能较好利用排序结构:

WHERE tenant_id = 'acme'
  AND event_time >= '2025-01-01 00:00:00'
  AND event_time <  '2025-02-01 00:00:00'

因为它固定了第一列,并限制了第二列,匹配范围是排序空间中的连续区域。

而下面的条件只限制第三列:

WHERE user_id = 42

这通常不能把排序空间缩减成一个连续区间。user_id = 42 可能出现在很多 tenant_idevent_time 范围中,排序键本身很难帮助 ClickHouse 大幅跳过数据。

这不是说查询一定会慢,而是说它不能主要依靠这个排序键进行范围裁剪。它可能仍然受益于:

  • 分区裁剪;
  • 数据跳过索引;
  • 其他过滤条件;
  • 列式读取只读取少数列;
  • 并行执行;
  • 缓存。

3. 排序键的列顺序不能随意交换

下面两个排序键的含义不同:

ORDER BY (tenant_id, event_time)

和:

ORDER BY (event_time, tenant_id)

前者适合先按租户筛选,再按时间查询;后者适合全局时间范围扫描。

如果业务查询主要是:

WHERE tenant_id = ?
  AND event_time BETWEEN ? AND ?

那么通常希望 tenant_id 出现在排序键前部。

如果业务主要是:

WHERE event_time >= now() - INTERVAL 1 DAY

并且租户过滤并不总是存在,那么把 event_time 放在更前面可能更合适。

这不是一个“字段基数越高越应该放前面”的简单规则。更准确的判断标准是:查询条件能否在排序键左前缀上形成较窄的连续范围

4. ORDER BYPRIMARY KEY 的关系

MergeTree 中:

ORDER BY (tenant_id, event_time, user_id)
PRIMARY KEY (tenant_id, event_time)

表示:

  • 数据仍然按完整的 ORDER BY 元组排序;
  • 稀疏主索引只使用 PRIMARY KEY 指定的列;
  • PRIMARY KEY 必须与排序键兼容,通常使用排序键的前缀;
  • 主键不是唯一性约束。

如果不单独写 PRIMARY KEY,通常会使用 ORDER BY 作为主键表达式。

为什么要让主键短于排序键?因为主索引需要保存排序键的采样值。主键列越多,索引内存和比较成本可能越高,而额外的排序列仍然可以帮助相同主键范围内的数据组织。

但这并不意味着应任意缩短主键。主键必须包含真正有助于范围裁剪的前缀。


四、稀疏主索引:它不是每行一个索引

1. Granule 是读取和索引的基本范围

MergeTree 不为每一行建立 B-Tree 索引。数据会被划分为若干 granule,可以理解为数据读取和主索引定位的基本块。

默认情况下,固定索引粒度常见为约 8192 行,但实际行为还受自适应索引粒度和数据大小设置影响。不要把 8192 当作所有版本、所有表、所有数据上的绝对物理保证。

每个 granule 通常有一个 mark,主索引记录该位置附近的排序键值。查询时 ClickHouse 先使用这些 mark 定位可能满足条件的 granule,再读取其中的列数据。

2. 用一个简化模型推导索引过程

为了便于演示,假设:

  • 每个 granule 恰好有 4 行;
  • 排序键是 (tenant_id, event_time)
  • 一个 part 中共有 12 行。

排序后的数据如下:

行号 tenant_id event_time
1 acme 2025-01-01 09:00
2 acme 2025-01-01 10:00
3 acme 2025-01-02 09:00
4 acme 2025-01-03 11:00
5 acme 2025-01-05 12:00
6 acme 2025-01-06 08:00
7 beta 2025-01-01 09:00
8 beta 2025-01-02 10:00
9 beta 2025-01-03 08:00
10 beta 2025-01-04 09:00
11 beta 2025-01-05 09:00
12 beta 2025-01-06 10:00

划分后:

Granule 1: 行 1~4,起始键约为 (acme, 2025-01-01 09:00)
Granule 2: 行 5~8,起始键约为 (acme, 2025-01-05 12:00)
Granule 3: 行 9~12,起始键约为 (beta, 2025-01-03 08:00)

执行:

SELECT *
FROM events
WHERE tenant_id = 'acme'
  AND event_time >= '2025-01-02 00:00:00'
  AND event_time <  '2025-01-06 00:00:00';

逻辑过程可以简化为:

  1. 根据排序键找到 acme 的连续范围;
  2. 判断这个时间范围与各 granule 的可能范围是否相交;
  3. 排除明显不可能命中的 granule;
  4. 读取剩余 granule 的相关列;
  5. 对读取出的真实行执行最终过滤。

注意,主索引只是稀疏索引。边界附近可能需要多读一个 granule,因为索引记录的是块的定位信息,而不是每一行的精确谓词结果。

3. 为什么排序键不适合任意过滤条件

如果查询改成:

SELECT *
FROM events
WHERE user_id = 42;

而排序键是 (tenant_id, event_time, user_id),那么在不同的租户和时间范围内都可能存在 user_id = 42

从排序键的角度看,条件没有固定左前缀:

tenant_id=?未知tenant\_id = ? \quad \text{未知}

event_time=?未知event\_time = ? \quad \text{未知}

因此不能简单地把所有可能的 user_id = 42 行视为一个连续范围。这就是“表有排序键,但查询仍扫描很多数据”的常见原因。


五、分区:先排除整组 parts

1. 分区键与排序键是两套机制

示例中的:

PARTITION BY toYYYYMM(event_time)
ORDER BY (tenant_id, event_time, user_id)

分别解决不同问题:

  • 分区键负责把数据划分到不同的物理分区;
  • 排序键负责每个 part 内的数据顺序和主索引;
  • 分区内仍然可以有多个 part;
  • 不同分区之间不会进行普通 MergeTree merge。

可以将查询范围想象成两级过滤:

先根据分区键排除不相关分区
    ↓
再在保留分区的各个 part 中使用排序键和跳过索引
    ↓
读取列数据并执行最终过滤

2. 月分区的具体效果

使用:

PARTITION BY toYYYYMM(event_time)

时,以下查询的时间范围覆盖 2025 年 1 月和 2 月:

SELECT count()
FROM events
WHERE event_time >= '2025-01-15 00:00:00'
  AND event_time <  '2025-03-01 00:00:00';

ClickHouse 通常可以推导出需要访问的月份,并排除其他月份分区。每个保留月份内部,仍要继续依赖排序键和其他索引处理具体时间范围。

但是,分区裁剪依赖查询条件能否被推导到分区表达式。下面这种写法会增加优化器推导的复杂度:

WHERE toString(event_time) LIKE '2025-01%'

不能把“某种表达式可能被优化”当成数据模型的唯一保证。对于关键查询,应通过 EXPLAIN 和实际 profile 验证分区是否被裁剪。

3. 分区不是索引

分区通常是更粗粒度的物理组织方式。它有几个重要边界:

  • 一个分区可能包含很多 GB 或 TB 数据;
  • 分区内部仍可能包含很多 parts;
  • 分区键不自动提供高效的点查;
  • 不满足分区条件的查询可能仍扫描大部分分区;
  • 分区不是为了替代排序键。

例如:

PARTITION BY toYYYYMM(event_time)
ORDER BY (tenant_id, event_time)

对于查询:

WHERE event_time >= '2025-01-01'
  AND event_time <  '2025-02-01'
  AND tenant_id = 'acme'

分区先筛掉月份,排序键再缩小 acme 在该月份中的范围。

4. 分区的运维含义

分区也决定了很多运维操作的边界,例如:

  • 按月删除旧数据;
  • 按分区交换或移动数据;
  • 观察不同月份的数据量;
  • 按分区执行生命周期策略。

但分区过多会带来反效果。比如按 user_id 分区:

PARTITION BY user_id

可能产生数量极大的分区和 parts,增加:

  • 元数据数量;
  • merge 调度压力;
  • 文件打开和管理成本;
  • 副本同步复杂度;
  • DDL 和分区级操作成本。

通常应选择数量可控、具有生命周期意义、且能被常见查询利用的分区键。按天、按月还是不分区,必须结合数据量、保留周期、写入批次和删除需求判断,而不是套用固定答案。


六、分区内的数据流:插入、merge 和查询

1. 插入阶段

假设执行:

INSERT INTO events
    (tenant_id, event_time, event_type, user_id, value, payload)
VALUES
    ('acme', '2025-01-01 09:00:00.000', 'login', 10, 1.0, 'a'),
    ('acme', '2025-01-02 09:00:00.000', 'pay',   11, 9.9, 'b'),
    ('beta', '2025-01-01 10:00:00.000', 'login', 20, 1.0, 'c');

大致会发生:

  1. 服务器接收一个插入数据块;
  2. 根据 toYYYYMM(event_time) 计算每行所属分区;
  3. 每个分区的数据按 ORDER BY 排序;
  4. 为对应分区写出新的 part;
  5. part 在写入完成后对查询可见;
  6. 后台线程稍后尝试合并 parts。

一次插入跨越多个分区时,物理上可能产生多个分区中的 part。不要把“一个 SQL INSERT”理解成“全表长期保持一个 part”。

2. 小批次写入为什么会导致 parts 增多

如果应用每次只写几行:

第 1 次 INSERT → part A
第 2 次 INSERT → part B
第 3 次 INSERT → part C
...

即使后台最终会尝试合并,短时间内也会出现大量小 part。结果可能是:

  • 查询需要打开更多 part;
  • merge 线程忙于整理小文件;
  • 写入和 merge 互相竞争 IO;
  • 复制表需要同步更多 part 元数据;
  • 进一步出现 too many parts 相关错误或告警。

因此,ClickHouse 通常更适合批量写入。批量大小应结合行宽、写入频率、分区数量和集群容量压测确定,而不是迷信某个固定行数。

3. Merge 不是实时发生的

写入后立即观察到多个 parts 是正常现象。后台 merge 受以下因素影响:

  • 当前 part 大小;
  • 分区内 part 数量;
  • 后台线程数;
  • 磁盘吞吐;
  • 复制和其他后台任务;
  • merge 选择策略;
  • 表设置和服务器资源。

不要依赖“刚写入的数据已经与历史数据合并”这一假设。查询语义应建立在 active parts 的一致可见性上,而不是建立在 merge 完成时间上。

4. 写入与事务边界

本文示例针对本地或普通 MergeTree 表。需要区分以下概念:

  • 单次 INSERT 的数据可见性;
  • 多条 SQL 语句之间的事务;
  • 多表写入的一致性;
  • 分布式表向各节点发送数据的完成语义;
  • 副本之间的复制延迟。

不能把 MergeTree 当作传统 OLTP 数据库,默认支持跨多条语句、跨多张表的通用事务。需要事务语义时,应根据具体 ClickHouse 版本、数据库引擎、表引擎以及部署方式核实,而不是仅凭 ENGINE = MergeTree 推断。

在集群中还要额外说明:

  • Distributed 表主要是查询路由和分布式写入入口;
  • 本地 MergeTree 表才保存真正的数据 parts;
  • 分片规则决定数据写到哪里;
  • 副本复制解决的是副本间数据同步,不等价于跨分片事务;
  • 网络故障、重试和重复发送可能让写入幂等性成为应用需要处理的问题。

七、数据跳过:在主索引之后继续排除数据块

1. 什么是数据跳过索引

数据跳过索引(data skipping index)是在数据块级别保存额外统计信息的索引。查询时,ClickHouse 根据过滤条件判断某个数据块是否“不可能包含匹配行”,如果确定不可能,就跳过该块。

它不是传统意义上的逐行索引,也不返回匹配行的位置。

其核心逻辑是:

若索引证明数据块不可能满足谓词,则跳过\text{若索引证明数据块不可能满足谓词,则跳过}

否则:

读取数据块,再执行真实过滤\text{读取数据块,再执行真实过滤}

因此,数据跳过索引必须遵守一个重要原则:

只能安全地跳过“不可能命中”的块,不能因为索引不精确而漏掉真实结果。

2. 数据跳过依赖数据的局部聚集性

假设一个数据块中的 event_type 只有:

login, logout

查询:

WHERE event_type = 'purchase'

则可以跳过这个块。

但如果每个数据块都混有大量不同的 event_type,那么几乎每个块的索引都可能包含 purchase,跳过效果就很差。

所以数据跳过索引的效果依赖:

  • 数据是否按相关字段聚集;
  • 单个索引块中值的数量;
  • 查询谓词是否被该索引类型支持;
  • 索引粒度是否合适;
  • 查询是否先被分区和排序键缩小范围。

它不是“给任意字段建索引后点查就会变快”。


八、常见数据跳过索引类型

不同 ClickHouse 版本支持的索引类型和参数可能有所变化。下面介绍稳定语义中常见的类型,具体可用参数应以目标版本 SQL Reference 为准。

1. minmax

minmax 保存块内的最小值和最大值,适合具有数值或时间范围意义的字段。

CREATE TABLE metrics
(
    host       LowCardinality(String),
    event_time DateTime,
    metric     String,
    value      Float64,

    INDEX idx_value value TYPE minmax GRANULARITY 4
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (host, event_time);

对于:

WHERE value > 1000

如果某块的最大值小于等于 1000,则该块不可能命中,可以跳过。

形式化地说,块 BB 的值范围是:

[min(B),max(B)][min(B), max(B)]

若查询条件是 x>cx > c,当:

max(B)cmax(B) \le c

时,块可以被安全排除。

对于查询 x = c,当:

c<min(B)c>max(B)c < min(B) \quad \text{或} \quad c > max(B)

时,也可以跳过。

minmax 对时间、数值、具有局部范围的字段比较有用。若每个块的最小值和最大值都覆盖整个取值域,它就几乎没有裁剪能力。

2. set

set 索引保存块内出现过的不同值,适合一个块中的不同值数量较少的字段。

CREATE TABLE user_events
(
    event_time DateTime,
    event_type LowCardinality(String),
    user_id    UInt64,

    INDEX idx_event_type event_type TYPE set(100) GRANULARITY 2
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, user_id);

如果某个块中只有:

login, logout, purchase

查询:

WHERE event_type = 'refund'

就可以跳过这个块。

set(100) 中的参数表示该索引保存不同值的规模上限。具体细节和边界行为应以版本文档为准。若一个块中的不同值太多,索引可能无法提供有效的集合判断,此时读取仍然是安全的,只是不能很好地跳过。

3. Bloom Filter 类索引

Bloom Filter 适合判断字符串或其他值“可能存在还是一定不存在”,常用于高基数字段的等值查询或部分集合查询。

例如:

CREATE TABLE logs
(
    event_time DateTime,
    service    LowCardinality(String),
    trace_id   String,

    INDEX idx_trace_id trace_id
        TYPE bloom_filter(0.01)
        GRANULARITY 2
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (service, event_time);

Bloom Filter 允许假阳性:

  • 返回“不存在”时,通常可以安全跳过;
  • 返回“可能存在”时,仍必须读取并执行真实过滤;
  • 不应产生导致真实命中被跳过的假阴性。

0.01 表示目标误判率一类的参数。误判率越低,通常需要更多索引空间和计算成本。它不是性能保证,实际收益仍取决于块内值的分布。

ClickHouse 还提供面向 token 或 n-gram 文本匹配场景的相关索引类型,但它们有明确的适用谓词和数据预处理语义,不应把普通 Bloom Filter 当作通用全文搜索引擎。


九、GRANULARITY 到底控制什么

示例:

INDEX idx_event_type event_type TYPE set(100) GRANULARITY 2

这里的 GRANULARITY 通常表示一个跳过索引粒度覆盖多少个基础数据 granule。

如果基础 granule 约为 8192 行:

  • GRANULARITY 1:索引块更细;
  • GRANULARITY 2:一个跳过索引块约覆盖两个基础 granule;
  • 更大的值:索引更小,但跳过范围更粗。

设一个跳过索引块覆盖 gg 个基础 granule,则:

  • gg 较小:裁剪更精细,索引元数据更多;
  • gg 较大:索引元数据更少,但命中一个条件时可能需要读更大的数据范围。

这只是近似直觉,实际读取范围还会受到自适应粒度、part 边界和查询执行策略影响。


十、如何添加和验证数据跳过索引

1. 在建表时声明

CREATE TABLE orders
(
    order_time DateTime,
    tenant_id  UInt64,
    status     LowCardinality(String),
    amount     Decimal(18, 2),

    INDEX idx_status status TYPE set(20) GRANULARITY 1,
    INDEX idx_amount amount TYPE minmax GRANULARITY 1
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(order_time)
ORDER BY (tenant_id, order_time);

新写入的 part 会根据表定义生成这些索引。

2. 给已有表增加索引

可以使用:

ALTER TABLE orders
ADD INDEX idx_status status TYPE set(20) GRANULARITY 1;

但增加定义不等于历史数据已经拥有索引。对已有 parts,通常还需要物化:

ALTER TABLE orders
MATERIALIZE INDEX idx_status;

这是一个后台 mutation 类操作,可能需要重写大量数据,产生明显的 IO 和 CPU 压力。生产环境应:

  1. 先评估涉及的数据量;
  2. 观察 mutation 状态;
  3. 避开业务高峰;
  4. 确认磁盘剩余空间;
  5. 验证新索引确实被查询使用。

可查看 mutation 状态:

SELECT
    database,
    table,
    mutation_id,
    command,
    is_done,
    latest_fail_reason
FROM system.mutations
WHERE table = 'orders'
ORDER BY create_time DESC;

字段和展示细节可能随版本变化,但诊断思路是:确认任务是否完成,以及是否存在失败原因。

3. 使用 EXPLAIN 查看裁剪信息

可以对查询执行:

EXPLAIN indexes = 1
SELECT count()
FROM orders
WHERE order_time >= '2025-01-01 00:00:00'
  AND order_time <  '2025-02-01 00:00:00'
  AND status = 'paid';

输出格式会随版本和设置变化。重点观察:

  • 读取了哪些分区;
  • 读取了哪些 parts;
  • 主键索引排除了多少 granule;
  • 数据跳过索引排除了多少 granule;
  • 最终仍需读取多少数据。

EXPLAIN 显示计划层面的裁剪信息,不能替代实际运行指标。还应结合查询日志、读取行数和读取字节数观察:

SELECT
    query_start_time,
    query_duration_ms,
    read_rows,
    read_bytes,
    result_rows,
    query
FROM system.query_log
WHERE type = 'QueryFinish'
  AND query LIKE '%FROM orders%'
ORDER BY query_start_time DESC
LIMIT 10;

查询日志是否启用、字段名和保留策略取决于部署配置。读取行数明显接近全表行数,通常说明分区、排序键或跳过索引没有有效缩小范围,当然也可能是查询本身确实需要扫描大量数据。


十一、一个完整的裁剪过程

继续使用:

CREATE TABLE events
(
    tenant_id   LowCardinality(String),
    event_time  DateTime64(3, 'UTC'),
    event_type  LowCardinality(String),
    user_id     UInt64,
    value       Float64,

    INDEX idx_event_type event_type TYPE set(20) GRANULARITY 1
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (tenant_id, event_time, user_id)
PRIMARY KEY (tenant_id, event_time);

查询:

SELECT
    event_type,
    count()
FROM events
WHERE tenant_id = 'acme'
  AND event_time >= '2025-01-01 00:00:00'
  AND event_time <  '2025-02-01 00:00:00'
  AND event_type = 'purchase'
GROUP BY event_type;

一个合理的执行推导如下。

第一步:分区裁剪

由:

PARTITION BY toYYYYMM(event_time)

推导需要访问 2025 年 1 月分区,其他月份通常可以排除。

第二步:按排序键定位

排序键前缀是:

tenant_id = 'acme'
event_time 在一月份范围内

这对应排序空间中的一个连续范围。主索引可以排除该范围外的大量 granule。

第三步:使用数据跳过索引

在仍然保留的 granule 中,idx_event_type 检查块内是否可能存在 purchase

  • 如果集合中没有 purchase,跳过;
  • 如果集合中包含 purchase,或索引无法确定不存在,读取该块;
  • 读取后仍执行真实的 event_type = 'purchase' 过滤。

第四步:列式读取

聚合只需要:

  • event_type
  • 可能参与过滤的 tenant_id
  • event_time
  • 其他必要的索引和列数据。

ClickHouse 不需要像行存储那样把每行的 payload 一并读取。若查询不选择 payload,该列通常不会成为主要读取对象。

这四步互相叠加,但不能互相替代:

分区裁剪 ≠ 排序键裁剪
排序键裁剪 ≠ 数据跳过索引
数据跳过索引 ≠ 唯一索引
列式读取 ≠ 少扫描行

十二、失败案例:为什么“建了索引”仍然扫描全表

案例一:排序键顺序与查询不匹配

表定义:

ORDER BY (event_time, tenant_id)

查询:

WHERE tenant_id = 100

由于 tenant_id 不是排序键左前缀,主索引通常无法仅凭这个条件定位一个连续范围。

改成:

ORDER BY (tenant_id, event_time)

可能改善租户加时间范围查询,但会改变全局时间扫描的局部性。正确方案取决于主要查询集合,而不是单个查询。

案例二:把分区键当成高效过滤索引

表定义:

PARTITION BY toYYYYMM(event_time)

查询:

WHERE user_id = 100

如果没有其他有效结构,ClickHouse 仍可能需要访问大量月份分区。分区只知道月份,不知道每个月内部哪些块含有这个用户。

可选方案包括:

  • 调整排序键;
  • 在用户查询确实频繁且数据分布有利时增加跳过索引;
  • 建立面向另一类查询的投影或汇总结构;
  • 改变查询模型,而不是盲目增加索引。

案例三:数据跳过索引建立在随机数据上

如果每个索引块中都包含几乎所有 event_type,那么:

INDEX idx_event_type event_type TYPE set(100)

几乎无法跳过数据。

索引本身没有错误,失败的是建模假设:块内没有形成足够的局部聚集性。

案例四:用 Bloom Filter 解决排序问题

Bloom Filter 适合判断“某个值可能是否在块内”,但它不会改变数据排列顺序,也不会把一个随机字段变成连续范围。

如果查询主要是:

WHERE tenant_id = 100
  AND event_time BETWEEN ...

优先考虑排序键和分区;只有在排序键不适合、且块内值分布确实能让 Bloom Filter 排除大量块时,才考虑 Bloom Filter。

案例五:用 FINAL 掩盖模型问题

在某些 ReplacingMergeTree 查询中,开发者可能通过 FINAL 取得去重后的逻辑结果。但 FINAL 会增加查询处理成本,且不能解决:

  • 分区过多;
  • 排序键不匹配;
  • parts 过多;
  • 查询读取不必要的大列;
  • 数据跳过索引失效。

普通 MergeTree 不需要用 FINAL 来“保证 merge 已完成”。如果业务需要去重,应明确选择相应引擎并设计查询语义。


十三、如何从查询反推排序键

可以把常见查询写成条件集合:

Q1: tenant_id = ? AND event_time 在范围内
Q2: tenant_id = ? AND event_type = ?
Q3: event_time 在最近一天
Q4: user_id = ? AND event_time 在范围内

若排序键为:

(tenant_id, event_time, user_id)

可以得到:

查询 分区裁剪 排序键主索引 可能需要跳过索引
Q1 通常不需要
Q2 取决于时间条件 主要利用 tenant_id event_type 可能需要
Q3 有或部分有 依赖 event_time 是否靠前 可能需要
Q4 取决于时间条件 不能充分利用 user_id user_id 可能需要

这个分析揭示了一个事实:一张表只有一套主要物理排序顺序,无法同时让所有维度都成为第一排序列。

当查询方向明显不止一种时,通常要在以下方案之间取舍:

  • 重新选择主排序键;
  • 增加合适的数据跳过索引;
  • 使用投影;
  • 建立汇总表或物化视图;
  • 针对不同访问模式保存不同的表。

这属于物理模型设计,而不是简单的 SQL 改写。


十四、分区、排序键和跳过索引的职责边界

可以用下面的层次理解它们:

分区

回答:

哪些整组数据可以完全不访问?

适合:

  • 时间生命周期;
  • 分区级删除和管理;
  • 粗粒度裁剪。

排序键和稀疏主索引

回答:

在保留的 part 中,哪些连续 granule 不可能命中?

适合:

  • 高频过滤列;
  • 范围查询;
  • 等值加范围查询;
  • 左前缀条件明显的查询。

数据跳过索引

回答:

对于排序键没有充分组织的字段,哪些局部数据块可以证明不可能命中?

适合:

  • 块内值集合较小;
  • 数值或时间范围具有局部聚集性;
  • 字符串等值查询有明显局部性;
  • 排序键不能覆盖但查询频率较高的过滤条件。

列式存储

回答:

即使需要扫描,是否只读取查询所需的列?

它减少的是读取列的宽度,不一定减少读取行数。一个只选择两列的全表聚合,可能仍然扫描大量行,但读取字节数显著低于读取整行。


十五、诊断顺序:先看是否读错了范围

一个查询慢时,应先确认它到底读取了什么,而不是先增加索引。

1. 查看表中的 parts

SELECT
    database,
    table,
    partition,
    name,
    active,
    rows,
    bytes_on_disk,
    min_time,
    max_time
FROM system.parts
WHERE database = currentDatabase()
  AND table = 'events'
ORDER BY partition, name;

关注:

  • active = 1 的 parts 数量;
  • 是否存在大量很小的 parts;
  • 分区数量是否异常;
  • 单个分区是否远超预期;
  • 数据时间范围是否符合写入模型。

2. 检查查询计划中的索引裁剪

EXPLAIN indexes = 1
SELECT count()
FROM events
WHERE tenant_id = 'acme'
  AND event_time >= '2025-01-01'
  AND event_time < '2025-02-01'
  AND event_type = 'purchase';

如果分区没有被排除,检查:

  • 查询条件是否覆盖分区列;
  • 分区表达式是否可被推导;
  • 是否对时间列做了复杂函数包装;
  • 实际查询是否通过视图或分布式表改变了条件。

如果主索引裁剪很弱,检查:

  • 排序键列顺序;
  • 是否缺少左前缀条件;
  • 查询是否只过滤了排序键后部字段;
  • part 是否过小,导致每个 part 的索引范围很粗。

如果跳过索引没有效果,检查:

  • 索引是否已物化到历史 parts;
  • 查询谓词是否支持该索引类型;
  • 块内值是否过于分散;
  • GRANULARITY 是否过粗;
  • 索引是否因数据类型或表达式不匹配而不能使用。

3. 通过查询日志确认实际读取量

一个查询耗时很长,但 read_rows 很少,问题可能在聚合、排序、网络或并发资源;一个查询 read_rows 接近表总行数,则更可能是裁剪结构没有发挥作用。

不要只看 wall-clock 时间。至少同时观察:

  • read_rows
  • read_bytes
  • 结果行数;
  • 查询并发;
  • 磁盘和 CPU;
  • 是否读取了不必要的大字段。

十六、数据模型中的几个边界

1. 排序键不会自动去重

ORDER BY (tenant_id, event_time, user_id)

只决定物理顺序和索引组织,不表示:

(tenant_id, event_time, user_id) 唯一

需要唯一性时,必须在写入流程或上游系统中保证,或者选择具有特定合并语义的表引擎并正确查询。

2. 分区键不会自动加速所有查询

只有查询条件能够排除分区时,分区才产生裁剪收益。对不含时间条件的查询,按月分区可能几乎只提供运维价值。

3. 跳过索引不会保证一定减少读取

跳过索引是机会性优化:

  • 没有可排除的数据块时,收益接近零;
  • 索引本身占用空间和计算资源;
  • 写入和 merge 时需要维护索引;
  • 过多索引会增加写放大和后台处理成本。

4. 过细的排序键也有成本

把很多列都放进 ORDER BY,可能带来:

  • 排序成本增加;
  • 主索引元数据变大;
  • merge 计算更重;
  • 写入吞吐下降;
  • 复杂类型或大字符串参与排序的额外成本。

排序键应该表达稳定的查询访问模式,而不是把所有经常出现的过滤列都塞进去。

5. ORDER BY 中的时间列不是越靠前越好

时间列靠前通常有利于时间范围扫描,但如果大多数查询都是租户内的时间范围:

WHERE tenant_id = ?
  AND event_time BETWEEN ? AND ?

那么:

ORDER BY (tenant_id, event_time)

往往比:

ORDER BY (event_time, tenant_id)

更能缩小每个租户的读取范围。

最终应以查询工作负载和实测裁剪结果为准。


十七、一个可执行的建模验证流程

可以用小规模数据先验证物理模型,而不是直接在生产表上猜测。

1. 建表并写入测试数据

CREATE TABLE demo_events
(
    tenant_id   LowCardinality(String),
    event_time  DateTime,
    event_type  LowCardinality(String),
    user_id     UInt64,
    value       Float64,

    INDEX idx_event_type event_type TYPE set(10) GRANULARITY 1
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (tenant_id, event_time, user_id);

写入两个月数据:

INSERT INTO demo_events VALUES
('acme', '2025-01-01 10:00:00', 'login',    1,  1.0),
('acme', '2025-01-02 10:00:00', 'purchase', 2, 20.0),
('acme', '2025-01-03 10:00:00', 'logout',   3,  1.0),
('beta', '2025-01-04 10:00:00', 'login',    4,  1.0),
('beta', '2025-02-01 10:00:00', 'purchase', 5, 50.0);

2. 验证分区和排序键对应的查询

EXPLAIN indexes = 1
SELECT count()
FROM demo_events
WHERE tenant_id = 'acme'
  AND event_time >= '2025-01-01 00:00:00'
  AND event_time <  '2025-02-01 00:00:00';

预期是:

  • 只考虑 2025 年 1 月分区;
  • 在该分区内根据 tenant_idevent_time 尝试缩小 granule;
  • 最终返回 3

3. 验证跳过索引的适用性

EXPLAIN indexes = 1
SELECT *
FROM demo_events
WHERE event_type = 'refund';

在这个极小数据集上,读取量和索引统计可能不稳定,甚至看不出明显收益,因为数据块太少。这个实验能验证语法和逻辑,但不能证明生产性能。

要验证数据跳过索引,必须使用接近真实的:

  • 数据规模;
  • 批次大小;
  • 字段分布;
  • 查询谓词;
  • 分区数量;
  • 并发度。

十八、建模结论

MergeTree 表的性能结构可以概括为:

分区:排除整组数据
排序键:让相关数据在 part 内形成连续范围
稀疏主索引:定位可能命中的 granule
数据跳过索引:根据块级统计排除更多 granule
列式存储:只读取查询需要的列
后台 merge:持续整理 parts,但不改变普通 MergeTree 的去重语义

设计一张 ClickHouse 明细表时,可以按以下因果关系检查:

  1. 哪些查询能够排除整个时间或业务分区?
  2. 分区内部,哪些过滤列应出现在排序键左前缀?
  3. 哪些高频过滤列无法放入排序键,但在块内具有局部聚集性?
  4. 写入批次是否会制造大量小 parts?
  5. 查询是否依赖普通 MergeTree 并不提供的唯一性或多语句事务?
  6. 在目标版本和实际部署中,EXPLAIN 与查询日志是否证明裁剪确实发生?

真正有效的 ClickHouse 建模,不是给表添加最多的分区和索引,而是让数据的物理排列与查询条件保持一致。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。