接下来,我们从“写入时发生了什么” → “查询时是如何一步步跳过数据的”,把
分区索引、一级索引、二级(跳数)索引、数据标记(marks) 串成一条完整执行链。

看完这条链,你就会明白:
为什么 ORDER BY 决定一切,为什么二级索引救不了表设计。


一、先给一张“总览图”(心智模型)

┌─────────────── 查询开始 ───────────────┐
│  WHERE 条件                          │
└───────────────┬─────────────────────┘
                ▼
        【分区索引】(Partition)
                ▼
        【一级索引】(Primary Key / ORDER BY)
                ▼
        【二级索引】(Skipping Index)
                ▼
        【数据标记】(Marks → 定位磁盘位置)
                ▼
        读取列文件 → 解压 → 计算

越靠上,越“便宜”
越靠下,越“贵”


二、写入时:这些东西是怎么“生成”的?

我们用一个典型表来说:

CREATE TABLE t
(
    dt Date,
    user_id UInt64,
    status String,
    amount Float64
)
ENGINE = MergeTree
PARTITION BY dt
ORDER BY (dt, user_id)
INDEX idx_status status TYPE set(10) GRANULARITY 4
SETTINGS index_granularity = 8192;

1️⃣ 分区索引(Partition)

写入时发生什么?

  • 根据 PARTITION BY dt
  • 每一行数据被路由到 某一个 partition
  • partition 是 物理目录
/dt=2025-01-01/
/dt=2025-01-02/

📌 特点:

  • 分区 = 最大粒度的剪枝
  • 完全不读数据

2️⃣ 一级索引(Primary Key / ORDER BY)

写入时发生什么?

  • 在 part 内按 ORDER BY 排序

  • 每 8192 行形成一个 granule

  • 对每个 granule:

    • 记录 (dt, user_id) 的 min / max

👉 形成 稀疏索引文件

granule_1: dt=[2025-01-01,2025-01-01], user_id=[1,100]
granule_2: dt=[2025-01-01,2025-01-01], user_id=[101,200]

📌 这一步 最重要:

ORDER BY 决定了「数据在磁盘上的物理布局」


3️⃣ 二级索引(跳数索引)

写入时发生什么?

  • 不改变数据顺序

  • 每 N 个 granule(GRANULARITY):

    • 统计一份“摘要信息”

比如:

INDEX idx_status status TYPE set(10) GRANULARITY 4

👉 每 4 × 8192 = 32768 行
👉 记录 status 的 distinct set

block_1: {'PAID','CANCEL'}
block_2: {'PAID'}

4️⃣ 数据标记(Marks)

写入时发生什么?

  • 每个 granule:

    • 记录该 granule 在各列文件中的 offset
  • marks 文件非常小

👉 marks = 磁盘 seek 的地图


三、读的时候:真正的“跳过”是怎么发生的?

我们来看一条典型查询:

SELECT sum(amount)
FROM t
WHERE dt = '2025-01-01'
  AND status = 'PAID'
  AND user_id BETWEEN 1000 AND 2000;

Step 1️⃣ 分区索引剪枝(最便宜)

只扫描 dt=2025-01-01 分区
  • 直接跳过其他 partition
  • 不读任何数据文件

⚡ O(1) 级别


Step 2️⃣ 一级索引过滤(决定性步骤)

ORDER BY (dt, user_id)
  • 根据 dt + user_id 的 min/max
  • 判断哪些 granule 不可能命中

👉 大量 granule 被直接排除
👉 这些 granule 连 mark 都不会读

⚡ 最核心的性能来源


Step 3️⃣ 二级索引再筛(锦上添花)

检查 idx_status:
如果 block 的 status set 不包含 'PAID'
→ 整个 block 丢弃

📌 注意:

  • 只要 block 内 有 1 行命中
  • 整个 block 都要读

👉 它只能 减少读,不保证不读


Step 4️⃣ Marks 定位 + 读列数据

对于剩余 granule:

  1. 根据 marks 定位 offset

  2. 只读涉及的列:

    • amount
    • status(可能)
  3. 解压

  4. 计算


四、它们之间的“配合关系”(重点)

组件决定什么能否彻底跳过
分区索引读不读这个 partition✅
一级索引读不读这个 granule✅
二级索引granule 里有没有可能命中❌
marks从哪开始读❌

只有前两层,是真正的“不读磁盘”


五、为什么说「ORDER BY 设计错了,后面全白搭」?

❌ 错误示例:

PARTITION BY dt
ORDER BY id
INDEX idx_user user_id TYPE bloom_filter
WHERE dt = '2025-01-01'
  AND user_id = 123;

结果:

  • 分区 OK
  • 一级索引完全用不上
  • 二级索引命中率低
  • 大量 granule 被读

📉 性能靠“赌”


六、工程级设计准则(你可以直接用)

1️⃣ 分区

  • 时间维度
  • 控制在 100~1000 个以内

2️⃣ 一级索引(ORDER BY)

  • 最常用过滤条件
  • 范围查询优先
  • 高选择性字段靠前

3️⃣ 二级索引

  • 不要用来补救 ORDER BY

  • 用在:

    • 低基数
    • 高频过滤
    • 无法排序的字段

4️⃣ Marks / granularity

  • 大查询 → granularity 大
  • 精确点查 → granularity 小
  • 8192 是折中默认

七、一句话终极总结

ClickHouse 查询快,是因为:

  • 分区决定“看不看目录”
  • 一级索引决定“读不读数据”
  • 二级索引决定“少读一点”
  • marks 决定“从哪读”

那么ClickHouse 的「数据标记(Marks)」,它是什么 → 长什么样 → 写入时怎么生成 → 读取时怎么用 → 为什么它是性能关键 → 常见误区,一步一层拆解。


一、数据标记(Marks)到底是什么?

Marks =「列文件中,每个 granule 的起始位置索引」

一句话定义:

Marks 记录了:
第 N 个 granule,在每一列文件里,从哪个 byte offset 开始读

它不是索引条件
也不参与过滤
它只解决一个问题:

我要从磁盘的哪儿开始读数据


核心关键词

  • 列级别
  • granule 粒度
  • offset 定位
  • 磁盘 seek 地图

二、Marks 和 granule 的关系(必须先理解)

1️⃣ granule 是什么?

  • ClickHouse 中的最小 IO 单元
  • 默认大小:8192 行
  • 所有索引、跳过,最终都落到 granule
part
 ├── granule 0 (rows 0–8191)
 ├── granule 1 (rows 8192–16383)
 ├── granule 2 ...

2️⃣ marks 是按 granule 编号存的

对于 每一列:

marks[column][granule_id] = file_offset

例如:

granule_idamount.bin offset
00
19832
219701

👉 这意味着:

想读 granule 2 的 amount
→ 直接 seek 到 19701 byte


三、Marks 文件长什么样?

在磁盘上(MergeTree):

t/
 ├── amount.bin
 ├── amount.mrk
 ├── status.bin
 ├── status.mrk
  • .bin:真实列数据(压缩)
  • .mrk:marks 文件(非常小)

📌 每一列都有自己的 marks


四、写入时:Marks 是怎么生成的?

写入链路(简化)

数据写入
 → 排序(ORDER BY)
 → 按 index_granularity 切 granule
 → 列式存储写 bin
 → 每写完一个 granule
     记录 offset → marks

举个例子(amount 列)

假设:

  • granule = 8192 行
  • amount 列压缩后大小不均
granule 0: 写了 9 KB → offset 0
granule 1: 写了 10 KB → offset 9216
granule 2: 写了 8 KB → offset 19456

marks 文件就会记录:

[0, 9216, 19456]

📌 marks 与数据量、压缩率强相关


五、读的时候:Marks 是怎么被使用的?

查询流程中 marks 的位置

WHERE 条件
 ↓
分区剪枝
 ↓
一级索引判断 granule 范围
 ↓
二级索引判断 granule 是否可能命中
 ↓
【Marks 定位磁盘 offset】
 ↓
read() + decompress

读某个 granule 的真实过程

要读:
- granule 12
- 列:amount, status

步骤:
1. 查 amount.mrk[12] → offset A
2. seek(amount.bin, A)
3. 读取 amount granule
4. 查 status.mrk[12] → offset B
5. seek(status.bin, B)
6. 读取 status granule

👉 没有 marks,就只能顺序扫


六、为什么 Marks 对性能这么关键?

1️⃣ 没有 marks,ClickHouse 就退化成“顺序列扫”

  • 无法随机定位
  • 无法只读命中的 granule
  • SSD / HDD 性能全浪费

2️⃣ 一级索引 & 二级索引的“落点”都是 marks

索引只能告诉你:
“granule 12 可能命中”

但:

marks 才能让你真正读到 granule 12

📌 索引 = 决策
📌 marks = 执行


3️⃣ 为什么 index_granularity 会影响性能?

SETTINGS index_granularity = 8192
  • granule 越大:

    • marks 越少
    • seek 次数少
    • 但过滤精度低
  • granule 越小:

    • marks 多
    • seek 多
    • 精度高

📌 marks 是 granularity 的直接产物


七、marks 与压缩、IO、CPU 的关系

1️⃣ marks 不存“行号”

  • 只存 byte offset
  • 与压缩格式强耦合

2️⃣ marks 让 ClickHouse 可以:

  • 跳读
  • 并行读
  • 乱序读

这是 ClickHouse 高并发的基础


3️⃣ marks 极小,常驻内存

  • KB~MB 级
  • 查询前就加载

👉 查 marks ≈ 查数组


八、常见误区(很重要)

❌ 1️⃣ marks ≠ 索引

  • 不能过滤
  • 不能加条件
  • 只是定位

❌ 2️⃣ 有 marks 就快

错 ❌
如果:

  • ORDER BY 设计差
  • 大量 granule 命中

👉 marks 也救不了


❌ 3️⃣ marks 是全表共享

错 ❌

  • 每个 part
  • 每个列
  • 一套 marks

九、工程级结论

✅ 一句话

Marks 是 ClickHouse 真正“读磁盘”的钥匙


✅ 两条黄金法则

  1. 索引负责“该不该读”
  2. Marks 负责“从哪读”

✅ 一个判断性能的核心指标

查询慢
→ 看最终读了多少 granule
→ 本质就是用了多少 marks


那么,每张表的marks是必然存在的了?它标记的是每个8192数据在磁盘中的位置,这么理解对吗?

结论先给:理解“基本对”,但需要两点关键修正。

✅ 是的,MergeTree 系列表中,marks 是必然存在的
⚠️ 但 marks 不是“每张表一份”,也不是“全局每 8192 行一条”

下面我把这两点讲清楚。


一、marks 是不是“必然存在”?

✅ 对:在 MergeTree 系列里,marks 是必然存在

只要是这些引擎之一:

  • MergeTree
  • ReplacingMergeTree
  • SummingMergeTree
  • AggregatingMergeTree
  • CollapsingMergeTree
  • ReplicatedMergeTree

👉 每个 data part、每一列,都会有对应的 marks 文件

📌 没有 marks,ClickHouse 根本无法随机读列文件


❌ 但不是所有表都有

引擎是否有 marks
MergeTree 系列✅ 必有
Log / TinyLog❌ 没有
Kafka / S3❌ 没有
Buffer❌ 没有

这些非 MergeTree 引擎:

  • 要么顺序读
  • 要么交给外部存储

二、marks 标记的到底是什么?

前面说的是:

“他标记的是每个 8192 数据在磁盘中的位置”

🔧 修正后的准确表述是:

marks 记录的是:
「每个 granule(默认 ≈8192 行)在“每一列文件”中的 byte offset」

也就是说,marks 标记的是:

  • ✅ 行块(granule)
  • ✅ 磁盘 byte 偏移
  • ❌ 不是“第 8192 行”
  • ❌ 不是“全表统一位置”

三、一个非常容易误解的点(重点)

❌ 错误理解

全表:
每 8192 行 → 一个 marks

✅ 正确理解

每个 part:
  每一列:
    每 8192 行 → 一个 marks

用层级表示:

table
 └── part_1
      ├── amount.bin + amount.mrk
      ├── status.bin + status.mrk
      └── ...
 └── part_2
      ├── amount.bin + amount.mrk
      ├── status.bin + status.mrk

📌 marks 是 part 级别的,不是表级别的


四、8192 是不是“固定的”?

❌ 不是固定的,只是默认值

SETTINGS index_granularity = 8192;

你可以改:

  • 2048(更精细,点查好)
  • 16384 / 32768(大扫描更好)

📌 marks 的“密度”由 granularity 决定


五、marks 是否“按行精确定位”?

❌ 不精确到行

  • 精度 = granule

  • granule 内:

    • 可能 1 行命中
    • 也可能 8192 行都要算

👉 所以 ClickHouse 的最小读单位是 granule,不是 row


六、用一句话帮你定型这个概念

在 MergeTree 表中:

  • marks 是必须的
  • 它记录的是
    👉「每个 part 内,每个列文件,每个 granule 的起始磁盘位置」
  • 默认 granule ≈ 8192 行
  • 索引负责“选哪些 granule”
  • marks 负责“从哪开始读”

七、现在的理解 vs 准确理解(对照)

你的理解是否准确修正
每张表都有 marks⚠️ 半对MergeTree 系列才有
每 8192 行一个✅是 granule,不是行号
标记磁盘位置✅是 byte offset
全表统一❌每 part、每列

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐