初识ClickHouse 的 granule / block / bin / mrk / primary.idx

36 阅读6分钟

一、核心概念解析

1. Granule(颗粒)  - 索引的跳读单元

定义:连续的 N 行数据组成的逻辑处理单位

  • 作用:ClickHouse 最小索引粒度,决定"这组数据是否需要被扫描"

  • 默认值index_granularity = 8192

  • 关键特性

    • 与列类型、压缩方式无关,纯粹按行数划分
    • 不是物理文件,也不是压缩块,是逻辑上的"数据块"
    • 查询时按 granule 为单位进行筛选,避免全表扫描

2. Block(块)  - 压缩/解压单元

定义:Granule 内部的物理压缩单位

  • 作用:控制数据压缩和解压的粒度,优化 CPU/IO 效率

  • 特点

    • 一个 Granule 通常包含多个 Block
    • 每个 Block 独立压缩后写入 .bin 文件
    • 解压时按 Block 为单位,减少内存占用
概念作用层级
Granule索引判断:要不要读?逻辑层
Block压缩优化:怎么读更快?物理层

3.  .bin 文件 - 列数据存储

定义:每个列独立的数据文件

  • 格式{列名}.bin(如 uid.bints.bin
  • 内容:该列的所有数据,按 Block 组织并压缩存储
  • 特点:列式存储的核心,读取时只需访问相关列的文件

4.  .mrk 文件 - 偏移量映射表

定义:Granule 到 .bin 文件位置的映射索引

  • 作用:快速定位到特定 Granule 在 .bin 文件中的起始位置
  • 机制:存储每个 Granule 在 .bin 文件的字节偏移量
  • 优势:避免顺序扫描整个 .bin 文件,实现随机访问

5. primary.idx - 主键稀疏索引

定义:Granule 级别的稀疏索引文件

  • 内容:记录每个 Granule 的主键(ORDER BY 键)最小值

  • 特性

    • 常驻内存,查询时快速判断 Granule 相关性
    • 稀疏存储,只存 Granule 起始值,内存占用可控
    • 用于快速过滤不相关的 Granule

二、架构关系全景图

查询执行流程:
┌─────────────────────────────────────────────┐
│   1. primary.idx(内存)判断相关 Granule     │
│       ↓                                      │
│   2. .mrk 文件获取 .bin 偏移量              │
│       ↓                                      │
│   3. .bin 文件读取压缩数据                  │
│       ↓                                      │
│   4. 按 Block 解压数据                      │
│       ↓                                      │
│   5. 返回最终结果                           │
└─────────────────────────────────────────────┘

存储层级关系:
primary.idx(Granule索引)
       ↓
     .mrk(位置映射)
       ↓
     .bin(压缩数据)
       ↓
    Block(解压单元)
       ↓
     行数据

三、实战示例详解

示例表结构

CREATE TABLE user_events
(
    event_time DateTime,     -- 事件时间
    user_id    UInt64,       -- 用户ID
    city       String,       -- 城市
    action     String        -- 用户行为
)
ENGINE = MergeTree
ORDER BY (event_time)        -- 主键排序
SETTINGS index_granularity = 8192;  -- 每8192行一个Granule

场景:表中有 16,384 行数据

Granule 划分:

Granule 0 → 行 0    ~ 8191   (事件时间: 2024-01-01 ~ 2024-01-01 23:59)
Granule 1 → 行 8192 ~ 16383  (事件时间: 2024-01-02 ~ 2024-01-02 23:59)

1. primary.idx 索引工作流程

索引内容:

Granule编号 | event_time最小值
-----------|------------------
     0     | 2024-01-01 00:00:00
     1     | 2024-01-02 00:00:00

查询示例:

-- 查询2024年1月2日的数据
SELECT count(*) FROM user_events 
WHERE event_time >= '2024-01-02 00:00:00';

索引判断过程:

  1. 读取 primary.idx(内存中)
  2. Granule 0: 最大值 < '2024-01-02' → 跳过
  3. Granule 1: 最小值 ≥ '2024-01-02' → 需要读取
  4. 结果:只需处理 Granule 1 的 8192 行,跳过 50% 的数据

2.  .mrk 文件定位机制

假设 user_id.bin 文件布局:

文件偏移量     | 数据内容
--------------|------------------
0 ~ 119 KB    | Granule 0 的所有Block
120 KB ~ 结尾 | Granule 1 的所有Block

对应的 user_id.mrk 内容:

Granule编号 | .bin文件偏移量
-----------|---------------
     0     |      0
     1     |   120 KB

定位过程:

  1. 确定需要读取 Granule 1
  2. 查询 user_id.mrk,获取偏移量 120 KB
  3. 直接 seek(120 KB) 到指定位置
  4. 优势:避免扫描前 120 KB 无关数据

3.  .bin 文件的 Block 组织

Granule 1 的实际存储结构:

Granule 1 (8192行)
├── Block A: 2000行,压缩大小 15 KB
├── Block B: 2000行,压缩大小 14 KB  
├── Block C: 2000行,压缩大小 16 KB
└── Block D: 2192行,压缩大小 17 KB
    (总计: 62 KB 压缩数据)

Block 级别的优化:

  • 并行解压:多个 Block 可同时解压,利用多核CPU
  • 按需读取:查询部分列时只解压相关Block
  • 内存友好:避免一次性解压整个Granule
对应关系条件示例说明
多对一granule 内数据 < 64KBJavaEnable (1B)多个 granule 的标记对应同一个压缩 block
一对一granule 内数据 64KB ~ 1MBURLHash (8B)每个 granule 的标记对应一个 block
一对多granule 内数据 > 1MBURL (String)一个 granule 标记对应多个 block

四、完整查询流程示例

查询:统计特定日期各城市的用户活跃数

SELECT city, count(DISTINCT user_id) as active_users
FROM user_events
WHERE event_time BETWEEN '2024-01-02 09:00' AND '2024-01-02 17:00'
GROUP BY city;

执行步骤详解:

步骤1:主键索引过滤

1. 加载 primary.idx 到内存
2. 找到 event_time 包含 '2024-01-02' 的 Granule
3. 确定只有 Granule 1 需要处理
→ 过滤掉 50% 的 Granule

步骤2:定位数据位置

1. 读取 event_time.mrk → Granule 1 偏移量: 80 KB
2. 读取 user_id.mrk   → Granule 1 偏移量: 120 KB  
3. 读取 city.mrk      → Granule 1 偏移量: 45 KB
→ 获得各列数据的精确位置

步骤3:读取并解压数据

1. 跳转到 .bin 文件的指定偏移量
2. 按 Block 读取压缩数据:
   - event_time.bin: 读取 4个Block,共 20 KB
   - user_id.bin:   读取 4个Block,共 62 KB  
   - city.bin:      读取 4个Block,共 85 KB
3. 并行解压各个 Block
→ 只处理实际需要的压缩数据

步骤4:执行查询计算

1. 应用时间范围过滤: '2024-01-02 09:00' ~ '2024-01-02 17:00'
2. 按 city 分组,统计去重 user_id
3. 返回最终结果

五、性能优化要点

1. 索引设计最佳实践

  • 主键顺序:将高筛选度的列放在 ORDER BY 前面

  • Granule 大小:根据数据特性调整 index_granularity

    • 默认 8192 适合大多数场景
    • 查询频繁且数据量大 → 可适当增大
    • 点查询多 → 可适当减小

2. 存储优化策略

  • 列选择原则:只查询需要的列,避免全列读取
  • 压缩效果:Block 大小影响压缩率,通常 64KB~1MB 较优
  • 预排序:数据按主键有序写入,提升索引效率

3. 查询性能影响

优化前(全表扫描):
读取: 所有 Granule → 所有 .bin 文件 → 全量解压
耗时: 100%

优化后(索引跳读):
读取: 相关 Granule → 部分 .bin → 部分解压  
耗时: 20%~50%(视过滤条件)

六、总结对比

组件作用类比关键优势
Granule逻辑跳读单位书的章节快速判断相关性
primary.idx稀疏索引目录页内存常驻,过滤快
.mrk文件位置映射页码索引精确跳转,避免扫描
.bin文件列数据存储章节内容列式存储,高效压缩
Block压缩单元段落并行解压,内存友好

核心价值

  1. 减少 IO:通过索引跳过无关数据
  2. 降低 CPU:按需解压,列式处理
  3. 内存高效:稀疏索引常驻内存
  4. 查询快速:多级跳读,避免全扫描

更详细的请移步:www.cnblogs.com/traditional…