ClickHouse 稀疏索引机制剖析:为什么 8192 行一个 Mark 刚刚好
2026/9/4 22:43:06 网站建设 项目流程

ClickHouse 稀疏索引机制剖析:为什么 8192 行一个 Mark 刚刚好

在关系型数据库(如 MySQL、PostgreSQL)的世界里,索引通常是密集型索引(Dense Index)——主键 B+ 树的每一个叶子节点精确对应物理表中的单行记录。如果表中有一亿行数据,B+ 树就必须索引一亿个主键值,导致索引文件本身的体积动辄达到几十 GB,甚至超过物理内存大小。

而在分析型数据库 ClickHouse 中,其核心存储引擎 MergeTree 采用了一种截然不同的设计——稀疏主键索引(Sparse Primary Index)

在 ClickHouse 的默认建表配置中,你会看到一个关键参数:index_granularity = 8192。这意味着 ClickHouse每隔整整 8192 行数据,才在主键索引文件(primary.cidx)中抽取并记录一个主键值(称为一个 Mark / 标记)

为什么是 8192?为什么不设成更精细的 128,或者更粗颗粒度的 10 万?深入这个看似随机的数字,能带我们看透现代列存引擎在内存空间、CPU 缓存与磁盘 IO 之间登峰造极的工程妥协。

[ClickHouse 稀疏索引与物理数据文件映射关系] 物理数据流 (每 8192 行划分为一个 Granule / 颗粒): Row 0 ~ 8191 Row 8192 ~ 16383 Row 16384 ~ 24575 ┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐ │ Granule 0 │ │ Granule 1 │ │ Granule 2 │ └────────────────────┘ └────────────────────┘ └────────────────────┘ ▲ ▲ ▲ │ │ │ Mark 0 (UserID: 100) Mark 1 (UserID: 5400) Mark 2 (UserID: 9200) └──────────────────────┴──────────────────────┴────────────────────┘ primary.cidx (稀疏主键索引:仅记录每个 Mark 的主键值与压缩块偏移)

极小内存常驻:将数十亿数据的索引塞进 CPU L3 Cache

ClickHouse 稀疏索引设计的第一个核心诉求,是让全量主键索引 100% 永久常驻在内存甚至 CPU 高速缓存中

做一笔极其简单的数学算术:

  • 假设我们有一张单表包含100 亿行(10 Billion)数据的超大海量日志表;
  • 主键为一个 8 字节的UInt64时间戳或用户 ID;
  • 按照index_granularity = 8192进行稀疏抽样,100 亿行数据总共只会产生:
    $$\text{Marks Count} = \frac{10,000,000,000}{8192} \approx 1,220,703 \text{ 个索引项}$$
  • 这 122 万个索引项占用的总物理内存体积仅仅为:
    $$1,220,703 \times 8 \text{ bytes} \approx 9.76 \text{ MB} !$$

百亿级的大表,其主键索引总共只需要不到10MB 内存
这意味着无论是 10 亿行还是 100 亿行数据,ClickHouse 在启动时可以一次性将所有分区的稀疏索引无脑加载进内存,甚至可以直接常驻在现代 CPU 几十 MB 的 L3 Cache 中。在进行范围过滤时,二分查找索引项只需要在 CPU 缓存内部进行微秒级运算,完全不需要发生任何一次磁盘读取

为什么不能设得更小(如 128 或 512)?

如果把index_granularity改成 128:

  1. 索引体积膨胀 64 倍:原本 10MB 的索引膨胀到 640MB,在包含上千张表的大集群中,索引元数据将吃掉上百 GB 内存,引发内存膨胀与 GC 停顿;
  2. Mark 元数据文件(.mrk)爆炸:每个列的标记文件体积剧增,在多列宽表下,元数据寻址本身的开销甚至超过了数据读取。

为什么不能设得更大(如 100,000)?

ClickHouse 读取磁盘数据的最小物理单元是Granule(颗粒)
在列存压缩文件中,一整个 Granule(8192 行)作为一个连续的数据块进行 LZ4/ZSTD 压缩。

当用户发起一个点查WHERE user_id = 8848时:

  • 稀疏索引通过二分查找定位到该user_id落在第 14 号 Mark 与第 15 号 Mark 之间;
  • 执行引擎必须将第 14 号 Mark 对应的整整 8192 行数据从磁盘拉入内存并解压,然后在内存中进行单核向量化扫表过滤。

如果把粒度设为 100,000,为了找一条记录,系统必须被迫从磁盘拉取并解压 10 万行无用数据,造成巨大的IO 读放大(Read Amplification)

// ClickHouse 源码中 Granule 二分范围裁剪核心逻辑 (MergeTreeIndexReader.cpp) bool check_granule_may_contain_keys( const MarkRanges & all_ranges, const KeyTuple & min_key, const KeyTuple & max_key) { // 利用常驻内存的 primary.cidx 进行纯 CPU 二分查找裁剪 // 裁剪掉所有主键范围确定不重叠的 Granule // 过滤完成后,仅将命中极少数 Granule 的偏移量加入磁盘读取任务队列 return binary_search_mark_ranges(all_ranges, min_key, max_key); }

8192 与 SIMD 批处理与压缩块的硬件对齐

选择 8192 的最深层原因,是它完美契合了现代硬件与压缩算法的黄金吞吐区间:

  1. AVX-512 向量化对齐:8192 是 512 的整数倍($8192 = 16 \times 512$),也是 64 字节 Cache Line 的整数倍,算子在处理内存数组时可以无缝进行循环展开与内存地址对齐;
  2. LZ4 / ZSTD 最佳压缩窗口:对于大多数 8 字节数值列,8192 行数据解压后体积约为 64KB($8192 \times 8 = 65,536 \text{ bytes}$),这刚好与现代 CPU 的 L1/L2 Cache 尺寸高度吻合,且构成了 LZ4 算法压缩效率与解压速度最高的最优 Block 尺寸。

8192 不是拍脑袋定下来的经验值,而是列式存储在极致压榨 CPU 缓存、限制索引内存占用与最小化磁盘读放大之间计算出的绝妙黄金交点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询