刚开始用ClickHouse那会儿,我一度以为它压根不需要索引这玩意儿。毕竟列式存储、向量化执行这些特性已经足够唬人了,随手一条聚合SQL在千万级数据上跑得飞快。直到某天线上一条带过滤条件的查询从几十毫秒变成几十秒,我才意识到,任何数据库在数据量面前都会暴露出底层机制的边界,ClickHouse的索引设计恰恰是它最容易被误解、也最值得抠细节的地方。
这篇文章不是来给你背文档的,而是完全围绕“查询为什么慢”和“索引到底怎么起作用”这两个痛点展开的。我把主键索引、跳数索引、分区裁剪、物化视图这几层东西的配合逻辑讲清楚,再把实战中容易踩的坑统统一并摆出来。不管是刚接触ClickHouse的新手,还是已经在生产环境维护集群的工程师,我希望能帮你省掉几天的排查时间。
1. 先搞清楚一个关键认知:ClickHouse的索引不是“索引”
如果你带着MySQL或Elasticsearch的思维来理解ClickHouse的索引,大概率会走弯路。它没有传统意义上那种独立于数据维护的二级索引结构,也没有B+树在每次查询时走一遍树的查找路径。ClickHouse的索引本质上是数据组织方式的一部分,服务于“快速跳过不需要的数据块”这个目标,而不是“定位到某一行”。
1.1 稀疏索引与数据块的关系
默认情况下,ClickHouse的MergeTree引擎表会按主键字段排序存储数据,排序后的数据每8192行被划分成一个granule(颗粒),这是索引和数据交互的最小单位。每个granule在primary.idx索引文件中对应一条索引项——里面记录的是这一组数据中第一行的主键值。这个结构就叫稀疏索引,因为它不需要为每一行建立索引记录,只需要记住每个颗粒的起始值。
查询的时候,ClickHouse会先读primary.idx,通过二分查找定位到可能命中的granule区间,然后只把这些granule对应的数据列读出来做过滤,而不是从头到尾扫完整个列。这就是为什么“查询条件只要碰到主键前缀,速度就极快”的根本原因。相比每行都有索引项的密集索引,稀疏索引的优点是索引体积小,可以完全缓存在内存中,查询时不需要频繁回读磁盘上的索引文件。
不过这里有个容易忽略的点:索引定位是到granule粒度,不是到行粒度。哪怕命中了1000个granule,ClickHouse实际要扫描的是这些granule覆盖的完整数据块,过滤出符合条件的行再返回。所以如果索引能帮你把扫描范围从100万个granule缩小到10个,那性能提升就是质变的;但如果从10万个缩小到5万个,提升有限,系统依然要读大量的数据块。
1.2 index_granularity参数对性能的影响
因为索引粒度由index_granularity控制(默认是8192),这个参数直接决定了索引的“分辨率”。调小它,每个granule包含的行数变少,索引条目变多,过滤粒度更细,扫描的数据量更少,但索引文件变大、构建和加载索引的消耗增加。调大它则恰恰相反。
我在实践中通常的做法是:如果表里单行数据非常宽(比如几十个字段,每行几KB),会把index_granularity调整到4096甚至更小,因为单行体积大时,每扫描一个granule的开销被放大,索引粒度带来的过滤优势更值得争取。反之,如果表结构窄、行数巨大,保持默认8192就够了,强行调小往往会让primary.idx变得过大,内存占用上升,反而对查询性能有反效果。这个参数不是越高越好,也不是越低越好,需要根据行的平均宽度和实际查询命中率来做权衡。
在调参之外更常见的一个问题是:索引排序键需要搭配分区键一起设计。如果数据被物理分区裁剪后已经只剩很小的范围,那主键索引能发挥的作用就会被弱化。所以在排查查询性能时,别只盯着索引看,要先把分区和索引这两层联动起来评估。关于分区键的设计,我在后面第4部分会专门展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 主键设计:决定查询性能的第一道闸门
MergeTree表的主键(ORDER BY)决定了数据在磁盘上的物理排序,这是ClickHouse查询性能的基石。很多刚接触的人容易犯的一个错误是照搬关系型数据库的思路,把唯一标识字段(比如UUID)直接设为主键,结果查询慢到怀疑人生。原因很简单:UUID是随机生成的,把它当排序键意味着数据排序完全无规律,每个查询条件都几乎要扫描全表。
2.1 最左前缀原则:为什么字段顺序如此重要
ClickHouse的稀疏索引对多列主键的匹配遵循最左前缀原则。也就是说,如果主键是(a, b, c),那么查询条件里带a、带(a,b)、带(a,b,c)时索引才能真正发挥作用;如果条件只带b或者只带c,索引走不了,扫描效率直接退化为全表扫描。
这意味着主键字段的顺序不是拍脑袋决定的,而是按“过滤能力从强到弱”来排列的。判断过滤能力强弱主要有两个维度:一是这个字段在查询条件中出现的频率,二是它的基数(基数越高,过滤后的数据量越小)。比如订单表里,如果90%的查询都会带用户ID和时间范围,那么把user_id放第一位、event_time放第二位就是合理的;如果60%的查询只按时间范围过滤,那event_time应该放在第一位。
有个真实案例让我印象很深。之前负责过一个埋点日志表,初始设计的主键是(device_id, event_time),想着每个设备一个范围,查设备轨迹很顺手。结果后来需求变化,绝大多数分析任务都是按天、按小时统计全局或某个渠道的数据,device_id在查询条件里根本不出现。这种情况下,索引完全失效,一次统计要扫全量数据。改成(event_time, device_id)之后,按时间范围裁剪的效率高了几个数量级。这个教训说明,主键设计不能只顾当前最直觉的查询路径,要面向未来一段时间内真实的查询模式。
2.2 高基数字段不要放主键首位
另一个容易翻车的地方是把基数极高的字段(比如用户的完整手机号,甚至手机号加时间戳的组合)放在主键第一位。看起来过滤精准,但Spark、ClickHouse这类分析型引擎要的是“快速排除大块数据”,而不是“单点精确查找”。如果每条数据的索引键几乎不重复,稀疏索引的每个granule起始值都不同,二分查找只能排除掉很少的granule,索引退化为一种近似“逐行扫描”的效果。
正确的做法是选择具有自然区分度但又能聚合出一批连续数据块的字段组合。比如城市ID、渠道类型、订单状态这类低基数字段,放在主键前面能快速把数据定位到某个区域,再用高基数的时间字段收窄范围,效果比“一上来就精确到单条记录”好得多。
2.3 排序键的常见误区与优化策略
还有个常见误区是追求把所有查询条件都放进主键里。排序键组合超过3到4个字段时,索引文件膨胀,写入排序的CPU开销也变大,但过滤收益却不会线性提升。因为ClickHouse的索引是稀疏的,字段太多只会让每个granule的索引项变长,查询时不太可能每个字段都贡献出关键的裁剪能力。
我的优化习惯是:主键内字段数尽量不超过4个,前两个字段必须是查询频率最高的过滤条件,后续字段用来进一步细分。另外,如果有字段需要在过滤中使用但又不适合放进主键,可以考虑和跳数索引配合,这是下一节要聊的重点。
3. 跳数索引:解决“查询条件不在主键上”的利器
主键设计得再合理,也架不住查询条件千变万化。现实中的分析任务经常要按某个非主键字段过滤,比如日志表主键是(event_time, project_id),但你要按status=500来查错误率,按user_id查某个用户的异常行为。这时候如果不想回全表扫数据,跳数索引(Skipping Index)就是唯一的出路。
3.1 四类跳数索引的适用场景
跳数索引也是建在granule粒度上的,每个索引块记录若干granule的统计信息,查询时如果发现某个索引块不满足条件,就直接跳过这一整段数据。目前MergeTree支持的跳数索引类型主要有四种,各有各的主场:
| 类型 | 原理 | 适用场景 | 注意点 |
|---|---|---|---|
| minmax | 记录一段数据的最小值和最大值 | 数值范围、日期时间过滤 | 字段取值范围越窄越好,否则没区分度 |
| set | 记录一段数据里出现过的去重值集合 | 低基数列枚举值过滤 | 基数高了会过度膨胀,不适合 |
| bloom_filter | 布隆过滤器记录值是否存在 | 高基数列等值匹配 | 有误判率,但对“是否存在”的过滤很有效 |
| ngrambf/tokenbf | 分词后布隆过滤 | 文本模糊查询 | 适合短文本和关键词,长文本代价高 |
minmax跳数索引是一类很常见的选择,建它的时候只需要指定表达式和granule粒度,不需要额外参数。举个例子:
sql复制ALTER TABLE user_events
ADD INDEX idx_status_minmax status TYPE minmax GRANULARITY 4;
这个索引会按每4个granule一组记录status字段的[最小,最大]范围,查询status = 500时,如果某个分组的minmax不含500,这组granule就会被整体跳过。
set类型的跳数索引也有它的用武之地。比如一个城市ID字段,基数不高不低,建set索引后,每个索引块只需要记录这个分组里出现过的城市ID集合,查询时判断目标值是否在集合里。不过要警惕:如果字段基数太高(比如几百万个不同值),set集合会变得非常大,索引的存储和读取成本反而超过收益,这是需要实测来确认的。
3.2 布隆过滤器:高基数字段等值查询的正确解法
我最常用的是bloom_filter类型,尤其在高基数字段上,比如user_id、order_id这类,基数动辄千万级别。minmax解决不了问题,因为每个granule里都可能包含多个不同的值,范围完全没有区分度;set没法用,因为去重后的值集合太大。布隆过滤器则可以非常紧凑地表达“这个值在这一段数据里是否存在”,虽然有一定的误判率(假阳性会让系统多扫一些granule,但不会漏掉真实数据),但只要把通过率参数控制好,性能收益非常可观。
建索引的时候可以指定布隆过滤器的误判率,默认是0.025。我通常在生产环境建为0.01甚至更低,因为索引文件体积增加不多,但误判带来的额外数据扫描会明显减少。实际执行的SQL长这样:
sql复制ALTER TABLE user_events
ADD INDEX idx_user_bloom user_id TYPE bloom_filter(0.01) GRANULARITY 4;
注意,GRANULARITY表示索引每隔多少个granule取样一次。取值越小,索引越精细,但索引文件越大;取值越大,索引越粗糙,跳过效果越差。对大多数场景,4这个值是个不错的起点,如果查询命中率依然不理想,再逐步改为2甚至1。
字符型字段的模糊查询也有对应的方案。ngrambf和tokenbf都可以对文本做分词后建布隆过滤器,tokenbf按空格分词,ngrambf按固定的n元组切分。中文场景下我一般推荐ngrambf,size参数代表抽取的ngram长度,建议设置小一点(如3),因为中文的词没有天然空格分隔,n取大了会漏匹配。
3.3 跳数索引最容易被忽略的写入性能代价
跳数索引不是免费的午餐。每写入一批数据,ClickHouse都要同步更新相关索引块,这直接影响写入吞吐。跳数索引的数量过多,或者表达式特别复杂时,写入性能下降会比较明显。我之前有一个实时写入的表,为了优化查询一口气加了6个跳数索引,结果写入P99直接涨了30%以上,后来砍掉了两个使用频率很低的索引才恢复。建议是:一个表跳数索引的数量不要超过3到4个,而且一定要以真实的慢查询日志为依据来决定加什么索引,而不是凭感觉预判。
还需要注意一点:跳数索引只对新建数据生效。如果你给一张已有大量数据的表添加索引,老分区是不会自动生成索引的,需要执行优化命令触发后台合并来构建索引,或者直接重写分区。这一点非常容易被忽略,经常有人在生产环境加了索引后查询没变快,查了半天才发现老数据根本没被索引覆盖。
4. 被忽略的查询加速器:分区、物化视图与查询写法协同
索引优化做到一定程度后,你会发现单纯调索引进阶空间没那么大了。这时候要开始把目光放远一点,看分区裁剪、物化视图以及查询语句本身的写法,它们和索引是协同工作的。数据量级一旦到了TB级别,这几层的配合比单一索引更关键。
4.1 分区键设计:别让数据在开始阶段就“输”了
分区键的作用是在读数据前先按目录把不需要的分区排除掉,它是比索引更前置的过滤层。分区键选得合适,能大幅度减少扫描量;选得不好,索引就算建得再好,也可能被拖累。
最常见的误区是把时间字段做过分细粒度的切分,比如按天分区、甚至按小时分区。初看觉得很优雅,查询按天过滤时直接命中对应分区,但生产环境会出两个问题:一是分区数量爆炸,一个月的表就有30个分区,系统和合并线程维护这些分区的开销巨大;二是大量小分区会让一些优化器功能失效。
我自己的习惯是:如果数据量日增在千万级以内,按月分区就够了;如果日增上亿,按天分区。分区数量尽量控制在1000个以内,超过这个数量级,分区本身的列举和裁剪成本就会抵消掉不少查询收益。分区字段也不一定非要是时间字段,比如业务上有明确的“租户ID”强隔离需求时,按租户ID分区也能获得极好的裁剪效果。
4.2 物化视图:用空间换时间的最强手段
物化视图是ClickHouse里被严重低估的一个功能。它本质上是一张维护在后台的表,数据插入源表时,系统会把增量数据同步计算进物化视图对应的目标表里。查询时直接查目标表即可,不需要每次都对原始数据做聚合计算。
假设你有一个每天几千万行的访问日志表,业务方时时关心按小时、按页面维度的UV和PV。如果每次都从原表GROUP BY,即使索引设计得很合理,也要扫大量数据。这时候建一张物化视图:
sql复制CREATE MATERIALIZED VIEW page_stats_hourly
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(stat_hour)
ORDER BY (stat_hour, page_id)
AS SELECT
toStartOfHour(event_time) AS stat_hour,
page_id,
uniqState(user_id) AS uv_state,
count() AS pv
FROM user_events
GROUP BY stat_hour, page_id;
查询UV的时候用uniqMerge(uv_state)来合并状态,查询PV直接读pv字段就行。这种方案把预聚合的结果落到独立的物理表里,大幅减少查询计算量。
创建物化视图的时候,我最想提醒你的是:先想清楚同步机制带来的数据时效性。物化视图是基于插入触发的,如果上游数据有延迟、有补数或者大批量回刷,物化视图里的数据和源表会出现不一致,而且它不会自动修复。所以物化视图适合“数据只写入一次、基本不修改”的日志场景,不适合频繁更新迭代的维度表或订单状态频繁变化的业务表。
4.3 查询写法里的索引命中陷阱
即使索引建得很合理,查询语句写法不配合也一样白搭。我这里说的是“函数包裹索引列”和“隐式类型转换”这两个最常见的坑。
先说函数包裹。假设event_time是DateTime类型,你写了WHERE toDate(event_time) = '2024-01-01'。这个写法直接在索引列上套了一个函数,ClickHouse没法直接使用主键索引对event_time进行范围裁剪,只能在读取数据后再做转换过滤,等于索引白建。正确写法是用WHERE event_time >= '2024-01-01 00:00:00' AND event_time < '2024-01-02 00:00:00'。一个简单的习惯:过滤条件里永远保持索引列是“原样”的,所有计算都放到条件值那一侧去完成。
再说到隐式类型转换。比如user_id字段是String类型,你传了整数比较,ClickHouse会尝试自动转类型。这种转换一旦发生,很可能导致索引无法直接匹配,同样退化成扫描。这种情况在Python和Java连接数据库时特别容易碰到,因为预编译语句的参数类型没对齐。排查时可以用EXPLAIN来验证索引是否生效:
sql复制EXPLAIN indexes = 1
SELECT count()
FROM user_events
WHERE user_id = 'abc123';
如果输出中没有出现使用的索引名,或者出现了类似Read全量granule的描述,说明这个查询根本没有走到索引上。这是我每次做调优最先跑的排查命令。
4.4 一个综合案例:从50秒到0.3秒的优化过程
最后用一个实际案例把上面这些技术串起来。有一张支付流水表,数据量约20亿行,按天分区。最初的查询是统计某天某个支付渠道的失败订单量,用户反馈要跑50多秒。
第一步用EXPLAIN查看,发现查询虽然带了当天分区,但在分区内部几乎扫描了全量数据。检查表结构后确认主键是(pay_time, merchant_no),而查询条件里的渠道字段channel并没有任何索引,导致分区内部只能全扫。
第二步决策:主键里再加入channel字段并不合适,因为channel的基数低,放在主键里对排序和索引帮助不大,还会拖慢写入。所以选择给channel字段建一个minmax跳数索引,GRANULARITY设为4。
第三步,做完索引后要等待数据重新合并生成索引。因为这张表是实时写入的,历史分区需要触发OPTIMIZE重建。这一步耗时比较长,所以我直接安排了一个后台任务去处理。
最终查询从50多秒降到了0.3秒左右。整个过程中最耗时的是等OPTIMIZE跑完,但真正的性能提升来源,是跳数索引把channel字段的过滤能力补上了,使得扫描granule数量从几百降到几个。索引本身没有创造数据,它只是帮系统更快地决定“哪些数据可以不用看”。
这个案例也印证了我在做ClickHouse调优时一直坚持的思路:先用EXPLAIN定位“数据到底扫到哪里”,再决定是调主键、加跳数索引、改分区键还是上物化视图。顺序反过来,很容易做无用功。
再分享一个日常运维的小技巧:不要试图对所有查询都追求毫秒级。ClickHouse擅长的是海量数据上的分析型查询,有些查询本身需要扫描大量数据才能算出结果,这时候应该考虑物化视图做预计算,而不是拼命堆索引。索引解决的是过滤型查询,物化视图解决的是聚合型查询,两者解决的问题不同,组合使用才是完整的提速方案。
另外,每次加索引或者改表结构之前,建议先在测试环境用真实数据量验证一下效果。ClickHouse在百万行和十亿行上的表现差异极大,测试环境数据量不够,你测出来的索引效果到了生产环境可能会完全变样。我在做调优时最常用的方法就是拿生产环境一个近期分区的真实数据,复制到测试集群里模拟测试,这样得到的结论才可靠。
