Skip to content

第 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

分区的核心价值

  1. 分区裁剪WHERE event_date >= '2024-02-01' → CH 直接只扫 2024* 开头的文件夹。
  2. 以分区为单位做 DROP PARTITION / ATTACH PARTITION → 删历史数据秒级完成。
  3. 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 InnoDBClickHouse 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]...      │
      └───────────────────────────────────────────────────────┘
                       一列 = 一个连续的字节块

📌 列式存储的三大红利

  1. SELECT 只读需要的列SELECT count() FROM events_mt 只开 user_id.bin(或最小一列),不碰其他 .bin。
  2. 同列类型相同,压缩率高user_id UInt64 列重复度高,LZ4/ZSTD 能压到 1/10 甚至 1/50。
  3. 向量化执行:一次加载一整列的一段(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 选择矩阵

列特征查询模式首选索引
日期 / 数值有序范围 > < BETWEENminmax
低基数枚举= / INset(N)
高基数等值= / INbloom_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 标记 inactive

5.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 s

5.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;

为什么危险?

  1. I/O 炸裂FINAL 要读所有 Part、重写所有数据;亿级表可能跑几小时,占满磁盘带宽。
  2. 并发锁:合并期间不能做其它 DDL / Mutation,结构变更会排队。
  3. 可能没意义:普通 MergeTreeFINAL 除了合并 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_rowsread_bytesquery_duration_ms
bad_mt10_000_000950 MB1800 ms
good_mt24_5762.3 MB28 ms

差距 ~60 倍。原因:

  • good_mt 先走分区裁剪(只扫 202402_* 文件夹);
  • 再走稀疏主键索引user_id 在主键最前),只读 3 个 Granule;
  • bad_mt 没分区 + 主键不包含 user_id → 整个表 scan。

5.6.3 教训

设计 MergeTree 的黄金法则

  1. 分区键:按最常用的时间粒度(天/月/周),保证 WHERE 带它。
  2. 排序键高频过滤列放前低基数列在前、高基数在后(比如 (country, user_id) 优于 (user_id, country));末尾可以加个时间列。
  3. 主键:通常 = 排序键的前缀(默认即可),只在确实想让主键比排序键短时才显式写。

5.7 📌 与 MySQL / PG 的对比小框

维度MySQL InnoDBPostgreSQL HeapClickHouse 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 目录结构 + 列式存储布局 + 元数据文件的作用,必考。

标准答案

  1. 一个表由若干 Part 组成,每个 Part 是一个目录,名字格式是 {分区号}_{minBlock}_{maxBlock}_{level}
  2. Part 目录下按分文件:每列都有独立的 {col}.bin(压缩后的列数据)和 {col}.mrk2(Mark 文件,记录 Granule 在 bin 中的偏移)。
  3. 外加若干元数据:columns.txt(列定义)、count.txt(行数)、primary.idx(稀疏主键索引)、checksums.txt(校验)、partition.dat + minmax_*.idx(分区键的值与范围,用于分区裁剪)。
  4. index_granularity(默认 8192)行构成一个 Granuleprimary.idx 对每个 Granule 只存它的第一行主键值。

加分项:能画出 ASCII 图展示 primary.idx ↔ mrk2 ↔ bin 的映射;能讲"列式存储"相对于 InnoDB 聚簇行存的 SELECT 单列 IO 优势。

易错点:把 Mark 和 Granule 混为一谈。Granule 是逻辑的 8192 行,Mark 是物理的偏移记录。


Q2:ClickHouse 的主键和 MySQL 的主键有什么区别?

考察点:这是 CH 最容易被 MySQL 经验带偏的一题。

标准答案

  1. 唯一性:MySQL 主键必须唯一,插入重复直接报错;CH 主键不保证唯一,重复值完全合法。
  2. 作用:MySQL 主键兼做聚簇索引(决定数据物理顺序)+ 唯一约束;CH 主键只做稀疏索引,决定 primary.idx 里记什么值。
  3. 存储:MySQL 主键索引每行 1 条(百万行的索引通常几十 MB 起步);CH 主键索引每 8192 行 1 条(百万行的索引才几 KB)。
  4. 与排序键的关系:CH 的 ORDER BY 才真正决定数据物理顺序;PRIMARY KEY 必须是 ORDER BY 的前缀,省略时默认等于 ORDER BY

加分项:能说出"真去重要用 ReplacingMergeTreeINSERT 前加 argMax";能讲出"把主键写短一些,可以节省稀疏索引常驻内存"。

易错点:拿 MySQL 思维来建 CH 表,以为加 PRIMARY KEY 就能防重 —— 结果一堆脏数据。


Q3:什么是稀疏索引?它是怎么加速查询的?

考察点:对 primary.idx 的工作流程的理解。

标准答案

  1. 稀疏索引指不对每一行打索引,而是对每 N 行(一个 Granule)打一条索引。CH 默认 N = 8192。
  2. primary.idx 里按主键升序存每个 Granule 的第一行主键值;因此体积极小,通常可以整个常驻内存。
  3. 查询时:
    • 根据 WHERE 条件对主键前缀的约束,二分查找 primary.idx,定位命中 Granule 的编号;
    • 根据 {col}.mrk2 把 Granule 编号转换成 .bin 文件里的字节偏移;
    • 只解压并读取这些 Granule 对应的压缩块;
    • 在 Granule 内部做顺序扫描过滤。
  4. 因此即使表有 10 亿行,点查/范围扫通常只读几个 Granule(几万行),速度极快。

加分项:能说明稀疏索引为什么点查不如 B+ Tree(还要多读整个 Granule),但批量范围扫远胜。

易错点:以为稀疏索引能像 B+Tree 一样"精准定位一行"—— 其实只能定位到包含目标行的 Granule,还要再扫一段。


Q4:跳数索引(Skip Index)是什么?minmaxsetbloom_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 名字的由来 + 运维心智。

标准答案

  1. Part = 目录级别的数据零件,一次 INSERT 生成一个新 Part,目录名 {partition}_{min}_{max}_{level},level=0。
  2. CH 后台有 MergeSelector 线程池,周期性挑选"大小相近的若干个 Part"启动合并:
    • 多路归并排序
    • 重写 bin / mrk2 / primary.idx
    • 新 Part level = 原 Part 最大 level + 1
    • 老 Part 标为 inactive,延迟(默认 8 分钟)后物理删除
  3. Merge 只在同一分区内进行,跨分区永远不合并。
  4. 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。

标准答案

代价

  1. I/O 爆炸:要读完所有 Part、重写所有列的 bin / mrk2 / primary.idx,亿级表可能跑几小时。
  2. 占用 Merge 线程池:合并期间正常后台 Merge 排队,持续写入可能触发 too_many_parts
  3. 阻塞 DDL:同一张表上的 ALTER / MUTATION 必须等完成。

什么时候真的需要

  • 确实要做一次"磁盘收缩"或"冷分区归档前的最终合并";
  • ReplacingMergeTree / SummingMergeTree 等需要让去重/求和一次性生效(生产更推荐 SELECT ... FINAL 按需去重);
  • 只对某个历史分区 OPTIMIZE ... PARTITION '2024-01' FINAL

永远不要对活跃写入中的大表跑 OPTIMIZE TABLE ... FINAL

加分项:能讲 OPTIMIZE ... DEDUPLICATE 是基于所有列的 hash 去重,比 FINAL 更贵;能推荐用 SELECT ... FINALargMax 作为"运行时去重"替代方案。

易错点:以为 FINAL 是"让查询走最新数据"的意思 —— 实际上它是"强制后台合并"的触发器。


Q7:PARTITION BY 应该怎么选?粒度越细越好吗?

考察点:建表时的工程权衡。

标准答案

  1. 分区的本质:把数据在磁盘上分到不同文件夹;查询时做分区裁剪,删历史时做 DROP PARTITION
  2. 选择原则
    • 按最常用的 WHERE 时间粒度:高频写入按、低频按、超低频按
    • 分区总数 ≤ 几千是红线,过多会把元数据 / 内存吃光。
    • 单分区行数 10 万 ~ 几十亿 都 OK,但单分区过小(几百行)会导致 merge 效率急剧下降。
  3. 反例
    • 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 粒度的理解。

标准答案

  1. index_granularity 决定每个 Granule 多少行,默认 8192。
  2. 改小(如 1024)
    • 稀疏索引更细 → primary.idx 变大 ~8 倍;
    • 单次读取的最小单位变小 → 点查更快
    • 写入开销略增,mrk2 文件变大。
  3. 改大(如 65536)
    • 索引更粗 → primary.idx 变小;
    • 单次读的数据量大 → 批量扫/聚合更快
    • 点查会读更多无用行。
  4. 现代版本推荐配合 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())

explain_demo.py ↗