ck表的分区索引、一级索引、二级索引、数据标记是怎么配合使用的,再读写时又会发生什么
接下来,我们从“写入时发生了什么” → “查询时是如何一步步跳过数据的”,把
分区索引、一级索引、二级(跳数)索引、数据标记(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:
-
根据 marks 定位 offset
-
只读涉及的列:
- amount
- status(可能)
-
解压
-
计算
四、它们之间的“配合关系”(重点)
| 组件 | 决定什么 | 能否彻底跳过 |
|---|---|---|
| 分区索引 | 读不读这个 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_id | amount.bin offset |
|---|---|
| 0 | 0 |
| 1 | 9832 |
| 2 | 19701 |
👉 这意味着:
想读 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 真正“读磁盘”的钥匙
✅ 两条黄金法则
- 索引负责“该不该读”
- Marks 负责“从哪读”
✅ 一个判断性能的核心指标
查询慢
→ 看最终读了多少 granule
→ 本质就是用了多少 marks
那么,每张表的marks是必然存在的了?它标记的是每个8192数据在磁盘中的位置,这么理解对吗?
结论先给:理解“基本对”,但需要两点关键修正。
✅ 是的,MergeTree 系列表中,marks 是必然存在的
⚠️ 但 marks 不是“每张表一份”,也不是“全局每 8192 行一条”
下面我把这两点讲清楚。
一、marks 是不是“必然存在”?
✅ 对:在 MergeTree 系列里,marks 是必然存在
只要是这些引擎之一:
MergeTreeReplacingMergeTreeSummingMergeTreeAggregatingMergeTreeCollapsingMergeTreeReplicatedMergeTree
👉 每个 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、每列 |
更多推荐
所有评论(0)