前置:/clickhouse-merge-tree-principle/(MergeTree 存储结构与稀疏索引基础)、/clickhouse-query-optimizer/(查询优化器与执行计划)、/clickhouse-schema-modeling-best-practices/(主键与 Schema 建模)。
目录
- 1. 裁剪的本质:从全表扫描到按需读取
- 2. 稀疏索引原理:主键如何跳过 granule
- 3. 主键设计与 ORDER BY 前缀
- 4. 分区裁剪:按分区路径跳过文件
- 5. 跳数索引:minmax、set 与 bloom_filter
- 6. 高级跳过索引:ngrambf 与 tokenbf
- 7. 物化跳过索引与数据布局
- 8. EXPLAIN 观察裁剪:indexes 与 pipeline
- 9. 裁剪失效排查与优化实践
- 10. 速查表与一句话记忆
- 延伸阅读
1. 裁剪的本质:从全表扫描到按需读取
ClickHouse 查询快的根本不是「读得快」,而是读得少。裁剪(Pruning)就是让存储层在回答查询前,先划掉一批绝不满足条件的数据。
裁剪三件套:
□ 主键索引:跳过不命中的 granule
□ 分区裁剪:跳过不命中的分区目录
□ 跳数索引:跳过块级不满足条件的整块
裁剪粒度:分区 → Part → granule → 行
衡量指标:read_rows、SelectedGranules 越小越成功
-- 用系统日志对比「裁剪前后」的扫描量
SELECT query, read_rows,
ProfileEvents['SelectedGranules'] AS granules
FROM system.query_log
WHERE query_start_time > now() - INTERVAL 1 DAY
ORDER BY query_start_time DESC LIMIT 10;
工程要点:裁剪的本质是分层拒绝——分区层、Part 层、granule 层逐级过滤,让存储层只读最小必要集合;成败的硬指标是 read_rows 与 SelectedGranules 是否远小于全量。
2. 稀疏索引原理:主键如何跳过 granule
MergeTree 的主键索引是稀疏的:不是每行建索引,而是每 index_granularity(默认 8192)行记录一个主键值。
稀疏索引结构:
□ 每个 granule = 8192 行,落盘为一个列块
□ 每 granule 对应索引文件里一个 mark(主键值+偏移)
原理:主键有序 → 范围条件二分定位
□ 只读中间命中的 granule,跳过两侧
□ 稀疏 = 8192 行一条,索引可全放内存
关键约束:
□ 主键依赖 ORDER BY(而非唯一约束)
□ 只能加速「匹配主键前缀」的条件
-- ORDER BY 即主键
CREATE TABLE events (
event_time DateTime, user_id UInt64,
event_type String, value UInt32
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, user_id);
-- event_time 是主键前缀,能命中稀疏索引
EXPLAIN indexes = 1
SELECT count() FROM events
WHERE event_time >= '2026-09-01' AND event_time < '2026-09-02';
工程要点:稀疏索引用每 8192 行一个 mark 的代价换来「主键全进内存 + 二分定位」,能让范围条件精确跳过两侧 granule;它加速的前提是条件匹配 ORDER BY 前缀,否则索引对本次查询毫无贡献。
3. 主键设计与 ORDER BY 前缀
主键的能力边界由 ORDER BY 前缀决定:只有条件与主键从左到右构成前缀匹配,稀疏索引才生效。
前缀匹配规则:
□ 单列主键 (a):where a=.. → 生效
□ 复合主键 (a,b):where a=.. and b=.. → 生效
□ 复合主键 (a,b):where b=.. → 失效(跳过 a)
主键选择原则:
□ 过滤最频繁的等值条件放最左
□ 时间范围放前面利于分区+裁剪
□ 主键不用加唯一性,索引仅用于裁剪
陷阱:
□ 主键过多列 → 索引行变宽,内存上升
□ 函数包裹列 → 需要物化列配合
-- 左:条件完全匹配前缀,裁剪充分
SELECT count() FROM events
WHERE event_time = '2026-09-01' AND user_id = 123;
-- 右:只按 user_id 过滤,前缀断裂,稀疏索引失效
SELECT count() FROM events WHERE user_id = 123;
-- 补救:加物化列,不打断 event_time 前缀
CREATE TABLE events_v2 (
event_time DateTime, user_id UInt64,
user_id_str String MATERIALIZED toString(user_id)
) ENGINE = MergeTree() ORDER BY (event_time, user_id);
工程要点:主键设计本质是前缀设计——把最高频过滤字段按「等值→范围」顺序排进 ORDER BY,让条件构成主键前缀;任何跳过左侧列的条件都会让稀疏索引失效,只能靠跳数索引或物化列补救。
4. 分区裁剪:按分区路径跳过文件
分区裁剪发生在文件系统层面:数据按分区键落盘为独立目录,查询时直接列出满足条件的分区目录,其余分区连碰都不碰。
分区裁剪原理:
□ 数据 → 分区表达式求值 → 目录
□ 查询时按分区键求值条件
□ 不匹配目录直接丢弃
□ 比主键索引更粗粒度、更早生效
分区键设计:
□ 时间分区用 toYYYYMM → 月度
□ 分区粒度 = 写入/合并的原子单元
□ 分区过细 → parts 膨胀 → 合并压力大
验证:
□ EXPLAIN indexes=1 的 SelectedParts
□ 命中 → SelectedParts 远小于总 part 数
-- 月度分区:where 只给一个月 → 只读一个目录
CREATE TABLE events
PARTITION BY toYYYYMM(event_time)
ORDER BY event_time;
EXPLAIN indexes = 1
SELECT count() FROM events WHERE event_time >= '2026-09-01';
-- 反例:条件不含分区键 → 所有分区都要读
EXPLAIN indexes = 1
SELECT count() FROM events WHERE user_id = 123;
工程要点:分区裁剪是最粗也最便宜的裁剪——分区键与查询条件对齐时,不匹配目录被直接排除;分区粒度要与查询粒度匹配,过细分区会引发 parts 膨胀,抵消裁剪收益。
5. 跳数索引:minmax、set 与 bloom_filter
当条件不在主键前缀上时,**跳数索引(数据跳过索引)**是第二道防线:它按 granule 块粒度预先计算统计信息,查询时判断整块能否跳过。
跳数索引类型:
□ minmax:块内最小/最大值 → 范围条件
□ set:块内去重值集合(限低基数)→ 等值
□ bloom_filter:布隆(误判率默认 0.025)→ 等值/IN
□ ngrambf / tokenbf:子串/分词布隆 → LIKE
工作方式:对每个 granule 判断「一定不匹配?」
□ 一定不匹配 → 跳过整块
□ 可能匹配 → 读块再逐行过滤
代价:每个索引列额外写索引文件
-- 给非主键列加跳数索引
CREATE TABLE events (
event_time DateTime, user_id UInt64,
event_type String, status_code UInt16, message String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, user_id)
INDEX idx_status minmax GRANULARITY 4
INDEX idx_type set(1000) GRANULARITY 4
INDEX idx_msg bloom_filter(0.025) GRANULARITY 4;
-- status 不在主键前缀,命中跳数索引
EXPLAIN indexes = 1
SELECT count() FROM events WHERE status_code = 500;
工程要点:跳数索引按 granule 粒度预存统计信息(minmax/set/bloom/ngrambf),专门拦截不在主键前缀上的条件;它对每个块回答「能否跳过」,能跳过就免读整块,但每个索引都有构建代价,要配给最热门的非主键过滤列。
6. 高级跳过索引:ngrambf 与 tokenbf
对于字符串模糊检索(LIKE、hasToken),普通索引无能为力,需要 ngrambf 与 tokenbf 这类基于布隆的文本索引。
文本索引家族:
□ tokenbf_v1:按空格分词建布隆 → 整词匹配
□ ngrambf_v1(N, size, hash, seed):按 N-gram
→ N=3 表示 3 字符滑动窗口,支持子串
原理:每个 granule 收集该块的 token/ngram 集合
□ 布隆回答「这块有没有这个词」
□ 没有 → 跳过整块;有(或误判)→ 读块过滤
适用:日志 message LIKE '%timeout%'、内容检索
注意:N 越小越灵敏但索引越大;子串短于 N 会退化
-- ngrambf:3-gram,256 字节,2 个哈希
INDEX idx_msg ngrambf_v1(3, 256, 2, 0) GRANULARITY 1;
-- tokenbf:按词匹配,适合英文分词
INDEX idx_tokens tokenbf_v1(1024, 3, 0) GRANULARITY 1;
-- 查询命中文本索引
EXPLAIN indexes = 1
SELECT count() FROM logs
WHERE message LIKE '%connection refused%';
工程要点:ngrambf 与 tokenbf 用布隆过滤器把文本匹配变成「块级可否跳过」判断,让 LIKE/hasToken 不再全扫;N-gram 粒度决定灵敏度与体积,子串过短(短于 N)会退化,要按实际检索长度选参数。
7. 物化跳过索引与数据布局
跳过索引的性价比和数据物理分布强相关:如果某个值均匀撒在所有 granule,那么任何跳数索引都无法跳过——索引救不了「处处都有」的数据。
物化跳跃关键:
□ 索引统计在写入时物化到索引文件
□ 查询时零计算,直接读统计判断
数据布局影响:
□ 值聚在一起(时间/分类排序)→ 好裁剪
□ 值均匀散布 → 每块都可能命中 → 无法跳过
改善数据布局:
□ 把高过滤字段排进 ORDER BY(天然聚合)
□ 低基数字段排进主键后缀
□ 保持全局有序写入
校验:EXPLAIN indexes=1 看索引被读、SelectedGranules 下降
-- 检查某列是否有跳过索引可用
SELECT name, type, expr
FROM system.data_skipping_indices
WHERE table = 'events';
-- 验证裁剪效果
SELECT read_rows,
ProfileEvents['SelectedGranules'] AS selected_g
FROM system.query_log
WHERE query ILIKE '%status_code%'
ORDER BY query_start_time DESC LIMIT 5;
工程要点:跳过索引是写入时物化、查询时零计算的统计结构,其效力取决于值的物理分布——聚簇分布才能裁剪,均匀分布则形同虚设;因此数据布局与索引同等重要,让高频过滤值在物理上聚拢是裁剪的根基。
8. EXPLAIN 观察裁剪:indexes 与 pipeline
裁剪是否生效不能靠猜,要用 EXPLAIN indexes = 1 与 EXPLAIN PIPELINE 直接观察存储层的动作。
EXPLAIN indexes = 1 输出:
□ SelectedParts → 分区裁剪是否生效
□ SelectedGranules → 主键索引是否生效
□ SelectedMarks → 跳数索引是否生效
□ 数值越小 → 裁剪越彻底
EXPLAIN PIPELINE 输出:
□ ParallelReadingFromParts:读哪些 part
□ FilterTransform:WHERE 过滤位置
-- 单条查询的完整裁剪体检
EXPLAIN indexes = 1
SELECT count() FROM events
WHERE event_time >= '2026-09-01'
AND event_time < '2026-09-02'
AND status_code = 500;
-- 物理执行管道里看并行读取的来源
EXPLAIN PIPELINE
SELECT count() FROM events WHERE status_code = 500;
工程要点:EXPLAIN indexes = 1 给出 SelectedParts/SelectedGranules/SelectedMarks 三个数字,EXPLAIN PIPELINE 给出算子与读取位置,两者配合就能定量回答「裁剪到什么程度、卡在哪一层」——这是所有裁剪优化的出发点。
9. 裁剪失效排查与优化实践
查询慢不等于要加索引,先排查裁剪链路哪里断了。
失效原因清单:
□ 条件不含分区键 → 分区裁剪失效
□ 条件不在主键前缀 → 稀疏索引失效
□ 过滤列没有索引 → 全扫 → 加跳过索引
□ 类型不匹配 → 隐式转换 → 索引失效
□ 函数包裹列 → 索引失效
□ 数据分布均匀 → 索引有但跳不动
排查流程:EXPLAIN indexes=1 看数字 → 对比全量 → 定位失效层 → 对症下药
-- 反例:函数包裹导致索引失效
SELECT count() FROM events
WHERE toYYYYMM(event_time) = 202609;
-- 正例:改写成范围,索引与分区裁剪都生效
SELECT count() FROM events
WHERE event_time >= '2026-09-01' AND event_time < '2026-10-01';
-- 类型不一致:UInt16 列与 String '500' 比较会隐式转换
EXPLAIN indexes = 1
SELECT count() FROM events WHERE status_code = 500;
工程要点:裁剪失效最常见四因——函数包裹列、类型隐式转换、条件不在前缀、分区键不匹配;排查只用三招:EXPLAIN indexes=1 看数字、对比全量、改写条件让列裸露,让索引能直接吃到原始列。
10. 速查表与一句话记忆
把全文压成一张速查表,方便日常对照。
裁剪三层与验证:
□ 分区裁剪 → SelectedParts(目录级)
□ 稀疏索引 → SelectedGranules(granule 级)
□ 跳数索引 → SelectedMarks(mark 级)
索引选择:
□ 范围条件 → minmax
□ 低基数等值 → set
□ 高基数等值/IN → bloom_filter
□ 文本 LIKE/子串 → ngrambf / tokenbf
设计口诀:
□ ORDER BY 排前缀,高频字段靠左
□ 分区键对齐查询粒度
□ 非前缀过滤列补跳数索引
□ 列裸露、类型对齐、别包函数
一句记忆:裁剪 = 先划掉不读,再读最少
-- 一张「索引体检」的标准模板
EXPLAIN indexes = 1
SELECT count() FROM events
WHERE <条件列> = <值> OR <条件列> BETWEEN .. AND ..;
工程要点:查询裁剪是 ClickHouse 性能的第一杠杆——分区裁剪砍目录、稀疏索引砍 granule、跳数索引砍 mark,三件套配合「列裸露、类型对齐、前缀匹配」的设计纪律,能让存储层对绝大多数数据块提前说 No。
延伸阅读
- /clickhouse-merge-tree-principle/ — MergeTree 存储结构与稀疏索引的底层原理
- /clickhouse-query-optimizer/ — 查询优化器规则与 EXPLAIN 执行计划分析
- /clickhouse-schema-modeling-best-practices/ — 主键、分区键与 Schema 建模最佳实践
- /clickhouse-columnar-compression/ — 列式压缩与数据编码对读取成本的影响
- /clickhouse-data-ingestion/ — 数据导入与分区/part 的形成过程
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。