4、主键索引与跳数索引实战:理解稀疏索引原理,合理使用 minmax、set、bloom_filter 等跳数索引加速查询
聊到 ClickHouse 的索引,很多人第一反应就是「主键索引」。但说实话,ClickHouse 的主键索引跟 MySQL 那种 B+ 树完全不是一回事。你如果拿 MySQL 的思路来理解 ClickHouse 的索引,那大概率会踩坑。
我刚开始接触 ClickHouse 时,就犯过这个错误。当时建了一张大宽表,主键设了三个字段,心想这下查询肯定快了吧?结果跑了个范围查询,慢得让我怀疑人生。后来一查文档才明白——ClickHouse 的主键索引是稀疏的。
4.1 稀疏索引:ClickHouse 的底层逻辑
什么叫稀疏索引?说白了,就是不是每一行数据都记录索引条目,而是每隔若干行才记录一条。这个「若干行」在 ClickHouse 里叫 index_granularity,默认是 8192 行。
你想想看,如果一张表有 1 亿行数据,MySQL 的 B+ 树索引可能要存几千万个索引条目。但 ClickHouse 的稀疏索引只存大约 12207 个条目(1亿 ÷ 8192)。这带来的好处很明显:索引体积小,能完全加载到内存里。
核心要点:稀疏索引不定位到具体行,只定位到数据块(granule)。查询时先通过索引找到可能包含目标数据的 granule,然后在这个 granule 内部做全扫描。
但这也意味着:如果你的查询条件不能有效裁剪 granule,那稀疏索引就帮不上忙。我见过不少团队把主键设成 UUID,结果每次查询都要扫全表——因为 UUID 是随机的,索引根本没法做范围裁剪。
4.2 主键设计:选对字段,查询快 10 倍
主键设计是 ClickHouse 性能优化的第一道关卡。我个人习惯遵循两个原则:
- 高基数且有序的字段放前面:比如时间戳、自增 ID。这样范围查询能快速定位到少数 granule。
- 查询频率高的过滤字段优先:如果业务上经常按用户 ID 查,那就把用户 ID 放在主键靠前的位置。
举个例子,假设我们有一张订单表:
CREATE TABLE orders (
order_id UInt64,
user_id UInt64,
order_time DateTime,
amount Float64,
status String
) ENGINE = MergeTree()
ORDER BY (order_time, user_id);
这里我把 order_time 放在第一位,因为大部分查询都是按时间范围过滤的。如果反过来,把 user_id 放前面,那按时间查就会慢很多——因为相同 user_id 的数据在物理上不连续,时间范围会跨很多 granule。
小技巧:如果你不确定主键顺序怎么排,可以用 EXPLAIN INDEXES 看看查询走了哪些 granule。granule 越少,说明索引裁剪得越好。
4.3 跳数索引:主键不够用时的「外挂」
主键索引虽然高效,但它只能加速主键字段的查询。如果查询条件里出现了非主键字段,比如按 status 过滤,那 ClickHouse 就只能全表扫描了。
这时候就需要跳数索引(Skip Index)登场。它的原理很简单:在每个 granule 上预先计算一些统计信息,查询时先看统计信息,如果确定 granule 里没有目标数据,就直接跳过这个 granule。
ClickHouse 提供了好几种跳数索引类型,我挑三个最常用的讲讲:
| 索引类型 | 适用场景 | 原理 | 我踩过的坑 |
|---|---|---|---|
minmax |
范围查询、排序字段 | 记录每个 granule 的最小值和最大值 | 对高基数字段效果一般,因为范围太宽 |
set |
等值查询、低基数枚举字段 | 记录每个 granule 中出现的所有值 | set 大小设太大,内存会爆 |
bloom_filter |
高基数字段的等值查询、数组包含查询 | 用布隆过滤器判断值是否存在 | 有假阳性率,但通常可接受 |
4.4 minmax 索引:最简单的范围裁剪
minmax 索引是最直观的跳数索引。它记录每个 granule 里目标字段的最小值和最大值。查询时,如果过滤条件不在这个范围内,就直接跳过整个 granule。
举个例子,假设我们经常按 amount 字段做范围查询:
ALTER TABLE orders ADD INDEX idx_amount (amount) TYPE minmax GRANULARITY 1;
这里的 GRANULARITY 1 表示每个 granule 都建一个索引条目。如果你觉得索引太密集,可以设大一点,比如 GRANULARITY 2,表示每 2 个 granule 合并成一个索引条目。
注意:GRANULARITY 不是越大越好。设太大,索引条目变少,但每个条目的范围变宽,裁剪效果会下降。我一般建议从 1 开始,实测后再调整。
我曾经在一个日志分析系统里用过 minmax 索引。当时主键是 (log_time, server_id),但业务上经常按 error_code 范围查。加了 minmax 索引后,查询时间从 12 秒降到了 0.8 秒——因为大部分 granule 里根本没有目标 error_code 范围的数据。
4.5 set 索引:枚举值的利器
如果你的字段是枚举类型,比如 status 只有 'pending'、'success'、'failed' 三种值,那 set 索引就很合适。它记录每个 granule 里出现了哪些值,查询时直接判断目标值是否在集合里。
ALTER TABLE orders ADD INDEX idx_status (status) TYPE set(0) GRANULARITY 1;
这里的 set(0) 表示不限制集合大小。你也可以设成 set(100),表示最多存 100 个不同的值。超过上限后,这个 granule 的索引就失效了。
避坑指南:我曾经把 set 索引用在一个高基数字段上(比如用户 ID),结果每个 granule 里都有几千个不同的用户 ID,set 索引直接失效,查询还是全表扫描。所以 set 索引只适合低基数场景。
4.6 bloom_filter 索引:高基数字段的救星
对于高基数字段的等值查询,minmax 和 set 都不太灵光。这时候 bloom_filter 索引就派上用场了。
布隆过滤器的原理不复杂:用多个哈希函数把值映射到一个位图上。查询时,如果位图上对应的位都是 1,说明值可能存在;如果有任何一位是 0,说明值一定不存在。
ALTER TABLE orders ADD INDEX idx_user_id (user_id) TYPE bloom_filter(0.025) GRANULARITY 1;
这里的 0.025 是假阳性率,表示最多允许 2.5% 的误判。假阳性率越低,位图越大,索引占用的内存也越多。我一般设 0.01 到 0.05 之间,效果和资源消耗比较平衡。
我记得有一次做用户行为分析,表里有 5 亿行数据,主键是 (event_time, event_type),但业务上经常按 user_id 查某个用户的所有行为。加了 bloom_filter 索引后,查询从 30 秒降到了 2 秒——虽然还有假阳性导致的少量误扫,但整体效果非常明显。
4.7 跳数索引的实战建议
说了这么多,最后总结几条实战经验:
- 先看查询模式:你的查询条件里,哪些字段不是主键?这些字段的过滤效果如何?如果过滤后能排除 90% 以上的数据,那跳数索引就值得加。
- 不要贪多:跳数索引不是越多越好。每个索引都会增加写入开销和内存占用。我一般建议一张表不超过 3-5 个跳数索引。
- 用
EXPLAIN验证效果:加完索引后,用EXPLAIN indexes = 1看看查询走了哪些 granule,对比加索引前后的 granule 数量。 - 注意数据分布:如果数据是随机分布的,跳数索引的效果会打折扣。比如
minmax索引在有序数据上效果最好,在随机数据上几乎没用。
嗯,索引这块内容比较多,但核心就一句话:主键索引负责快速定位,跳数索引负责精准跳过。两者配合好了,ClickHouse 的查询性能才能发挥到极致。