MergeTree 调优:part 生命周期、merge 策略与 granularity

MergeTree 是 ClickHouse 的基石,性能与它的 part 生命周期、merge 策略、granularity 设置强相关。本文深入 part/granule 存储结构、merge 触发机制与参数(min-rows/max-bytes)、索引粒度权衡、自定义主键顺序、part 分裂与冻结,以及分区策略对 merge 的影响,最后给出完整调优清单。

前置:/clickhouse-merge-tree-principle/(MergeTree 存储与合并原理)、/clickhouse-schema-modeling-best-practices/(分区键与排序键建模)、/clickhouse-table-engines/(表引擎体系)、/clickhouse-data-ingestion/(写入与 part 形成)。

目录

1. MergeTree 存储结构:part 与 granule

理解调优先理解存储单元。MergeTree 表的数据物理上被切分为 part,part 内部再按 granule 组织索引与列数据。

存储层级:
□ 分区:数据按分区键归入目录
□ part:一次落盘的最小不可变单元
□ granule:索引与列粒度单元(默认 8192 行)
□ mark:每个 granule 在列文件里的定位标记

part 内部结构:列文件 .bin + 标记 .mrk + 主键 primary.idx + 跳过索引

不可变性:part 落盘不可修改,更新=新建+合并
-- 观察 part 与 granule 相关的系统表
SELECT name, rows, bytes_on_disk, data_compressed_bytes, index_size
FROM system.parts
WHERE active = 1 AND table = 'events'
ORDER BY name;

-- 查看当前表/库的 granularity 设置
SELECT name, value FROM system.merge_tree_settings
WHERE name LIKE '%granularity%';

工程要点:MergeTree 的物理单位是 part(不可变落盘单元)→ granule(索引与列粒度,默认 8192 行);所有调优参数都围绕「part 怎么形成、granule 怎么划分」展开,先建立这两个概念的图像,后面每个参数都能对上号。

2. Part 生命周期:写入、合并与淘汰

part 从诞生到消失经历「写入 → 合并 → 淘汰」三阶段,每个阶段都有可干预的旋钮。

生命周期:
□ 写入:一批 Block 落盘成一个 part
□ 合并:后台把相邻小 part 合成大 part
□ 淘汰:TTL 到期 / 分区 DROP / move 冷存储

健康度:active part 数稳定、平均行数 >> granule、merges 不堆积

失衡信号:part 持续增长(批次太小)、碎片 part、merge 追不上
-- part 数量与大小分布
SELECT count() AS parts, sum(rows) AS rows,
       min(rows) AS min_part_rows, max(rows) AS max_part_rows
FROM system.parts WHERE active = 1 AND table = 'events';

-- 当前正在进行的 merge
SELECT table, elapsed, rows_read, rows_written
FROM system.merges ORDER BY elapsed DESC LIMIT 10;

-- part 变更历史
SELECT event_time, event_type, rows, bytes_on_disk
FROM system.part_log WHERE table = 'events'
  AND event_time > now() - INTERVAL 1 DAY ORDER BY event_time;

工程要点:part 的**「写入→合并→淘汰」全生命周期都是可观测、可干预的——写入阶段控制批次大小、合并阶段调参数与策略、淘汰阶段用 TTL/分区管理;健康标志是part 数量稳定且远小于行数**,出现碎片暴涨就要回查写入侧。

3. Merge 触发机制与策略

merge 不是随机的:它由后台线程按分区内相邻 part 与预设阈值挑选组合。

merge 触发条件:
□ 分区内相邻 part 合并,依大小分层
□ 小 part 快速合并成大 part,大 part 间更谨慎
□ 后台线程数由 background_pool_size 控制

merge 策略:默认按大小分层;空间不足/线程拥挤时降级

管理手段:OPTIMIZE FINAL 应急合并;控制写入批次
-- 手动合并整个表(应急)
OPTIMIZE TABLE events FINAL;

-- 只合并某分区
OPTIMIZE TABLE events PARTITION '202609' FINAL;

-- 查看 merge 相关默认参数
SELECT name, value FROM system.merge_tree_settings
WHERE name LIKE '%merge%' OR name LIKE '%pool%' ORDER BY name;

工程要点:merge 由后台线程按分区内相邻 part、依大小分层触发,手动 OPTIMIZE ... FINAL 只是应急手段;日常调优是让写入批次足够大 + 后台参数匹配硬件,让 merge 始终跑在写入前面,而不是靠手动合并补课。

4. Merge 参数:min-rows 与 max-bytes

merge 的参数决定「多大才算大 part、合并边界在哪里」,直接影响 part 数量与 merge 压力。

核心参数:
□ min_rows_for_compaction(默认 1048576 行):常规合并起点
□ min_bytes_for_compaction(默认 256MB)
□ parts_to_delay_insert / parts_to_throw_insert:part 过多时拖慢/拒绝写入
□ max_parts_in_total:全表 part 数上限保护

权衡:阈值越大 → part 越大、合并越重 → 按数据量折中

目标:单分区 part 数稳定、大 part 占比高
-- 设置表级 merge 参数
CREATE TABLE events (
    event_time DateTime, user_id UInt64, value UInt32
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, user_id)
SETTINGS
    min_rows_for_compaction = 2000000,
    min_bytes_for_compaction = 524288000,   -- 500MB
    parts_to_delay_insert = 300,
    parts_to_throw_insert = 500;

-- 观察 part 分布判断阈值是否合适
SELECT if(rows > 1000000, 'big', 'small') AS size,
       count() AS parts, sum(rows) AS rows
FROM system.parts WHERE active = 1 AND table = 'events'
GROUP BY size;

工程要点:min_rows_for_compaction 与 min_bytes_for_compaction 划定**「常规合并的起点」**——达到阈值才进入慢速大合并,否则快速合并;调参要在「大 part 减少查询开销」与「大合并的 IO 峰值」间折中,并用 part 分布直方图验证。

5. granularity:索引粒度与内存权衡

index_granularity 决定一个 granule 多少行,进而决定主键索引条目数——它是「索引精度」与「索引体积」的平衡旋钮。

granularity 的作用:
□ index_granularity(默认 8192):主键每多少行一条
□ 越小 → 索引越密 → 裁剪越精细
□ 越小 → 索引文件越大、内存占用越高
□ 越大 → 索引稀疏 → 裁剪粒度粗

adaptive index granularity:
□ 按数据密度自动调 granule 大小
□ 用 index_granularity_bytes(默认 10MB)控制
□ 稀疏长字符串列 → 自适应降低索引膨胀

权衡:日志/时序 8192 合适;高精度范围查询调小
-- 表级设置 index_granularity
CREATE TABLE events_g (
    event_time DateTime, user_id UInt64, value UInt32
) ENGINE = MergeTree()
ORDER BY (event_time, user_id)
SETTINGS index_granularity = 4096,
         index_granularity_bytes = 10485760;   -- 10MB

-- 对比不同 granularity 的索引体积
SELECT table, sum(index_size) AS idx_bytes, sum(rows) AS rows,
       sum(index_size) / sum(rows) AS idx_per_row
FROM system.parts WHERE active = 1 GROUP BY table;

工程要点:index_granularity 是**「索引精度 ⇄ 索引体积」**的旋钮——调小让裁剪更细但索引更占内存,调大反之;配合 index_granularity_bytes 自适应可按列数据密度自动权衡,最终以 system.parts.index_size 与命中 granule 数做验收。

6. 自定义主键顺序与索引设计

ORDER BY 即主键,它同时决定数据物理排列与索引可用性,是 MergeTree 性能的第一设计决策。

主键顺序设计:
□ 过滤最频繁的等值列放最左
□ 时间范围列放前利于范围裁剪与分区
□ 低基数列太靠前会饿死后续列

索引设计:列数克制;条件不匹配前缀靠跳数索引;复杂表达式用物化列

物理排列:主键顺序=磁盘排列,同 key 聚簇利于裁剪聚合
□ 避免 UUID/随机高基数列做主键前缀
-- 合理主键:高频等值 + 时间范围
CREATE TABLE events_pk (
    event_time DateTime, user_id UInt64,
    event_type LowCardinality(String), value UInt32
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_type, event_time, user_id);

-- 反模式:高基数列做主键前缀(索引近乎无效)
CREATE TABLE bad_pk (
    id UUID, event_time DateTime, value UInt32
) ENGINE = MergeTree() ORDER BY id;

-- 检查主键配置
SELECT name, type, position, is_in_primary_key
FROM system.columns WHERE table = 'events_pk';

工程要点:主键顺序同时决定索引可用性与物理排列——高频等值列靠左、时间范围列靠前,避免 UUID/随机高基数列做前缀;主键列数要克制,条件必须匹配前缀,否则靠跳数索引补位,这是 MergeTree 建模的第一决策。

7. Part 分裂与冻结:并发写入与备份

写入并发与数据安全会以「part 分裂」与「冻结(freeze)」两种形态影响 MergeTree。

part 分裂:并发写同一分区 → 多 part 并存;适度利于并行读,过度拖累 merge

并发写入设计:高并发天然多 part,后台 merge 兜底;写前按分区键排序

freeze(冻结):ALTER TABLE FREEZE 生成只读快照,不阻塞读写
-- 查看并发写入形成的 part 分布
SELECT partition, count() AS parts, sum(rows) AS rows
FROM system.parts WHERE active = 1 AND table = 'events'
GROUP BY partition ORDER BY partition;

-- 冻结(备份快照)
ALTER TABLE events FREEZE;

-- 清理旧的冻结快照
ALTER TABLE events UNFREEZE WITH NAME 'snapshot_20260930';

工程要点:并发写入会自然产生多个 part(利于并行读),但过度分裂会拖累 merge,需靠写入批次与分区键平衡;ALTER TABLE FREEZE 提供不阻塞读写的只读快照用于备份,调优时要同时管理「part 分裂度」与「冻结副本占用的空间」。

8. 分区策略对 merge 的影响

分区键不仅是查询裁剪的入口,也是 merge 的工作单元——它决定 part 怎么归组。

分区对 merge 的影响:
□ merge 只在「同一分区内」的相邻 part 间进行
□ 分区越细 → part 组越小 → merge 碎片化
□ 分区越粗 → part 组越大 → merge 更整体
□ 跨分区不合并 → 历史分区稳定

分区粒度选择:
□ 对齐查询与 TTL 粒度
□ 日志型:天/月分区常用
□ 避免超高基数分区键(每批都进新分区)

坏味道:
□ 按小时分区 → 一年 8760 分区 → part 海量
□ 随机 ID 分区 → 每批进新分区 → 永不合并
□ 单分区写入热点 → merge 集中在热分区
-- 分区 part 分布体检
SELECT partition, count() AS parts, sum(rows) AS rows,
       sum(bytes_on_disk) AS bytes
FROM system.parts WHERE active = 1 AND table = 'events'
GROUP BY partition ORDER BY parts DESC LIMIT 20;

-- 合理分区:月度,历史分区稳定、热点集中
SELECT partition, count() AS parts, max(rows) AS max_part_rows
FROM system.parts WHERE active = 1 AND table = 'events_month'
GROUP BY partition ORDER BY partition;

工程要点:merge 只在同一分区内进行,所以分区粒度直接决定 merge 的碎片化程度——过细分区制造海量小 part、随机键分区让 part 永不收敛;分区键要对齐查询与 TTL 粒度,让历史分区稳定、热分区集中,并持续用 system.parts 观察分布。

9. 调优清单与实践

把前面的理论收拢成一张可执行清单,按顺序逐项体检。

MergeTree 调优清单:
□ 分区键:对齐查询与 TTL,避免过细
□ 主键:高频等值靠左,时间靠前,列数克制
□ 写入批次:单批够大,避免碎片 part
□ merge 参数:min_rows/min_bytes 匹配数据量
□ granularity:精度与内存权衡,必要时自适应
□ 跳数索引:非前缀过滤列补位
□ 观察:parts 数、index_size、merges 队列
□ 备份:FREEZE 快照;验证:EXPLAIN + query_log

体检步骤:system.parts → system.merges → EXPLAIN → query_log
-- 一步体检包
SELECT 'parts' AS metric, count() AS v FROM system.parts WHERE active=1 AND table='events'
UNION ALL
SELECT 'merges_running', count() FROM system.merges WHERE table='events'
UNION ALL
SELECT 'total_rows', sum(rows) FROM system.parts WHERE active=1 AND table='events';

-- EXPLAIN 验证主键/跳数索引命中
EXPLAIN indexes = 1
SELECT count() FROM events
WHERE event_type = 'view' AND event_time >= '2026-09-01';

工程要点:MergeTree 调优是**「分区键 → 主键 → 写入批次 → merge 参数 → granularity → 索引」的逐层体检**——每一层都有明确指标(parts 数、index_size、merges 队列、裁剪命中),改一项、验一项,最终目标是让 part 稳定、合并跟上、裁剪精准。

10. 速查表与一句话记忆

最后压成速查表。

MergeTree 速查:
□ part:不可变落盘单元,越小越碎
□ granule:索引与列粒度(默认 8192 行)
□ merge:同分区内相邻 part 后台合并
□ 分区:对齐查询与 TTL,别过细
□ 主键:高频等值靠左,列数克制
□ min_rows/min_bytes:常规合并阈值
□ index_granularity:精度与内存权衡
□ FREEZE:只读快照备份

一句记忆:part 大而整、merge 跟得上、索引裁得准
三个观察:parts 数、merges 队列、SelectedGranules
-- 核心观察命令(日常巡检)
SELECT count(), sum(rows), sum(bytes_on_disk)
FROM system.parts WHERE active = 1 AND table = 'events';

SELECT * FROM system.merges ORDER BY elapsed DESC LIMIT 5;

EXPLAIN indexes = 1 SELECT count() FROM events WHERE <过滤条件>;

工程要点:MergeTree 调优一句话——「part 大而整、merge 跟得上、索引裁得准」;日常只看三个数字(parts 数、merges 队列、SelectedGranules),任何异常都能倒推回写入批次、分区键或索引设计上。

延伸阅读

  • /clickhouse-merge-tree-principle/ — MergeTree 存储与合并的底层原理
  • /clickhouse-schema-modeling-best-practices/ — 分区键、排序键与 Schema 建模
  • /clickhouse-table-engines/ — 表引擎体系与 MergeTree 家族
  • /clickhouse-data-ingestion/ — 数据导入与 part 形成过程
  • /clickhouse-columnar-compression/ — 列式压缩与编码对存储的影响
  • /clickhouse-replicated-tables-disaster-recovery/ — 复制表与 part 级别的容灾

数据库专题

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「数据库」更多文章

  1. 数组与高阶函数:arrayMap、arrayFilter 与 Lambda 表达式
  2. 联邦查询与外部数据源:MySQL、PostgreSQL 与 URL 表引擎
  3. JOIN 高级技巧与优化:哈希连接、全局表与关联陷阱