ClickHouse 索引机制学习

ClickHouse 的索引

ClickHouse 是一款开源的列式 OLAP 数据库,以”百亿字节级”数据规模下仍能保持毫秒级查询而闻名。实现这一目标的核心设计之一,就是它独特的索引体系。

与传统 RDBMS 使用 B-Tree / B+Tree 对每一行建立索引不同,ClickHouse 采用了**稀疏索引(Sparse Index)跳数索引(Data Skipping Index)**的组合方案,在海量数据场景下实现了极低的磁盘和内存开销,同时显著加速查询执行。

本文围绕以下两条主线展开深入学习:

  1. 稀疏主键索引 —— ClickHouse 如何以极小的索引体积快速定位数据
  2. 跳数索引 —— 当主键索引无法覆盖查询条件时,如何通过”摘要信息”跳过无关数据块

前置知识:MergeTree 存储模型

理解 ClickHouse 索引之前,需要先了解其底层存储结构。MergeTree 是 ClickHouse 最核心的表引擎家族,所有索引机制都建立在它之上。

Part(数据分区片段)

数据写入时,ClickHouse 将每次 INSERT 的数据生成一个独立的 Part(磁盘目录)。每个 Part 包含自己的列文件(.bin)、标记文件(.mrk2)以及主键索引文件(primary.idx)。后台线程会持续将多个 Part 合并(Merge),合并时索引也会被重新构建。

Granule(粒度块)

每个 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 值

稀疏主键索引(Sparse Primary Index)

核心思想:不索引每一行,而是索引每一组

传统数据库的 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);

PRIMARY KEY 与 ORDER BY 的关系

需要特别注意的是,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 仅用于排序,不进入索引

稀疏索引 vs B-Tree:对比总结

对比维度B-Tree 索引ClickHouse 稀疏索引
索引粒度每行一个条目每 8192 行一个条目
索引大小数百 MB(百万行级)数十 KB(百万行级)
内存需求通常无法全部常驻必须且能够完全常驻
点查询能力极强(直接定位单行)较弱(定位到 Granule)
范围查询能力良好极强(OLAP 场景的核心优势)
写入开销较高(维护树结构)极低(顺序追加)

跳数索引(Data Skipping Index)

稀疏主键索引仅对 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 行)生成一个索引条目。

跳数索引类型详解

minmax 索引

最简单也最常用的类型。它记录每个索引块内目标列的最小值和最大值。查询时,如果过滤条件的范围完全不与 [min, max] 重叠,则整个块被跳过。

适用场景: 值随时间慢慢变化的列(如温度传感器数据、单调递增的 ID、日期时间等)。如果数据分布随机,min-max 范围过大则几乎无法跳过任何块。

INDEX idx_amount amount TYPE minmax GRANULARITY 1

set 索引

记录每个索引块内目标列的去重值集合(最多存储 N 个不同值,N 为参数)。查询时检查目标值是否在集合中,不在则跳过。

适用场景: 低基数列(状态码、枚举类型、布尔标志等,不同值数 < 1000)。

INDEX idx_status status TYPE set(100) GRANULARITY 4

bloom_filter 索引

使用**布隆过滤器(Bloom Filter)**这种概率型数据结构。它可以以极小的空间开销判断某个值是否”可能存在”于某个块中。可能产生假阳性(False Positive,导致多读一些块),但不会产生假阴性(不会漏掉匹配数据)。

适用场景: 高基数列的等值查询(如 UUID、trace_id、user_id 等)。

重要限制: 布隆过滤器是概率型的,无法用于否定谓词(如 !=NOT LIKE)。

INDEX idx_trace trace_id TYPE bloom_filter(0.01) GRANULARITY 4

ngrambf_v1 索引(N-gram 布隆过滤器)

将字符串拆分为 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%';  -- 触发索引

tokenbf_v1 索引(Token 布隆过滤器)

与 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)不同值 < 1000WHERE status = 'OK'
高基数等值bloom_filterUUID / ID 类WHERE trace_id = ...
子串搜索ngrambf_v1任意子串LIKE '%timeout%'
单词搜索tokenbf_v1空格 / 符号分割hasToken(col, 'err')

实战技巧与最佳实践

主键设计原则

  1. 分析查询模式: 找出 WHERE 子句中出现频率最高的列,作为主键候选
  2. 低基数优先: 将基数低的列放在复合主键的前面,最大化过滤效率
  3. 避免过多列: 主键列太多会增加内存占用和写入开销
  4. 配合分区: PARTITION BYORDER BY 尽量使用不同列,双重过滤效果最佳

跳数索引使用注意事项

-- 验证索引效果
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);

进阶特性:Projection 作为二级索引

从 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';
MySQL 源码阅读笔记
MySQL 8.0 执行计划学习