返回首页

ClickHouse跳过索引:bloom、set、minmax

本文解释了ClickHouse中二级(跳过)索引的机制,该机制允许在搜索ORDER BY之外的列时跳过数据块。考虑了索引类型:用于范围的minmax、用于低基数的set、用于高基数字符串的bloom_filter、用于LIKE搜索的ngrambf_v1、用于按令牌全文搜索的tokenbf_v1。展示了如何通过EXPLAIN indexes=1检查索引使用情况,描述了索引无用的场景及其成本(磁盘空间、INSERT速度降低)。提供了通过IP搜索多账户以进行欺诈检测的真实示例。

ClickHouse二级(跳过)索引:完整指南
Advertisement 728x90

ClickHouse中的二级(跳跃)索引:当ORDER BY索引不够用时

1. 跳跃索引的工作原理——跳过数据块

在传统数据库(PostgreSQL、MySQL)中,索引是一种精确指向满足条件的行的结构。B树会说:“值 user_id = 123 在第45678行”。

在ClickHouse中,主索引(通过ORDER BY的稀疏索引)工作方式不同。它仅存储每8192行(颗粒)的值,并能高效地剪枝那些在ORDER BY中靠前列的整个数据块

但如果你需要搜索一个不在ORDER BY中的列呢?例如,你想查找具有特定IP地址的所有投注,但你的ORDER BY是 (user_id, created_at)。ClickHouse将不得不读取所有颗粒,并在读取后过滤IP。这称为全表扫描。

Google AdInline article slot

二级(跳跃)索引解决了这个问题。它们不指向特定行,而是说:“在这个N个颗粒的块中,肯定没有这个值——你可以跳过它。”如果索引说“可能”——ClickHouse仍然会读取该块。

现实类比: 想象你在图书馆里找一本绿色封面的书。主索引(按作者姓氏的目录)帮不上忙。但你走过书架,快速扫一眼:“这个书架上的书全是蓝色的——跳过。这个书架上有绿色的——我来检查。”跳跃索引就像颜色编码的书架,而不是精确的指针。

为什么叫跳跃? 因为索引的主要工作是跳过肯定不需要的块。跳过的块越多,查询越快。

Google AdInline article slot

重要限制: 跳跃索引仅在颗粒级别工作。它们无法找到颗粒内的确切行位置。因此,当所需值很少见(低选择性)时,索引才有用。如果80%的行匹配条件——你仍然需要读取所有内容。

2. INDEX ... TYPE minmax —— 用于范围查询

最简单的跳跃索引是 minmax。它存储每个颗粒组中某列的最小值和最大值。

CREATE TABLE player_events
(
    user_id     UInt64,
    event_time  DateTime,
    amount      Decimal(18,2),
    outcome     String      -- 'win', 'loss', 'push'
)
ENGINE = MergeTree()
ORDER BY (user_id, event_time)              -- 主排序
INDEX idx_outcome_minmax outcome TYPE minmax GRANULARITY 4;

参数解析:

Google AdInline article slot
  • INDEX idx_outcome_minmax —— 索引名称(可自定义,但要有意义)。
  • outcome —— 建立索引的列。
  • TYPE minmax —— 索引类型:存储一组颗粒中的最小值和最大值。
  • GRANULARITY 4 —— 多少个颗粒(每个8192行)组合成一个索引组。这里4 × 8192 = 32768行每个索引条目。

查询中的工作方式:

-- 查找特定结果的记录
SELECT * FROM player_events 
WHERE outcome = 'win' AND event_time >= '2025-06-01';

ClickHouse读取 idx_outcome_minmax 索引:

  • 组1:min='loss', max='push' → 没有'win' → 跳过32768行。
  • 组2:min='loss', max='win' → 包含'win' → 读取该组。
  • 组3:min='win', max='win' → 只有'win' → 读取。

minmax何时有效:

  • 单调变化的列(时间、ID、温度)。
  • 唯一值少但分布不均匀的列。
  • 范围查询(BETWEEN>=<=)。

何时无用:

  • 随机值(如哈希、UUID)。最小值和最大值将覆盖整个范围,索引不会跳过任何内容。

3. INDEX ... TYPE set —— 用于低基数列上的等值查询

set 索引存储一组颗粒中的唯一值。如果搜索的值不在这个集合中——跳过该组。

CREATE TABLE bets
(
    user_id     UInt64,
    sport_id    UInt8,      -- 只有20种运动
    amount      Decimal(18,2),
    created_at  DateTime
)
ENGINE = MergeTree()
ORDER BY (created_at, user_id)
INDEX idx_sport sport_id TYPE set(10) GRANULARITY 2;

参数:

  • set(10) —— 索引为每个组存储的最大唯一值数量。如果一个组包含超过10个唯一的sport_id值,索引只记住10个(可能导致误报)。选择一个略大于预期列基数的数字。

工作方式:

-- 查询特定运动
SELECT sum(amount) FROM bets WHERE sport_id = 1;

idx_sport 索引知道每个颗粒组中出现了哪些sport_id值。如果一个组不包含sport_id=1——跳过整个组。如果包含——读取它。

set何时有效:

  • 列基数(最多几百个值)。
  • 等值查询(=IN)。
  • 数据在颗粒内分组良好(例如,一小时内所有足球投注紧凑存储)。

博彩示例: 一个投注表,ORDER BY (created_at, user_id)。列 sport_id(20个值)不在ORDER BY中。在 sport_id 上建立set索引可以快速找到所有冰球投注,而无需扫描所有内容。

4. bloom_filter 索引 —— 用于高基数字符串列

布隆过滤器是一种概率数据结构。它可以判断“该值肯定不在组中”或“该值可能存在”。它从不说“肯定存在”——它只能出现误报。

CREATE TABLE player_events
(
    user_id     UInt64,
    ip_address  String,          -- 数百万个唯一IP
    event_type  String,
    created_at  DateTime
)
ENGINE = MergeTree()
ORDER BY (user_id, created_at)
INDEX idx_ip ip_address TYPE bloom_filter(0.01) GRANULARITY 3;

参数:

  • bloom_filter(0.01) —— 误报率为1%。数字越小,索引越精确,但占用更多空间。通常使用0.01(1%)或0.001(0.1%)。
  • GRANULARITY 3 —— 每个索引条目3个颗粒(3 × 8192 = 24576行)。

工作方式:

-- 查找来自可疑IP的所有事件
SELECT * FROM player_events WHERE ip_address = '192.168.1.100';

每个颗粒组的索引通过布隆过滤器检查:“这个组可能包含IP=192.168.1.100吗?”如果“否”——跳过该组。如果“是”(包括误报)——读取该组。

布隆过滤器何时有效:

  • 高基数列(IP地址、电子邮件、user_agent)。
  • 精确匹配查询。
  • 搜索的值很少见(例如,1000万个IP中的特定一个)。

为什么minmax不适合IP: 由于随机分布,组中的最小和最大IP几乎覆盖整个范围,因此剪枝无效。

实际示例——多账户检测(一个IP,多个user_id):

-- 查找来自给定IP的所有用户
SELECT DISTINCT user_id FROM player_events 
WHERE ip_address = '192.168.1.100';

没有索引——全表扫描。使用 ip_address 上的布隆过滤器——快速,即使该IP出现在0.1%的行中。

5. ngrambf_v1 —— 用于字符串的LIKE/ILIKE搜索

有时你需要按子串搜索:WHERE player_name LIKE '%John%'。常规索引不起作用,因为开头的 % 阻止了B树的使用。

ngrambf_v1 将字符串拆分为n-gram——长度为N的子串。例如,对于N=3,'Johny''Joh''ohn''hny'。索引在这些n-gram上构建布隆过滤器。

CREATE TABLE players
(
    player_id   UInt64,
    player_name String,
    country     String
)
ENGINE = MergeTree()
ORDER BY player_id
INDEX idx_name player_name TYPE ngrambf_v1(3, 500000, 2, 0.01) GRANULARITY 4;

ngrambf_v1的参数:

  • 3 —— n-gram长度(通常2-4)。越大越精确,但占用更多内存。
  • 500000 —— 每个索引条目的布隆过滤器大小(字节)。
  • 2 —— 哈希函数数量(通常2-4)。
  • 0.01 —— 误报概率。

如何在查询中使用:

-- 查找名字包含'Alex'的玩家
SELECT * FROM players WHERE player_name LIKE '%Alex%';

索引将 'Alex' 拆分为n-gram('Ale''lex'),并检查这些n-gram是否存在于组中。如果某个组没有这些n-gram——跳过该组。

限制:

  • 仅适用于 LIKEILIKE(不区分大小写)。
  • 要求搜索字符串长于n-gram(至少3个字符)。
  • 不适用于短字符串(例如 'a')。

何时使用: 按玩家昵称、部分电子邮件、地址搜索。在博彩中——通过玩家名字的一部分查找玩家以进行客户支持。

6. tokenbf_v1 —— 用于令牌(单词)搜索

tokenbf_v1 类似于 ngrambf_v1,但将字符串拆分为令牌——由空格、标点、数字分隔的单词。

CREATE TABLE logs
(
    log_time    DateTime,
    message     String,
    user_agent  String
)
ENGINE = MergeTree()
ORDER BY log_time
INDEX idx_msg message TYPE tokenbf_v1(500000, 2, 0.01) GRANULARITY 2;

tokenbf_v1的参数:

  • 500000 —— 布隆过滤器大小(字节)。
  • 2 —— 哈希函数数量。
  • 0.01 —— 误报概率。

工作方式:

对于字符串 "User 123 logged in from Ukraine",令牌:'User''123''logged''in''from''Ukraine'

-- 查找所有提及错误的日志
SELECT * FROM logs WHERE message LIKE '%error%';

索引将 'error' 拆分为令牌(仅 'error'),并检查该令牌在组中是否存在。

tokenbf_v1何时优于ngrambf_v1:

  • 搜索整个单词(而非部分)。
  • 英文文本、日志、user_agent。
  • 误报比ngrambf_v1少。

博彩示例: 搜索包含 'fraud''suspicious' 消息的投注日志。

7. 如何检查索引是否被使用 —— EXPLAIN indexes=1

你创建了索引,但它工作吗?ClickHouse提供了 EXPLAIN indexes = 1 命令。

-- 启用索引使用分析
EXPLAIN indexes = 1
SELECT user_id, amount FROM bets 
WHERE sport_id = 1 AND created_at >= '2025-06-01';

示例输出:

Expression
  ...
  ReadFromMergeTree
    Indexes:
      PrimaryKey
        Condition: (created_at >= '2025-06-01')
        Used keys: (created_at)
        Granules: 150 / 12000
      Skip
        Name: idx_sport
        Type: set
        Condition: sport_id = 1
        Granules: 80 / 12000

数字含义:

  • Granules: 150 / 12000 —— 主键剪枝了11850个颗粒,剩余150个。
  • Skip ... Granules: 80 / 150 —— 跳跃索引进一步剪枝了70个颗粒,剩余80个。
  • 最终收益:12000 → 80个颗粒被读取。

如果索引未被使用:

  • 未显示在 Skip 部分 → 要么未创建,要么查询与索引类型不匹配。
  • Granules: 12000 / 12000 —— 读取所有内容,索引未起作用。

索引可能未被使用的原因:

  • 索引类型与操作符不匹配(minmax= 无效)。
  • 颗粒度太大(粗粒度索引)。
  • 搜索的值几乎无处不在(索引无法跳过块)。

8. 跳跃索引何时无帮助

场景1:高基数 + 随机分布

如果列 user_id(数百万个值)且ORDER BY不以 user_id 开头,跳跃索引(即使是布隆过滤器)剪枝效果差。因为值 user_id=123 可能分散在整个表中。

场景2:查询未过滤“好”列

如果WHERE只有 amount > 1000amount 上没有索引,则 sport_id 上的索引无帮助。

场景3:GRANULARITY 太大

如果 GRANULARITY = 64(每组524k行),且表有1000万行,则只有约20个组。你只能跳过20个块,微不足道。

场景4:搜索的值出现在50%+的行中

跳跃索引适用于稀有值。如果一半的行匹配条件,索引几乎对所有块都说“可能”,你将读取所有内容。

场景5:索引太小

-- 不好:布隆过滤器太小(10000字节)
INDEX idx_ip ip_address TYPE bloom_filter(0.01) GRANULARITY 4;

小的布隆过滤器会产生许多误报(经常在不是时也说“可能”)。索引停止跳过块。

9. 跳跃索引的成本 —— 内存和插入速度

每个索引都有成本。不要“以防万一”创建索引。

成本1:额外的磁盘空间

  • minmax —— 非常便宜(每个组每列8字节)。
  • set(100) —— 更贵,但每个组几千字节。
  • bloom_filter —— 昂贵:500k字节且GRANULARITY=1时,对于有10k个组的表,仅索引就需要5 GB。

成本2:插入变慢

每次插入时,ClickHouse为每个颗粒更新所有索引。表上的5个索引可能使插入速度减慢2-3倍。

经验法则:

  • 大型表(数十亿行)上不超过2-3个跳跃索引。
  • 仅对经常过滤的列建立索引。
  • 对于测试工作负载——实验。对于生产——测量。

如何估算索引成本:

-- 检查表中的索引大小
SELECT 
    table,
    index_name,
    formatReadableSize(index_size) AS size
FROM system.indexes
WHERE table = 'bets';

如果索引大小接近数据大小——你可能过度了。

10. 实际示例:通过IP地址进行欺诈检测

想象在你的赌场中,一群玩家使用一个IP地址进行多账户操作(违反规则)。你需要找到所有从可疑IP登录的人。

事件表:

  • 5亿行。
  • ORDER BY = (user_id, event_time) —— 按用户快速查询。
  • 频繁查询:SELECT user_id FROM events WHERE ip_address = 'x.x.x.x'

解决方案 —— bloom_filter 索引:

CREATE TABLE player_events
(
    user_id     UInt64,
    event_time  DateTime,
    ip_address  String,
    event_type  String,   -- 'login', 'bet', 'withdraw'
    amount      Decimal(18,2)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (user_id, event_time)
INDEX idx_ip ip_address TYPE bloom_filter(0.01) GRANULARITY 4;

性能对比:

场景 无索引 使用bloom_filter (0.01)
稀有IP查询时间(0.001%行) 60秒(全表扫描5亿) 0.3秒
频繁IP查询时间(5%行) 60秒 45秒(索引帮助不大)
表大小(压缩后) 100 GB 108 GB(+8%)
插入时间(1万行/秒) 每批0.5毫秒 每批0.7毫秒(+40%)

如何编写反欺诈查询:

-- 查找所有使用过可疑IP的用户
SELECT DISTINCT user_id 
FROM player_events 
WHERE ip_address = '192.168.1.100'   -- bloom_filter帮助
AND event_time >= today() - 30;      -- 分区剪枝旧数据

-- 然后检查有多少不同账户使用这个IP
SELECT count(DISTINCT user_id) AS suspicious_accounts
FROM player_events 
WHERE ip_address = '192.168.1.100';

为什么用bloom_filter而不是minmax:

  • IP地址随机分布;组中的最小/最大几乎总是覆盖整个范围。
  • 布隆过滤器非常适合集合成员检查。

下一步

现在你已经了解了ClickHouse所有类型的二级索引。接下来的主题:

  • 组合索引 —— 多个跳跃索引如何协同工作。
  • 调整颗粒度 —— 如何为不同数据类型选择最佳颗粒大小。
  • 分布式表中的索引 —— 跳跃索引在集群中如何工作。

总结: ClickHouse中的跳跃索引不是银弹。它们不像PostgreSQL中的B树那样工作。但对于正确的场景(稀有值、布隆过滤器、n-gram),它们能将全表扫描变成闪电般的查询。关键规则:

  • 在发现问题(全表扫描)之前不要创建索引。
  • 从高基数列的bloom_filter开始,低基数列用set。
  • 始终使用 EXPLAIN indexes = 1 检查。
  • 记住成本:磁盘空间 + 插入变慢。

上一篇:

— Editorial Team

Advertisement 728x90

继续阅读