ClickHouse 是一款开源的列式 OLAP 数据库,以”百亿字节级”数据规模下仍能保持毫秒级查询而闻名。实现这一目标的核心设计之一,就是它独特的索引体系。
与传统 RDBMS 使用 B-Tree / B+Tree 对每一行建立索引不同,ClickHouse 采用了**稀疏索引(Sparse Index)和跳数索引(Data Skipping Index)**的组合方案,在海量数据场景下实现了极低的磁盘和内存开销,同时显著加速查询执行。
本文围绕以下两条主线展开深入学习:
理解 ClickHouse 索引之前,需要先了解其底层存储结构。MergeTree 是 ClickHouse 最核心的表引擎家族,所有索引机制都建立在它之上。
数据写入时,ClickHouse 将每次 INSERT 的数据生成一个独立的 Part(磁盘目录)。每个 Part 包含自己的列文件(.bin)、标记文件(.mrk2)以及主键索引文件(primary.idx)。后台线程会持续将多个 Part 合并(Merge),合并时索引也会被重新构建。
每个 Part 内的数据按 ORDER BY 指定的列物理排序存储,然后被逻辑地划分为多个 Granule(粒度块)。默认情况下,每个 Granule 包含 8192 行(由 index_granularity 参数控制)。Granule 是 ClickHouse 读取数据的最小不可分割单元,也是索引的基本操作单位。
此外,ClickHouse 还支持自适应粒度(Adaptive Granularity):当某组行的总字节数达到 index_granularity_bytes(默认 10 MB)时,即使行数未达 8192 也会提前切分,确保每个 Granule 在内存中的大小可控。
| 文件 | 格式 | 说明 |
|---|---|---|
primary.idx | 二进制 | 主键稀疏索引,每个 Granule 一个条目 |
[col].bin | 压缩列数据 | 每列一个文件(Wide 格式) |
[col].mrk2 | 标记文件 | Granule 在 .bin 中的偏移量指针 |
skp_idx_*.idx | 二进制 | 跳数索引数据 |
partition.dat | 元数据 | 分区键的 MinMax 值 |
传统数据库的 B-Tree 索引会为表中的每一行创建一个索引条目。假设一张表有 887 万行,B-Tree 索引将产生数百 MB 的索引数据。而 ClickHouse 的稀疏索引只为每 8192 行(一个 Granule)创建一个索引条目,即只记录每个 Granule 的第一行的主键列值。同样 887 万行的表,稀疏索引只需约 1083 个条目(约 97 KB),可以完全常驻内存,即使是 PB 级表也同样适用。
第一阶段 —— Granule 筛选(Granule Selection)
当查询包含 WHERE 条件时,ClickHouse 对 primary.idx 执行二分查找,快速定位可能包含匹配行的 Granule。由于数据已按 ORDER BY 列物理排序,这个过程的时间复杂度为 O(log₂ n),其中 n 是索引条目数。
第二阶段 —— 数据读取(Data Reading)
ClickHouse 通过 .mrk2 标记文件定位到 .bin 列文件中对应 Granule 的物理位置,然后并行流式读取这些 Granule,在内存中进一步过滤出精确匹配的行。其余无关的 Granule(可能占数据总量的 99%+)则被完全跳过。
ClickHouse 支持复合主键,例如 ORDER BY (user_id, timestamp)。索引中会存储每个 Granule 第一行的所有主键列值。查询时的一个重要规则是:
-- 推荐:低基数列在前,高基数列在后
CREATE TABLE events (
event_date Date,
user_id UInt32,
session_id UUID,
event_type String
) ENGINE = MergeTree
ORDER BY (event_date, user_id, session_id);
需要特别注意的是,ClickHouse 中的 PRIMARY KEY 不代表唯一性约束,它仅定义稀疏索引的构建列。如果同时指定了 PRIMARY KEY 和 ORDER BY,则 PRIMARY KEY 必须是 ORDER BY 的前缀。这样做的好处是:PRIMARY KEY 可以只包含常用的过滤列以减少内存开销,而 ORDER BY 可以包含更多列用于精细控制磁盘排序。
CREATE TABLE example (
project_id UInt32,
name String,
created_date Date
) ENGINE = MergeTree
PRIMARY KEY (created_date, project_id)
ORDER BY (created_date, project_id, name);
-- created_date, project_id 用于索引 + 排序
-- name 仅用于排序,不进入索引
| 对比维度 | B-Tree 索引 | ClickHouse 稀疏索引 |
|---|---|---|
| 索引粒度 | 每行一个条目 | 每 8192 行一个条目 |
| 索引大小 | 数百 MB(百万行级) | 数十 KB(百万行级) |
| 内存需求 | 通常无法全部常驻 | 必须且能够完全常驻 |
| 点查询能力 | 极强(直接定位单行) | 较弱(定位到 Granule) |
| 范围查询能力 | 良好 | 极强(OLAP 场景的核心优势) |
| 写入开销 | 较高(维护树结构) | 极低(顺序追加) |
稀疏主键索引仅对 ORDER BY 列有效。当查询的 WHERE 条件涉及非主键列时,ClickHouse 提供了**跳数索引(Data Skipping Index)**作为补充。它的核心思想是:为每个 Granule(或多个 Granule 组成的块)存储一份”摘要信息”,查询时先检查摘要,确定某个块不可能包含匹配数据后直接跳过。
一个生动的类比:如果主键索引像字典页眉的字母指引,那么跳数索引就像百科全书每一页底部的主题标签——你仍然需要翻阅页面,但可以快速跳过与目标主题无关的页。
INDEX index_name expr TYPE type GRANULARITY granularity_value
-- 示例:为 amount 列创建 minmax 索引
CREATE TABLE orders (
order_id UInt64,
amount Decimal(10,2),
status String,
INDEX idx_amount amount TYPE minmax GRANULARITY 1
) ENGINE = MergeTree
ORDER BY order_id;
其中 GRANULARITY 表示跳数索引的块大小(以 Granule 为单位)。例如 GRANULARITY 3 表示每 3 个 Granule(24576 行)生成一个索引条目。
最简单也最常用的类型。它记录每个索引块内目标列的最小值和最大值。查询时,如果过滤条件的范围完全不与 [min, max] 重叠,则整个块被跳过。
适用场景: 值随时间慢慢变化的列(如温度传感器数据、单调递增的 ID、日期时间等)。如果数据分布随机,min-max 范围过大则几乎无法跳过任何块。
INDEX idx_amount amount TYPE minmax GRANULARITY 1
记录每个索引块内目标列的去重值集合(最多存储 N 个不同值,N 为参数)。查询时检查目标值是否在集合中,不在则跳过。
适用场景: 低基数列(状态码、枚举类型、布尔标志等,不同值数 < 1000)。
INDEX idx_status status TYPE set(100) GRANULARITY 4
使用**布隆过滤器(Bloom Filter)**这种概率型数据结构。它可以以极小的空间开销判断某个值是否”可能存在”于某个块中。可能产生假阳性(False Positive,导致多读一些块),但不会产生假阴性(不会漏掉匹配数据)。
适用场景: 高基数列的等值查询(如 UUID、trace_id、user_id 等)。
重要限制: 布隆过滤器是概率型的,无法用于否定谓词(如 != 或 NOT LIKE)。
INDEX idx_trace trace_id TYPE bloom_filter(0.01) GRANULARITY 4
将字符串拆分为 n 个字符的重叠子串(N-gram)后存入布隆过滤器。查询时,搜索字符串也被拆分为相同的 N-gram,逐一在布隆过滤器中查找。
适用场景: 子串模糊搜索(LIKE '%keyword%')。注意搜索字符串的长度必须 >= n,否则索引无法生效。
-- ngrambf_v1(n, bloom_filter_size, hash_count, seed)
INDEX idx_msg message TYPE ngrambf_v1(3, 10000, 3, 7) GRANULARITY 1
-- 查询示例
SELECT count() FROM logs
WHERE message LIKE '%timeout%'; -- 触发索引
与 ngrambf_v1 类似,但它是将字符串按非字母数字字符分割为 Token(单词)后存入布隆过滤器。适用于单词级别的精确匹配搜索,而非子串搜索。
适用场景: URL 中的某个路径段、日志中的特定关键词(配合 hasToken 函数使用)。
INDEX idx_url url TYPE tokenbf_v1(10000, 3, 0) GRANULARITY 1
-- 查询示例
SELECT * FROM logs
WHERE hasToken(url, 'api/v2/users');
| 场景 | 推荐类型 | 数据特征 | 示例 |
|---|---|---|---|
| 数值范围查询 | minmax | 值有序 / 单调 | WHERE amount > 100 |
| 低基数等值 | set(N) | 不同值 < 1000 | WHERE status = 'OK' |
| 高基数等值 | bloom_filter | UUID / ID 类 | WHERE trace_id = ... |
| 子串搜索 | ngrambf_v1 | 任意子串 | LIKE '%timeout%' |
| 单词搜索 | tokenbf_v1 | 空格 / 符号分割 | hasToken(col, 'err') |
PARTITION BY 与 ORDER BY 尽量使用不同列,双重过滤效果最佳ALTER TABLE ADD INDEX 只对新数据生效,已有数据需执行 MATERIALIZE INDEX!=、NOT LIKE 等谓词不会触发索引EXPLAIN indexes = 1 查看查询实际过滤了多少 Granule,确认索引是否真正生效-- 验证索引效果
EXPLAIN indexes = 1
SELECT * FROM logs
WHERE trace_id = 'abc-123';
-- 输出中关注 Skip 部分:
-- Skip
-- Name: idx_trace
-- Parts: 1/6
-- Granules: 4/10004 ← 跳过了 99.96% 的数据
CREATE TABLE app_logs (
timestamp DateTime,
level LowCardinality(String),
service LowCardinality(String),
trace_id String,
user_id UInt64,
message String,
error_code Nullable(UInt32),
-- 跳数索引:针对不同列特征选择不同类型
INDEX idx_level level TYPE set(10) GRANULARITY 4,
INDEX idx_service service TYPE set(100) GRANULARITY 4,
INDEX idx_trace trace_id TYPE bloom_filter(0.01) GRANULARITY 4,
INDEX idx_user user_id TYPE bloom_filter(0.01) GRANULARITY 4,
INDEX idx_msg message TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 4,
INDEX idx_err error_code TYPE minmax GRANULARITY 4
) ENGINE = MergeTree
PARTITION BY toYYYYMM(timestamp)
ORDER BY (service, timestamp);
从 ClickHouse 25.6 版本开始,Projection 可以创建仅存储排序键和 _part_offset 的轻量级副本,其主键索引可以像真正的二级索引一样工作,支持 Granule 级别的裁剪。这意味着一张表现在可以拥有多个稀疏索引,每个都能参与查询优化。
-- 创建轻量级 Projection(仅存储排序键 + 偏移)
ALTER TABLE uk_price_paid
ADD PROJECTION by_town (
SELECT * ORDER BY town
);
-- 查询时自动使用 Projection 的索引
SELECT * FROM uk_price_paid
WHERE town = 'LONDON';