主题
第 5 章 MergeTree 核心原理
学习目标:把
MergeTree这台"发动机"拆开看 —— 数据在磁盘上长什么样、每个 Part 目录里有什么文件、primary.idx为什么叫"稀疏索引"、Mark 怎么在列式文件里跳 Granule、后台 Merge 是怎么把一堆小 Part 合并成大 Part 的。读完能在白板上画出 Part 目录结构、画出 Mark-to-Granule 的跳读过程、讲清PARTITION BY / ORDER BY / PRIMARY KEY三者关系,面试官随便问哪一个都能答。
5.0 一分钟开胃菜:为什么叫 "Merge" Tree?
想象你在写日记:
- 每天写一页,塞进抽屉(= 每次 INSERT 生成一个 Part 目录)
- 每周末把这 7 页按日期排好,装订成一本小册子(= 后台合并出 level=1 的 Part)
- 每月末把 4 本周册装订成一本月册(= level=2)
- 每年末再装订成一本年册(= level=3)
所以 MergeTree 的名字就是两个动作:
写 → 生成「小 Part」
定期 Merge → 合并成「大 Part」,删除小 Part这条逻辑贯穿整章。
┌───────────────────────────────────────────────────┐
│ MergeTree │
│ │
│ INSERT(3 行) ──▶ Part_0 (level=0, 3 行) │
│ INSERT(5 行) ──▶ Part_1 (level=0, 5 行) │
│ INSERT(1 万行) ──▶ Part_2 (level=0, 1 万行) │
│ │
│ ▼ 后台 Merge 触发 │
│ │
│ Part_0..2 (level=0) ──merge──▶ Part_A (level=1)│
│ │
│ …… …… level=1 的 Part 继续被 merge 成 level=2│
│ │
└───────────────────────────────────────────────────┘5.1 PARTITION BY / ORDER BY / PRIMARY KEY —— 三位一体
建一张 MergeTree 表时,这三个子句决定一切:
sql
CREATE TABLE learn_ck.events_mt
(
event_date Date,
event_time DateTime,
user_id UInt64,
event_name LowCardinality(String),
revenue Decimal(18, 2)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date) -- ① 分区键
ORDER BY (user_id, event_time) -- ② 排序键(同时默认是主键)
PRIMARY KEY (user_id) -- ③ 主键(可省略,默认 = ORDER BY 的前缀)
SETTINGS index_granularity = 8192; -- 每 8192 行打一个 Mark(默认)5.1.1 分区 PARTITION BY:物理上"划柜子"
PARTITION BY toYYYYMM(event_date) 的含义:每个月一个独立的文件夹。
/var/lib/clickhouse/data/learn_ck/events_mt/
├── 202401_1_1_0/ ← 2024-01 分区的第 1 个 Part
├── 202401_2_2_0/ ← 2024-01 分区的第 2 个 Part
├── 202402_1_1_0/ ← 2024-02 分区
└── 202403_1_3_1/ ← 2024-03 分区已经 merge 过一轮,level=1分区的核心价值:
- 分区裁剪:
WHERE event_date >= '2024-02-01'→ CH 直接只扫2024*开头的文件夹。- 以分区为单位做
DROP PARTITION/ATTACH PARTITION→ 删历史数据秒级完成。- Merge 只在同分区内进行 → 不同月的 Part 永远不会合并。
选择分区键的铁律:
- 通常按时间(天 / 月),高频写入按天,低频按月。
- 分区数 ≤ 几千是红线,过多分区会把元数据内存吃光。
- 同一分区内的数据量 10 万到 10 亿行都 OK;
toYYYYMMDD粒度对亿级日志表可能太细。
5.1.2 排序 ORDER BY:分区内按这个顺序存
ORDER BY (user_id, event_time) 的含义:每个 Part 内的所有行,按 (user_id, event_time) 升序物理存放。
Part 202401_1_1_0 内部(假设 8 行,实际每 Part 百万级起步):
┌──────────┬────────────────┬───────┬─────────┐
│ user_id │ event_time │ name │ revenue │ ← 按 (user_id, event_time) 升序
├──────────┼────────────────┼───────┼─────────┤
│ 10001 │ 2024-01-01 08 │ click │ 0.5 │
│ 10001 │ 2024-01-03 09 │ view │ 0.0 │
│ 10002 │ 2024-01-02 10 │ click │ 1.0 │
│ 10002 │ 2024-01-05 11 │ buy │ 99.0 │
│ 10003 │ 2024-01-02 12 │ view │ 0.0 │
│ 10004 │ 2024-01-01 13 │ click │ 2.0 │
│ 10005 │ 2024-01-02 14 │ view │ 0.0 │
│ 10005 │ 2024-01-04 15 │ buy │ 49.0 │
└──────────┴────────────────┴───────┴─────────┘5.1.3 主键 PRIMARY KEY:稀疏索引只记排序键的前缀
- 如果你不写
PRIMARY KEY,CH 自动取ORDER BY作为主键。 - 如果写了
PRIMARY KEY,它必须是ORDER BY的前缀(否则 CH 不让建表)。 - 主键只用来做稀疏索引,不保证唯一(这是和 MySQL 最本质的区别,反复强调)。
ORDER BY (user_id, event_time, session_id)
└──────────────────────────────┘
PRIMARY KEY (user_id, event_time) ✓ 合法(是前缀)
PRIMARY KEY (user_id) ✓ 合法(是前缀)
PRIMARY KEY (event_time) ✗ 非法(不是前缀)为什么要分开定义?
- 有时候排序键需要包含更多列以提高压缩率(比如加个
session_id),但你不希望索引变粗; - 主键短 → 稀疏索引体积小,内存常驻更友好。
📌 与 MySQL 对比
概念 MySQL InnoDB ClickHouse MergeTree 主键 唯一,聚簇存储 不唯一,仅用于稀疏索引与排序 插入重复主键 报错 完全允许,查询时会看到多行 索引结构 B+Tree(每行有叶子节点) 稀疏索引(每 8192 行 1 条) 索引大小 百万行的主键索引可能几 GB 百万行的 primary.idx可能才几 KB
5.2 磁盘上的 Part 目录:拆给你看
在服务器上跑一下:
bash
cd /var/lib/clickhouse/data/learn_ck/events_mt/
ls
# 202401_1_1_0 202401_2_2_0 202402_1_1_0 detached/ format_version.txt每一个 202401_1_1_0 就是一个 Part(数据零件)。它的目录名分成 4 段:
202401 _ 1 _ 1 _ 0
└─ 分区号 └─ min_block └─ max_block └─ level
YYYYMM格式 块号下界 块号上界 合并层级- 分区号:由
PARTITION BY表达式生成。 - min_block / max_block:一个单调递增的块号区间。INSERT 一次生成一个 Part,其 min=max;两个 Part 合并后,新 Part 的区间覆盖两者。
- level:合并层级。level=0 是新插入的 Part,每合并一次 +1。
5.2.1 进去看看(核心文件一览)
202401_1_1_0/
├── checksums.txt ← 所有文件的大小 + 校验和
├── columns.txt ← 表结构(列名 + 类型)
├── count.txt ← 本 Part 的行数(例如 "1000000")
├── default_compression_codec.txt ← 默认压缩算法
├── partition.dat ← 分区键值(二进制)
├── minmax_event_date.idx ← 分区键的 min/max 值(分区裁剪用)
├── primary.idx ← ★★★ 稀疏主键索引(本章第二主角)
├── user_id.bin ← ★★★ 列 user_id 的压缩数据文件
├── user_id.mrk2 ← ★★★ 列 user_id 的 Mark 文件
├── event_time.bin
├── event_time.mrk2
├── event_name.bin
├── event_name.mrk2
├── revenue.bin
└── revenue.mrk2文件分成 4 类:
| 类别 | 文件 | 作用 |
|---|---|---|
| 元数据 | columns.txt / checksums.txt / count.txt / partition.dat | 表结构 + 自检 + 快速 count |
| 分区裁剪 | minmax_event_date.idx | 本 Part 的 event_date 最小/最大值 |
| 主键索引 | primary.idx | 稀疏的主键值列表 |
| 列数据 | {column}.bin + {column}.mrk2 | 每列独立一个 bin(数据) + 一个 mrk2(偏移索引) |
ASCII 图示(每列独立存储是"列式存储"的精髓):
┌─────────────── 行式存储 (MySQL InnoDB) ───────────────┐
│ Row1: user_id|event_time|event_name|revenue │
│ Row2: user_id|event_time|event_name|revenue │
│ Row3: ... │
└───────────────────────────────────────────────────────┘
一行 = 一个连续的字节块
vs
┌─────────────── 列式存储 (ClickHouse) ─────────────────┐
│ user_id.bin : [10001][10001][10002][10002]... │
│ event_time.bin : [ 08:00][ 09:00][10:00][11:00]... │
│ event_name.bin : [click][ view][click][ buy]... │
│ revenue.bin : [ 0.5][ 0.0][ 1.0][ 99.0]... │
└───────────────────────────────────────────────────────┘
一列 = 一个连续的字节块📌 列式存储的三大红利
- SELECT 只读需要的列:
SELECT count() FROM events_mt只开user_id.bin(或最小一列),不碰其他 .bin。- 同列类型相同,压缩率高:
user_id UInt64列重复度高,LZ4/ZSTD 能压到 1/10 甚至 1/50。- 向量化执行:一次加载一整列的一段(1024 或 65536 行)进 CPU SIMD 寄存器,一条指令处理一批。
5.2.2 Granule / Mark / Block 三个术语
这三个词经常一起出现,容易搞混:
┌─────────────────────────────────────────────────────────────────┐
│ 一个 Part 内 │
│ │
│ ┌─── Granule 0 ───┐┌─── Granule 1 ───┐┌─── Granule 2 ───┐ │
│ │ 8192 行 ││ 8192 行 ││ 8192 行 │ ... │
│ └──────────────────┘└──────────────────┘└──────────────────┘ │
│ │
│ Mark 0 ────▶ (offset=0, granule_size=8192) │
│ Mark 1 ────▶ (offset=19k, granule_size=8192) ← 压缩块偏移 │
│ Mark 2 ────▶ (offset=38k, granule_size=8192) │
│ │
│ primary.idx: │
│ [Granule0 首行的 PK 值] │
│ [Granule1 首行的 PK 值] │
│ [Granule2 首行的 PK 值] │
│ ↑ 每 Granule 只记 1 条!稀疏! │
└─────────────────────────────────────────────────────────────────┘三者精确定义:
- Granule(颗粒):逻辑上连续的
index_granularity(默认 8192)行,是"稀疏索引一格"的粒度。 - Mark(标记):物理上指向某个 Granule 在
.bin文件里的字节偏移(mrk2文件的一行)。 - Block(压缩块):
.bin文件里的压缩单元,CH 写文件时是一块一块压缩的;Mark 会同时记录"压缩块偏移 + 块内偏移"。
所以读一段 Granule 的流程: ① 从
primary.idx找到目标 Granule 编号; ② 从user_id.mrk2读出这个 Granule 对应的.bin偏移; ③ 读对应压缩块 → 解压 → 跳到块内偏移 → 顺序取 8192 行。
5.3 稀疏主键索引 primary.idx:一切跳读的起点
5.3.1 "稀疏"到底有多稀疏
假设一个 Part 有 1 亿行,index_granularity = 8192:
Granule 数量 = 100_000_000 / 8192 ≈ 12_207 个
primary.idx 记录数 = 12_207只用 1.2 万条索引项覆盖 1 亿行!
每条记录只是"该 Granule 的第一行的主键值":
primary.idx(按主键升序)
┌─────┬─────────────┐
│ 0 │ 10001 │ ← Granule 0 的第一行 user_id
├─────┼─────────────┤
│ 1 │ 20533 │ ← Granule 1 的第一行 user_id
├─────┼─────────────┤
│ 2 │ 35872 │
├─────┼─────────────┤
│ 3 │ 51200 │
├─────┼─────────────┤
│... │ ... │
└─────┴─────────────┘📌 生活类比:字典的"索引页"不会把每个单词都标页码,而是在每页顶端标"这一页从 a-b 开始"。CH 的稀疏索引是一样的思路:不标每一行,只标每一段的开头。
5.3.2 怎么用它跳 Granule:二分 + 扫描
查询:SELECT * FROM events_mt WHERE user_id = 35900
primary.idx:
Granule 0 起始 user_id = 10001
Granule 1 起始 user_id = 20533
Granule 2 起始 user_id = 35872 ← 35900 落在这一格
Granule 3 起始 user_id = 51200
...
二分查找:35900 ∈ [35872, 51200) → 定位 Granule 2
→ 从 mrk2 文件读 Granule 2 对应的 user_id.bin 偏移
→ 解压读出这 8192 行,逐行过滤 user_id = 35900
→ 只读了 8192 行,而不是 1 亿行最坏情况:二分 + 扫 1 个 Granule;最好情况(命中完整 Granule):二分后直接返回。
5.3.3 index_granularity 参数:粗细怎么取?
sql
... SETTINGS index_granularity = 8192;| 值 | 索引更细 / 粗 | 后果 |
|---|---|---|
| 1024 | 细 | 稀疏索引体积 ×8,适合点查(每次少扫行) |
| 8192(默认) | 中 | 平衡吞吐与点查 |
| 65536 | 粗 | 索引超小,适合全表扫 / 大范围聚合 |
还有一个"按字节"的等价设置:
sql
SETTINGS index_granularity_bytes = 10485760 -- 约 10 MB/Granule现代版本推荐用字节粒度,CH 会自动调整行数上限,避免"列宽变化导致 Granule 大小突变"。
5.3.4 主键 ≠ 唯一约束(反复强调)
sql
INSERT INTO events_mt VALUES
('2024-01-01','2024-01-01 10:00:00',10001,'click',0.5),
('2024-01-01','2024-01-01 10:00:00',10001,'click',0.5); -- ← 完全重复
INSERT INTO events_mt VALUES
('2024-01-01','2024-01-01 10:00:00',10001,'click',0.5); -- ← 再来一次
SELECT count() FROM events_mt WHERE user_id = 10001;
-- → 3 不是 1!MergeTree 不去重- 要真正去重:用
ReplacingMergeTree+FINAL(第 6 章)。 - 要幂等写入:用
Replicated*MergeTree(引擎会对"同一批 block 哈希"去重)。
5.4 跳数索引 Skip Index:让非主键列也能跳
稀疏主键索引只对"主键前缀范围查询"有效。如果 WHERE event_name = 'purchase',主键 (user_id, event_time) 根本帮不上忙,只能全表扫吗?
不一定 —— 你可以加跳数索引 (data skipping index)。
5.4.1 5 种常用跳数索引
sql
ALTER TABLE events_mt
ADD INDEX idx_name event_name TYPE set(100) GRANULARITY 4,
ADD INDEX idx_revenue revenue TYPE minmax GRANULARITY 2,
ADD INDEX idx_ua_bloom user_agent TYPE bloom_filter(0.01) GRANULARITY 4,
ADD INDEX idx_url_token url TYPE tokenbf_v1(512, 3, 0) GRANULARITY 4,
ADD INDEX idx_url_ngram url TYPE ngrambf_v1(3, 512, 3, 0) GRANULARITY 4;核心概念:跳数索引的 GRANULARITY N 表示"每 N 个 Granule 统计一次",所以它是**"稀疏之上的更稀疏"**。
① minmax —— 每段统计最小最大值
Granule 0 : revenue ∈ [ 0.00, 99.00 ]
Granule 1 : revenue ∈ [ 0.00, 15.50 ]
Granule 2 : revenue ∈ [10.00, 88.00 ]
Granule 3 : revenue ∈ [ 0.00, 42.00 ]
WHERE revenue > 50
→ 只读 G0、G2(G1、G3 最大值都 < 50,直接跳过)适用:时间、金额、ID 等有序性好的列(哪怕不是严格排序也行)。
② set(N) —— 每段记录最多 N 个去重后的枚举值
Granule 0 : event_name ∈ { click, view }
Granule 1 : event_name ∈ { click, buy }
Granule 2 : event_name ∈ { view }
WHERE event_name = 'buy'
→ 只读 G1(G0、G2 的 set 里没有 buy)适用:低基数(low cardinality)枚举列,事件类型、国家代码、状态。N 建议 > 列的真实基数;如果基数太高(几万种),集合装不下就失效。
③ bloom_filter(error_rate) —— 布隆过滤器
Granule 0 布隆指纹 : [0110001011000010...]
查询 user_agent = 'Chrome/100' 的哈希 → 是否所有位都是 1?
是 → 可能在(去扫)
否 → 一定不在(跳过)适用:高基数(百万种取值)的等值过滤列,如 UA、URL、设备 ID。 error_rate 越小 → 过滤器越大 → 误判率低但占用内存高,典型 0.01(1% 误判)。
④ tokenbf_v1(size, hashes, seed) —— 按"token"分词后的布隆
把字符串用非字母数字字符分词,每个 token 丢进布隆:
"https://example.com/a/b" → ["https","example","com","a","b"]适用:url LIKE '%example%' 这种"含关键词"搜索。
⑤ ngrambf_v1(n, size, hashes, seed) —— N-gram 布隆
把字符串按 N-gram(默认 3)切成连续子串,每个子串丢进布隆:
"ClickHouse" → ["Cli","lic","ick","ckH","kHo","Hou","ous","use"]适用:中文、日志正文、短字符串模糊匹配;比 tokenbf 更精细但占用也更大。
5.4.2 选择矩阵
| 列特征 | 查询模式 | 首选索引 |
|---|---|---|
| 日期 / 数值有序 | 范围 > < BETWEEN | minmax |
| 低基数枚举 | = / IN | set(N) |
| 高基数等值 | = / IN | bloom_filter |
| 长文本"含关键词" | LIKE '%x%' | tokenbf_v1 |
| 短字符串 / 中文 | LIKE / = | ngrambf_v1 |
5.4.3 加索引后的验证
sql
-- 强制 CH 使用 / 不使用 索引,方便对比
SELECT count() FROM events_mt WHERE event_name = 'buy'
SETTINGS use_skip_indexes = 0; -- 关闭跳数索引
SELECT count() FROM events_mt WHERE event_name = 'buy'
SETTINGS use_skip_indexes = 1; -- 开启(默认)
-- 观察 read_rows 字段
SELECT query, read_rows, read_bytes, query_duration_ms
FROM system.query_log
WHERE event_time > now() - INTERVAL 1 MINUTE
AND query ILIKE '%event_name%'
ORDER BY event_time DESC LIMIT 5;💡 坑:跳数索引不是越多越好 —— 每条 INSERT 都要更新所有跳数索引,索引太多会拖慢写入;而且如果查询选择性太差(几乎所有 Granule 都命中),索引做了等于白做。
5.5 后台 Merge:小 Part 如何合并成大 Part
5.5.1 生活类比:快递分拣中心
每天收到 10000 个小包裹 ──┐
│
▼ ┌──────── 分拣员 ────────┐
堆积在仓库 ◀──── 定时触发
│
┌───────合并────────────┐ │
▼ ▼ │
level=1 包裹箱 level=1 │
(装 10~100 个小包裹) │
│
┌───────合并────────────┐ │
▼ ▼ │
level=2 大货柜 level=2│
│
最终配送 │
└─────────────────────────┘- 每次
INSERT生成一个 level=0 的小 Part。 - CH 后台有个 MergeSelector 线程池,定期挑选 "大小接近的 Part" 合并到 level+1。
- 旧 Part 不是立刻删除,而是标记为
inactive,延迟 8 分钟左右(old_parts_lifetime)再彻底删。
5.5.2 Merge 要干的活
Merge 一组 Part:
1. 读取所有输入 Part 的 (按主键) 排序游标
2. 多路归并排序(k-way merge)
3. 如果是 Replacing/Summing/Aggregating 等变种,执行相应的"合并规则"
4. 重写所有列的 bin / mrk2 / primary.idx
5. 新 Part 原子 rename 到正式目录
6. 老 Part 标记 inactive5.5.3 在 system.* 里看合并
sql
-- ① 看当前的 Part 列表
SELECT partition, name, level, rows, bytes_on_disk, active
FROM system.parts
WHERE database = 'learn_ck' AND table = 'events_mt'
ORDER BY partition, name;
-- ② 看最近的合并历史
SELECT event_time, database, table, part_name, merge_reason,
source_part_names, rows, bytes_compressed_on_disk
FROM system.part_log
WHERE event_time > now() - INTERVAL 10 MINUTE
AND event_type = 'MergeParts'
ORDER BY event_time DESC;
-- ③ 看正在进行的合并
SELECT database, table, elapsed, progress, num_parts, source_part_names
FROM system.merges;典型一次合并日志:
event_time : 2026-04-17 10:23:45
part_name : 202401_1_5_1 ← 新 Part,level=1
source_part_names : [202401_1_1_0, 202401_2_2_0, 202401_3_3_0,
202401_4_4_0, 202401_5_5_0]
rows : 1_234_567
elapsed : 3.2 s5.5.4 OPTIMIZE TABLE … [FINAL] 与 OPTIMIZE … DEDUPLICATE —— 最危险的两条 SQL
sql
-- 把所有 Part 强制合并成一个(可能巨耗时)
OPTIMIZE TABLE learn_ck.events_mt FINAL;
-- 额外做全表去重(比 FINAL 更贵)
OPTIMIZE TABLE learn_ck.events_mt FINAL DEDUPLICATE;
-- 只合并一个分区
OPTIMIZE TABLE learn_ck.events_mt PARTITION '202401' FINAL;为什么危险?
- I/O 炸裂:
FINAL要读所有 Part、重写所有数据;亿级表可能跑几小时,占满磁盘带宽。 - 并发锁:合并期间不能做其它 DDL / Mutation,结构变更会排队。
- 可能没意义:普通
MergeTree做FINAL除了合并 Part 之外什么都不干;Replacing/Summing做 FINAL 才有去重/求和效果。
生产铁律:不要对常更新的表手动跑
OPTIMIZE FINAL。让后台 Merge 自行调度;确实需要"压一下"某个历史分区时,加PARTITION '...'限定。
5.6 实战:亿级数据扫描对比
5.6.1 场景
同样造 1000 万行(亿级也行,受限篇幅本章演示千万级):
sql
-- ① 普通 MergeTree,ORDER BY 不合理
CREATE TABLE learn_ck.bad_mt
(
event_date Date,
event_time DateTime,
user_id UInt64,
event_name LowCardinality(String),
revenue Decimal(18, 2)
)
ENGINE = MergeTree
PARTITION BY tuple() -- 不分区
ORDER BY event_time; -- 按时间排
-- ② 合理设计
CREATE TABLE learn_ck.good_mt
(
event_date Date,
event_time DateTime,
user_id UInt64,
event_name LowCardinality(String),
revenue Decimal(18, 2)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date) -- 按月分区
ORDER BY (user_id, event_time); -- 主键放在前用 seed.py 灌 1000 万行同样的数据。
5.6.2 跑同一条查询
sql
SELECT user_id, count(), sum(revenue)
FROM learn_ck.bad_mt
WHERE user_id = 42_000_001
AND event_date BETWEEN '2024-02-01' AND '2024-02-28'
GROUP BY user_id;
SELECT user_id, count(), sum(revenue)
FROM learn_ck.good_mt
WHERE user_id = 42_000_001
AND event_date BETWEEN '2024-02-01' AND '2024-02-28'
GROUP BY user_id;查 system.query_log 的耗时对照(实际数据会因机器而异):
| 表 | read_rows | read_bytes | query_duration_ms |
|---|---|---|---|
bad_mt | 10_000_000 | 950 MB | 1800 ms |
good_mt | 24_576 | 2.3 MB | 28 ms |
差距 ~60 倍。原因:
good_mt先走分区裁剪(只扫202402_*文件夹);- 再走稀疏主键索引(
user_id在主键最前),只读 3 个 Granule; bad_mt没分区 + 主键不包含 user_id → 整个表 scan。
5.6.3 教训
设计 MergeTree 的黄金法则:
- 分区键:按最常用的时间粒度(天/月/周),保证 WHERE 带它。
- 排序键:高频过滤列放前、低基数列在前、高基数在后(比如
(country, user_id)优于(user_id, country));末尾可以加个时间列。- 主键:通常 = 排序键的前缀(默认即可),只在确实想让主键比排序键短时才显式写。
5.7 📌 与 MySQL / PG 的对比小框
| 维度 | MySQL InnoDB | PostgreSQL Heap | ClickHouse MergeTree |
|---|---|---|---|
| 物理存储 | 聚簇索引 B+Tree(叶节点 = 整行) | 堆表(无序),B-Tree 索引独立 | 每列独立文件(列存),按主键有序 |
| 主键 | 唯一,聚簇 | 可有可无,唯一 | 不唯一,仅用于稀疏索引 |
| 索引 | B+Tree,每行 1 条索引项 | B-Tree/Hash/GIN/GiST/BRIN | 稀疏 primary.idx(每 8192 行 1 条)+ 跳数索引(每 N 个 Granule 1 条) |
| 随机读 | 毫秒级点查 | 毫秒级点查 | 相对慢(要解压整个 Granule) |
| 批量扫描 | 慢(行存 + 二级索引) | 中(需跳页) | 极快(列存 + 跳数索引 + 向量化) |
| 数据合并 | 无此概念 | 无此概念 | 核心特性(后台 Merge) |
| UPDATE/DELETE | 实时 | 实时(+ VACUUM) | Mutation 异步重写整个 Part |
| 事务 | 完整 ACID | 完整 ACID | 仅"单分区单次 INSERT 原子" |
一句总结:MySQL/PG 是"快速点操作" 优化,CH MergeTree 是"大批量扫描 + 聚合" 优化。别用 CH 替代 OLTP,也别用 MySQL 做亿级聚合。
5.8 本章小结
┌─────────────────────────────────────────────────────────┐
│ 本章核心要点 │
├─────────────────────────────────────────────────────────┤
│ ① Part 目录命名:{partition}_{minBlock}_{maxBlock}_{lvl} │
│ ② 列式存储:每列一个 .bin + .mrk2 文件,可独立读取 │
│ ③ PARTITION BY:物理分文件夹,用于分区裁剪 + 删历史 │
│ ④ ORDER BY:决定分区内物理顺序 + 压缩率 │
│ ⑤ PRIMARY KEY:只做稀疏索引,不是唯一约束 │
│ ⑥ 稀疏索引:每 index_granularity=8192 行 1 条 │
│ ⑦ Mark 文件:Granule → .bin 偏移的映射 │
│ ⑧ Skip Index 5 种:minmax/set/bloom/tokenbf/ngrambf │
│ ⑨ 后台 Merge:小 Part 合并成大 Part,level +1 │
│ ⑩ OPTIMIZE FINAL 慎用!阻塞 + 大 I/O │
└─────────────────────────────────────────────────────────┘5.9 面试高频题
Q1:MergeTree 的数据在磁盘上是怎么组织的?
考察点:Part 目录结构 + 列式存储布局 + 元数据文件的作用,必考。
标准答案:
- 一个表由若干 Part 组成,每个 Part 是一个目录,名字格式是
{分区号}_{minBlock}_{maxBlock}_{level}。 - Part 目录下按列分文件:每列都有独立的
{col}.bin(压缩后的列数据)和{col}.mrk2(Mark 文件,记录 Granule 在 bin 中的偏移)。 - 外加若干元数据:
columns.txt(列定义)、count.txt(行数)、primary.idx(稀疏主键索引)、checksums.txt(校验)、partition.dat+minmax_*.idx(分区键的值与范围,用于分区裁剪)。 - 每
index_granularity(默认 8192)行构成一个 Granule,primary.idx对每个 Granule 只存它的第一行主键值。
加分项:能画出 ASCII 图展示 primary.idx ↔ mrk2 ↔ bin 的映射;能讲"列式存储"相对于 InnoDB 聚簇行存的 SELECT 单列 IO 优势。
易错点:把 Mark 和 Granule 混为一谈。Granule 是逻辑的 8192 行,Mark 是物理的偏移记录。
Q2:ClickHouse 的主键和 MySQL 的主键有什么区别?
考察点:这是 CH 最容易被 MySQL 经验带偏的一题。
标准答案:
- 唯一性:MySQL 主键必须唯一,插入重复直接报错;CH 主键不保证唯一,重复值完全合法。
- 作用:MySQL 主键兼做聚簇索引(决定数据物理顺序)+ 唯一约束;CH 主键只做稀疏索引,决定
primary.idx里记什么值。 - 存储:MySQL 主键索引每行 1 条(百万行的索引通常几十 MB 起步);CH 主键索引每 8192 行 1 条(百万行的索引才几 KB)。
- 与排序键的关系:CH 的
ORDER BY才真正决定数据物理顺序;PRIMARY KEY必须是ORDER BY的前缀,省略时默认等于ORDER BY。
加分项:能说出"真去重要用 ReplacingMergeTree 或 INSERT 前加 argMax";能讲出"把主键写短一些,可以节省稀疏索引常驻内存"。
易错点:拿 MySQL 思维来建 CH 表,以为加 PRIMARY KEY 就能防重 —— 结果一堆脏数据。
Q3:什么是稀疏索引?它是怎么加速查询的?
考察点:对 primary.idx 的工作流程的理解。
标准答案:
- 稀疏索引指不对每一行打索引,而是对每 N 行(一个 Granule)打一条索引。CH 默认 N = 8192。
primary.idx里按主键升序存每个 Granule 的第一行主键值;因此体积极小,通常可以整个常驻内存。- 查询时:
- 根据 WHERE 条件对主键前缀的约束,二分查找
primary.idx,定位命中 Granule 的编号; - 根据
{col}.mrk2把 Granule 编号转换成.bin文件里的字节偏移; - 只解压并读取这些 Granule 对应的压缩块;
- 在 Granule 内部做顺序扫描过滤。
- 根据 WHERE 条件对主键前缀的约束,二分查找
- 因此即使表有 10 亿行,点查/范围扫通常只读几个 Granule(几万行),速度极快。
加分项:能说明稀疏索引为什么点查不如 B+ Tree(还要多读整个 Granule),但批量范围扫远胜。
易错点:以为稀疏索引能像 B+Tree 一样"精准定位一行"—— 其实只能定位到包含目标行的 Granule,还要再扫一段。
Q4:跳数索引(Skip Index)是什么?minmax、set、bloom_filter 各自适用什么场景?
考察点:非主键列的加速手段。
标准答案:
跳数索引:为非主键列建的"数据段摘要",每 GRANULARITY 个 Granule 生成一条摘要;查询时用摘要提前排除肯定不命中的数据段,避免全表扫。
5 种类型:
minmax:记录每段的最小/最大值,适合有序性较好的列 / 范围查询(日期、金额、递增 ID)。set(N):记录每段最多 N 个去重枚举值,适合低基数等值查询(事件类型、国家、状态)。bloom_filter(err):每段一个布隆过滤器,高基数等值查询(UA、URL、设备 ID)。tokenbf_v1:按非字母数字字符分词后的布隆,适合字符串含关键词查询。ngrambf_v1:N-gram 布隆,适合中文、短字符串模糊匹配。
加分项:
- 能补"索引不是越多越好,每个索引都要在 INSERT 时更新,会拖慢写入";
- 能补"跳数索引对选择性太差的查询等于白建 —— 几乎所有 Granule 都命中";
- 能补"关
SET use_skip_indexes = 0对比read_rows来验证索引是否真的生效"。
易错点:在 event_name 这种低基数列上加 bloom_filter —— 选 set(N) 更合适。
Q5:什么是 Part?Part 的 Merge 是怎么发生的?
考察点:MergeTree 名字的由来 + 运维心智。
标准答案:
- Part = 目录级别的数据零件,一次 INSERT 生成一个新 Part,目录名
{partition}_{min}_{max}_{level},level=0。 - CH 后台有 MergeSelector 线程池,周期性挑选"大小相近的若干个 Part"启动合并:
- 多路归并排序
- 重写 bin / mrk2 / primary.idx
- 新 Part level = 原 Part 最大 level + 1
- 老 Part 标为 inactive,延迟(默认 8 分钟)后物理删除
- Merge 只在同一分区内进行,跨分区永远不合并。
- Replacing / Summing / Aggregating 等家族在 merge 时额外执行去重 / 求和 / 聚合逻辑。
加分项:
- 能说明"写入太碎会导致 Part 爆炸",必须批量写或开
async_insert; - 能讲
system.parts(当前 Part) /system.part_log(Part 生命周期) /system.merges(正在进行的合并)三张运维视图; - 能讲
too_many_parts(>300 个活跃 Part 就写不进去)这条红线。
易错点:以为 INSERT 是"立刻合并"—— Merge 永远是异步的,刚写完的表 parts 数很多是正常现象。
Q6:OPTIMIZE TABLE ... FINAL 的代价是什么?什么时候该用?
考察点:线上最容易误用的一条 SQL。
标准答案:
代价:
- I/O 爆炸:要读完所有 Part、重写所有列的 bin / mrk2 / primary.idx,亿级表可能跑几小时。
- 占用 Merge 线程池:合并期间正常后台 Merge 排队,持续写入可能触发
too_many_parts。 - 阻塞 DDL:同一张表上的
ALTER/MUTATION必须等完成。
什么时候真的需要:
- 确实要做一次"磁盘收缩"或"冷分区归档前的最终合并";
ReplacingMergeTree/SummingMergeTree等需要让去重/求和一次性生效(生产更推荐SELECT ... FINAL按需去重);- 只对某个历史分区
OPTIMIZE ... PARTITION '2024-01' FINAL。
永远不要对活跃写入中的大表跑 OPTIMIZE TABLE ... FINAL。
加分项:能讲 OPTIMIZE ... DEDUPLICATE 是基于所有列的 hash 去重,比 FINAL 更贵;能推荐用 SELECT ... FINAL 或 argMax 作为"运行时去重"替代方案。
易错点:以为 FINAL 是"让查询走最新数据"的意思 —— 实际上它是"强制后台合并"的触发器。
Q7:PARTITION BY 应该怎么选?粒度越细越好吗?
考察点:建表时的工程权衡。
标准答案:
- 分区的本质:把数据在磁盘上分到不同文件夹;查询时做分区裁剪,删历史时做
DROP PARTITION。 - 选择原则:
- 按最常用的 WHERE 时间粒度:高频写入按日、低频按月、超低频按年。
- 分区总数 ≤ 几千是红线,过多会把元数据 / 内存吃光。
- 单分区行数 10 万 ~ 几十亿 都 OK,但单分区过小(几百行)会导致 merge 效率急剧下降。
- 反例:
PARTITION BY (user_id)→ 一个用户一个分区,立刻爆炸;PARTITION BY toYYYYMMDD(event_date)但表只有 10 万行/天 → 分区过细;PARTITION BY tuple()不分区 → 删历史只能走 DELETE,极慢。
加分项:能讲自定义表达式分区(如 (toYYYYMM(date), biz_line));能讲为不同分区指定不同存储策略/TTL。
易错点:把 PARTITION BY 当成"索引"来用 —— 它只是物理分文件夹。
Q8:index_granularity 怎么调?改大 / 改小有什么区别?
考察点:对稀疏索引与 IO 粒度的理解。
标准答案:
index_granularity决定每个 Granule 多少行,默认 8192。- 改小(如 1024):
- 稀疏索引更细 →
primary.idx变大 ~8 倍; - 单次读取的最小单位变小 → 点查更快;
- 写入开销略增,mrk2 文件变大。
- 稀疏索引更细 →
- 改大(如 65536):
- 索引更粗 →
primary.idx变小; - 单次读的数据量大 → 批量扫/聚合更快;
- 点查会读更多无用行。
- 索引更粗 →
- 现代版本推荐配合
index_granularity_bytes(默认 10 MB),让 CH 按字节自动调整行数,避免列宽变化时 Granule 大小失衡。
加分项:能讲"索引粒度改动要重建表(或 ALTER + OPTIMIZE)",不是热切换;能讲不同负载下的典型取值(点查偏 1024、大聚合偏 65536)。
易错点:以为改小一定更快 —— 对大范围聚合反而更慢,且索引常驻内存会吃更多 RAM。
📌 下一章预告:第 6 章把 MergeTree 的 5 个"变种"一次讲透 ——
Replacing去重、Summing求和、Aggregating+ MV 的实时大屏、Collapsing的 CDC 折叠、Graphite的时序 rollup。看完这两章,ClickHouse 的灵魂就摸清了。
🎬 可视化演示
演示加载缓慢或样式异常?点此在新标签页打开 ↗
💻 示例代码
python
"""
第 5 章 MergeTree 核心原理 · EXPLAIN + query_log 对比脚本
作用:
对比 "不合理建表 (bad_mt)" vs "合理建表 (good_mt)" 在同一条查询下的
- EXPLAIN PIPELINE 输出
- system.query_log 里的 read_rows / read_bytes / query_duration_ms
额外演示:
- use_skip_indexes=0/1 对跳数索引的影响
- 手动触发 OPTIMIZE 后 Part 的变化
前提:先跑过 seed.py 造数。
运行:
python explain_demo.py
"""
from __future__ import annotations
import sys
import textwrap
import time
import uuid
import clickhouse_connect
HOST = "127.0.0.1"
PORT = 8123
USER = "default"
PASSWORD = ""
DATABASE = "learn_ck"
def banner(s: str) -> None:
print("\n" + "=" * 78)
print(" " + s)
print("=" * 78)
def run_query(client, label: str, sql: str, settings: dict | None = None
) -> str:
"""执行查询并返回 query_id,方便从 query_log 取指标"""
qid = str(uuid.uuid4())
opts = {"query_id": qid}
if settings:
opts["settings"] = settings
t0 = time.time()
res = client.query(sql, **opts)
elapsed = (time.time() - t0) * 1000
cnt = len(res.result_rows)
print(f"[{label}] qid={qid[:8]} client_elapsed={elapsed:7.1f} ms "
f"rows_returned={cnt}")
return qid
def show_query_log(client, qids: list[tuple[str, str]]) -> None:
"""从 query_log 里拉指标"""
print("\n--- system.query_log 对照表 ---")
time.sleep(2)
client.command("SYSTEM FLUSH LOGS")
q_ids = "(" + ",".join(f"'{q}'" for _, q in qids) + ")"
rows = client.query(
f"""
SELECT query_id,
read_rows,
formatReadableSize(read_bytes) AS read_bytes_h,
formatReadableSize(memory_usage) AS mem_h,
query_duration_ms
FROM system.query_log
WHERE type = 'QueryFinish'
AND query_id IN {q_ids}
"""
).result_rows
by_id = {r[0]: r for r in rows}
print(f"{'Label':<34} {'read_rows':>12} {'read_bytes':>12} "
f"{'mem':>10} {'dur_ms':>8}")
print("-" * 78)
for label, qid in qids:
r = by_id.get(qid)
if not r:
print(f"{label:<34} (no log, maybe FLUSH LOGS not propagated)")
continue
print(f"{label:<34} {r[1]:>12,} {r[2]:>12} {r[3]:>10} {r[4]:>8}")
def main() -> int:
client = clickhouse_connect.get_client(
host=HOST, port=PORT, username=USER, password=PASSWORD,
database=DATABASE,
)
print(f"Connected. Database = {DATABASE}")
# 选一个真实存在的 user_id 作查询条件
try:
uid_row = client.query(
"SELECT user_id FROM learn_ck.good_mt "
"WHERE event_date BETWEEN '2024-02-01' AND '2024-02-28' "
"LIMIT 1"
).result_rows
if not uid_row:
print("!! good_mt 里没有 2024-02 的数据,先跑 seed.py")
return 1
uid = uid_row[0][0]
except Exception as e:
print(f"!! 查询 good_mt 失败,确认 seed.py 已执行: {e}")
return 1
print(f"挑选查询条件:user_id = {uid}")
# ─────────────────────────────────────────
banner("① EXPLAIN PIPELINE:好表 vs 差表")
# ─────────────────────────────────────────
for tbl in ("bad_mt", "good_mt"):
print(f"\n-- EXPLAIN PIPELINE for {tbl} --")
sql = f"""
EXPLAIN PIPELINE
SELECT user_id, count(), sum(revenue)
FROM learn_ck.{tbl}
WHERE user_id = {uid}
AND event_date BETWEEN '2024-02-01' AND '2024-02-28'
GROUP BY user_id
"""
rows = client.query(sql).result_rows
for r in rows:
print(" " + r[0])
# ─────────────────────────────────────────
banner("② 同一查询:bad_mt vs good_mt 指标对照")
# ─────────────────────────────────────────
qids = []
for tbl in ("bad_mt", "good_mt"):
qids.append((
f"{tbl:<10} WHERE user_id + date",
run_query(
client,
tbl,
f"""
SELECT user_id, count(), sum(revenue)
FROM learn_ck.{tbl}
WHERE user_id = {uid}
AND event_date BETWEEN '2024-02-01' AND '2024-02-28'
GROUP BY user_id
""",
),
))
show_query_log(client, qids)
# ─────────────────────────────────────────
banner("③ 跳数索引开关对比 (events_mt)")
# ─────────────────────────────────────────
qids = []
for flag in (0, 1):
qids.append((
f"events_mt use_skip_indexes={flag}",
run_query(
client,
f"ski={flag}",
"""
SELECT count(), sum(revenue)
FROM learn_ck.events_mt
WHERE event_name = 'purchase'
AND event_date BETWEEN '2024-03-01' AND '2024-03-31'
""",
settings={"use_skip_indexes": flag},
),
))
show_query_log(client, qids)
# ─────────────────────────────────────────
banner("④ system.parts / merges 快照")
# ─────────────────────────────────────────
for tbl in ("events_mt", "good_mt", "bad_mt"):
rows = client.query(
f"""
SELECT count() AS parts, sum(rows) AS rows,
formatReadableSize(sum(bytes_on_disk)) AS size,
min(level) AS min_lvl, max(level) AS max_lvl
FROM system.parts
WHERE database='learn_ck' AND table='{tbl}' AND active
"""
).result_rows
if rows:
r = rows[0]
print(f" {tbl:<10} parts={r[0]:<4} rows={r[1]:>12,} "
f"size={r[2]:>10} level=[{r[3]}..{r[4]}]")
print("\nHint: 手动触发合并(慎用):")
print(" OPTIMIZE TABLE learn_ck.good_mt PARTITION '202402' FINAL;")
return 0
if __name__ == "__main__":
sys.exit(main())