数据库基础体系 · 第 46/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
ClickHouse 列式建模:MergeTree、排序键、分区和数据跳过
在 ClickHouse 中,表结构设计很大程度上不是“选择几个字段并建立索引”,而是设计以下几件事:
- 数据以什么物理结构落盘;
- 每个数据片段内部如何排序;
- 查询可以先排除哪些分区;
- 在仍需访问的分区中,还可以跳过哪些数据块;
- 写入、合并和后台维护如何改变这些物理结构。
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; - 让不同批次的重复数据只保留一份;
- 把多个表的写入变成一个事务。
如果需要在合并时改变行语义,应使用相应的引擎,例如 ReplacingMergeTree、SummingMergeTree 或 AggregatingMergeTree。这些引擎又有各自的查询语义和 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. 排序键决定哪些查询具有连续范围
设排序键为:
对于两行 和 ,按字典序比较:
- 先比较 与 ;
- 若不同,结果由 决定;
- 若相同,再比较 ;
- 依此类推。
因此,数据在排序键空间中形成连续范围。查询条件越接近排序键的左前缀,越容易排除大块数据。
例如排序键为:
(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_id 和 event_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 BY 与 PRIMARY 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';
逻辑过程可以简化为:
- 根据排序键找到
acme的连续范围; - 判断这个时间范围与各 granule 的可能范围是否相交;
- 排除明显不可能命中的 granule;
- 读取剩余 granule 的相关列;
- 对读取出的真实行执行最终过滤。
注意,主索引只是稀疏索引。边界附近可能需要多读一个 granule,因为索引记录的是块的定位信息,而不是每一行的精确谓词结果。
3. 为什么排序键不适合任意过滤条件
如果查询改成:
SELECT *
FROM events
WHERE user_id = 42;
而排序键是 (tenant_id, event_time, user_id),那么在不同的租户和时间范围内都可能存在 user_id = 42。
从排序键的角度看,条件没有固定左前缀:
因此不能简单地把所有可能的 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');
大致会发生:
- 服务器接收一个插入数据块;
- 根据
toYYYYMM(event_time)计算每行所属分区; - 每个分区的数据按
ORDER BY排序; - 为对应分区写出新的 part;
- part 在写入完成后对查询可见;
- 后台线程稍后尝试合并 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 根据过滤条件判断某个数据块是否“不可能包含匹配行”,如果确定不可能,就跳过该块。
它不是传统意义上的逐行索引,也不返回匹配行的位置。
其核心逻辑是:
否则:
因此,数据跳过索引必须遵守一个重要原则:
只能安全地跳过“不可能命中”的块,不能因为索引不精确而漏掉真实结果。
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,则该块不可能命中,可以跳过。
形式化地说,块 的值范围是:
若查询条件是 ,当:
时,块可以被安全排除。
对于查询 x = c,当:
时,也可以跳过。
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;- 更大的值:索引更小,但跳过范围更粗。
设一个跳过索引块覆盖 个基础 granule,则:
- 较小:裁剪更精细,索引元数据更多;
- 较大:索引元数据更少,但命中一个条件时可能需要读更大的数据范围。
这只是近似直觉,实际读取范围还会受到自适应粒度、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 压力。生产环境应:
- 先评估涉及的数据量;
- 观察 mutation 状态;
- 避开业务高峰;
- 确认磁盘剩余空间;
- 验证新索引确实被查询使用。
可查看 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_id和event_time尝试缩小 granule; - 最终返回
3。
3. 验证跳过索引的适用性
EXPLAIN indexes = 1
SELECT *
FROM demo_events
WHERE event_type = 'refund';
在这个极小数据集上,读取量和索引统计可能不稳定,甚至看不出明显收益,因为数据块太少。这个实验能验证语法和逻辑,但不能证明生产性能。
要验证数据跳过索引,必须使用接近真实的:
- 数据规模;
- 批次大小;
- 字段分布;
- 查询谓词;
- 分区数量;
- 并发度。
十八、建模结论
MergeTree 表的性能结构可以概括为:
分区:排除整组数据
排序键:让相关数据在 part 内形成连续范围
稀疏主索引:定位可能命中的 granule
数据跳过索引:根据块级统计排除更多 granule
列式存储:只读取查询需要的列
后台 merge:持续整理 parts,但不改变普通 MergeTree 的去重语义
设计一张 ClickHouse 明细表时,可以按以下因果关系检查:
- 哪些查询能够排除整个时间或业务分区?
- 分区内部,哪些过滤列应出现在排序键左前缀?
- 哪些高频过滤列无法放入排序键,但在块内具有局部聚集性?
- 写入批次是否会制造大量小 parts?
- 查询是否依赖普通 MergeTree 并不提供的唯一性或多语句事务?
- 在目标版本和实际部署中,
EXPLAIN与查询日志是否证明裁剪确实发生?
真正有效的 ClickHouse 建模,不是给表添加最多的分区和索引,而是让数据的物理排列与查询条件保持一致。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:MongoDB 索引与副本集:查询计划、分片、选举和生产运维
- 下一篇:ClickHouse 查询与运维:批量写入、物化视图、集群和性能诊断
- 延伸:DuckDB 嵌入式分析:列式执行、Parquet、SQL 和本地数据工程
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论