MergeTree 合并原理与数据生命周期

深入 MergeTree 内核:INSERT 写入路径与 Part 生成、Part 不可变结构与列式布局、主键稀疏索引与 minmax 跳过索引、后台合并机制、TTL 与分区生命周期、Part 损坏检测(CHECK TABLE)与突变(Mutations),以及 Part 数量与合并并发的调优实践。

1. 写入路径:从 INSERT 到不可变 Part

理解 MergeTree 的第一步,是搞清楚一条 INSERT 到底在磁盘上做了什么。MergeTree 不是原地更新的存储:每一次写入都会生成一个不可变(immutable)的 Part,后续的所有「修改」都通过后台合并来消化。

1.1 一次 INSERT 的生命周期

CREATE TABLE events (
    event_time DateTime,
    user_id UInt64,
    event_type String,
    value Float64
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, user_id)
SETTINGS index_granularity = 8192;

INSERT INTO events VALUES
    ('2024-06-01 10:00:00', 1, 'view', 1.5),
    ('2024-06-01 10:00:05', 2, 'click', 2.0);

一条 INSERT 的完整路径如下:

  1. 解析与物化:客户端把 SQL 文本发送给服务器,服务器把数据组装成 Block(按列组织的内存结构)。
  2. 排序:数据按 ORDER BY 指定的列在内存中排序。
  3. 分区切分:根据 PARTITION BY 把排序后的数据划分到不同的分区目录。
  4. 刷盘为 Part:每个分区内的数据写成一个不可变 Part,先写临时目录再原子重命名到最终目录。
  5. 校验与可见:写入 checksums.txt 等元数据后,Part 才对外可见(active = 1)。

关键结论:写入路径不修改任何旧数据。旧 Part 原封不动,新数据永远是新 Part。这也是 MergeTree 写入吞吐高的根本原因——没有随机 IO、没有 WAL 重放,顺序写即可。

1.2 写入去重(Deduplication)

单副本(非 Replicated)的 MergeTree 默认也带一个轻量去重:同一批 INSERT 的相同数据块不会被重复写入。

-- 同一 INSERT 重发一次,第二次会被去重拒绝
INSERT INTO events VALUES ('2024-06-01 10:00:00', 1, 'view', 1.5);

-- 在 Replicated 引擎上,跨副本通过 block_id 去重,窗口由
-- replicated_deduplication_window 控制(默认 1000 个块)

去重粒度为「数据块」而非单行:只要块内容一致(相同行数、相同行值、相同列类型),就认为是重复块。这个特性也解释了为什么生产环境推荐攒批写入而不是逐行 INSERT。

1.3 批量写入与 flush 策略

写入方式生成的 Part适用场景
逐行 INSERT每行一个微型 Part仅测试,禁止生产
按批 INSERT(1 万~10 万行)每批一个 Part生产默认,配合异步插入
异步插入 async_insert = 1合并小批后落盘高频实时接入
INSERT ... SELECT一个大 Part批量迁移、聚合回填

生产经验:单次 INSERT 建议达到 5 万~100 万行,避免产生海量 1 行的「垃圾 Part」淹没合并线程。

-- 异步插入:server 端攒 1 秒或 10000 行再落盘
INSERT INTO events
SETTINGS async_insert = 1, async_insert_max_data_size = 10000000
SELECT * FROM source_events;

2. Part 结构与列式存储布局

Part 是 MergeTree 存储的最小单元,理解它的目录结构是排查一切问题的前提。

2.1 Part 目录解剖

/var/lib/clickhouse/data/default/events/
└── 202406_0_1_0/            # 一个分区目录 = 一个 Part
    ├── checksums.txt        # 所有文件的校验和
    ├── columns.txt          # 列定义
    ├── count.txt            # 行数
    ├── primary.idx          # 稀疏主键索引
    ├── event_time.bin / .mrk2
    ├── user_id.bin / .mrk2  # 每列两个文件:数据 + 标记
    ├── event_type.bin / .mrk2
    └── value.bin / .mrk2
  • .bin 文件:该列的全部数据(列式连续存储)。
  • .mrk2 文件:标记文件,记录每个 granule 在 .bin 中的偏移量。
  • primary.idx:每 index_granularity 行一个索引项(默认 8192 行)。

2.2 Part 名称的语义

Part 名称是 all_1_1_0 这种格式,含义为:{分区ID}_{最小块号}_{最大块号}_{合并层级}。

名称段示例含义
分区 ID202406分区键值(toYYYYMM 的格式化结果)
最小块号1组成该 Part 的最小 data part 序号
最大块号5组成该 Part 的最大 data part 序号
层级2合并代数,每次参与合并 +1
SELECT name, partition, part_type, rows, bytes_on_disk, level, active
FROM system.parts
WHERE table = 'events' AND active = 1;

level 越高说明合并次数越多;active = 1 表示当前可见,active = 0 表示已被合并覆盖、等待清理。

2.3 Wide 与 Compact Part

Part 有两种物理形态:

  • Compact Part:所有列数据挤在单个 data.bin 里,适合小批量写入(默认 min_bytes_for_wide_part = 10MB、min_rows_for_wide_part = 5_000_000 以下时触发)。
  • Wide Part:每列独立 .bin 文件,查询 IO 更精细,是大 Part 的默认形态。
-- 强制小表也使用 Wide 形态,便于观察列文件
CREATE TABLE events_wide (...)
ENGINE = MergeTree()
ORDER BY event_time
SETTINGS min_bytes_for_wide_part = 0, min_rows_for_wide_part = 0;

3. 主键稀疏索引与跳过索引

3.1 稀疏索引与 index_granularity

MergeTree 的「主键」不是唯一约束,而是排序键 + 稀疏索引。索引只记录每 index_granularity(默认 8192)行第一行的值,因此磁盘占用极小(千分之一左右)。

稀疏索引意味着:如果查询条件命中了排序键前缀,ClickHouse 可以直接跳过大部分 granule;但如果用非排序键列过滤,就必须全表扫描。ORDER BY 的设计决定了查询能有多快。

-- 命中主键前缀:只读相关 granule
SELECT count() FROM events WHERE event_time >= '2024-06-01' AND event_time < '2024-06-02';

-- 非主键列过滤:退化为扫描
SELECT count() FROM events WHERE event_type = 'view';

3.2 minmax 跳过索引

当过滤条件使用非排序键列时,可以用 INDEX ... TYPE minmax 建立辅助索引,让 Part 在扫描前先被裁剪掉一部分 granule。

CREATE TABLE events_idx (
    event_time DateTime,
    user_id UInt64,
    event_type String,
    value Float64,
    INDEX idx_event_type event_type TYPE minmax GRANULARITY 4,
    INDEX idx_user_id user_id TYPE minmax GRANULARITY 4
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, user_id);
跳过索引类型适用过滤说明
minmax范围/等值记录 granule 内最小/最大值,最通用
set低基数字典等值记录 granule 内全部值,基数千级内有效
bloom_filter等值/IN概率型结构,适合高基数点查
ngrambf_v1模糊 LIKE对中文字符串 LIKE 特别有效
tokenbf_v1分词等值按空格/符号分词后建布隆过滤器
-- 验证索引是否命中:输出中 SelectedParts / SelectedGranules 下降即生效
EXPLAIN indexes = 1
SELECT count() FROM events_idx WHERE event_type = 'view';

4. 后台合并机制

4.1 合并触发与选择策略

MergeTree 的后台线程会持续监控 Part 数量,按「合并公平性 + 大小均衡」策略挑选若干 Part 合并成一个大 Part。触发合并的常见条件:

  • 新 Part 数量超过阈值(parts_to_throw_insert 默认 300、parts_to_delay_insert 默认 150);
  • TTL 到期需要删除/移动数据;
  • 收到 OPTIMIZE TABLE ... FINAL 强制指令。
-- 查看当前正在执行的合并任务
SELECT database, table, elapsed, progress,
       total_parts, parts_merged, result_part_path
FROM system.merges;

4.2 观察合并与合并日志

合并全过程记录在 system.part_log,比 system.merges 的历史更完整:

SELECT event_time, table, part_name, result_part_name, merged_from,
       merge_reason, rows, bytes_read
FROM system.part_log
WHERE event_type = 'MergeParts'
ORDER BY event_time DESC
LIMIT 10;
字段含义
merge_reasonNotWorth(大小不均衡)/ TTLDelete / Manual 等
rows合并后 Part 行数
result_part_name新 Part 名,level 已 +1

4.3 强制合并

-- 等待所有分区的合并完成(生产谨慎使用,会阻塞写入)
OPTIMIZE TABLE events FINAL;

-- 只优化指定分区
OPTIMIZE TABLE events PARTITION 202406 FINAL;

OPTIMIZE ... FINAL 不是必须的日常操作:它会占用合并线程并产生额外 IO。正确做法是让后台自然合并,仅在导入大批历史数据后执行一次。

4.4 合并的并发与大小控制

合并线程池默认 3 个(background_pool_size),并受以下设置约束:

设置默认值作用
background_pool_size3后台任务(含合并)线程数
background_merges_mutations_concurrency_ratio2合并与突变并发比例
max_bytes_to_merge_at_max_space_in_pool150GB合并 Part 的最大总大小
number_of_free_entries_in_pool_to_lower_max_size_of_merge8线程繁忙时降低合并大小的阈值
merge_max_block_size8192 行合并时读取块的行数
-- 限制单次合并的规模,避免大 Part 合并长时间占满 IO
SET merge_max_block_size = 16384;

5. TTL 与分区生命周期

5.1 基于时间的 TTL

TTL 让数据在过期后自动删除(或转移到冷盘),是最常用的生命周期管理手段:

CREATE TABLE events_ttl (
    event_time DateTime,
    user_id UInt64,
    event_type String,
    value Float64
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY event_time
TTL event_time + INTERVAL 90 DAY;

TTL 的生效依赖后台合并:每次合并时检查 TTL 表达式,过期的 Part 整体删除(ttl_only_drop_parts = 1)或逐行删除。设置 merge_with_ttl_timeout(默认 86400 秒)控制检查节奏。

-- 查看 TTL 配置是否被正确解析
SELECT name, ttl FROM system.tables WHERE name = 'events_ttl';

5.2 TTL 分层存储(Move)

TTL 还可以把数据移动到另一块磁盘,而不是删除,实现冷热分层:

-- config.xml 中定义存储策略:
-- <storage_configuration>
--   <disks><disk_s3><type>s3</type>...</disk_s3></disks>
--   <policies><hot_cold>
--     <volumes><hot><disk>default</disk></hot>
--     <cold><disk>disk_s3</disk></cold>
--   </volumes></hot_cold></policies>

CREATE TABLE events_tier (
    event_time DateTime,
    user_id UInt64,
    event_type String
) ENGINE = MergeTree()
ORDER BY event_time
TTL event_time + INTERVAL 30 DAY TO VOLUME 'cold'
SETTINGS storage_policy = 'hot_cold';
操作语法效果
过期删除TTL col + INTERVAL 90 DAY合并时删行或删 Part
移动冷盘TTL col + INTERVAL 30 DAY TO VOLUME 'cold'数据搬到冷盘,查询仍透明
移动多级TO DISK 's3'指定具体磁盘
列级 TTLTTL col1 + INTERVAL 1 DAY列值恢复为默认值

5.3 分区生命周期管理

分区级操作是最高效的删除手段——直接删目录,不触发逐行处理:

-- 删除整月分区(秒级完成,远快于 DELETE)
ALTER TABLE events DROP PARTITION '202406';

-- 把分区分离到 detached 目录(用于备份或排障)
ALTER TABLE events DETACH PARTITION '202405';
ALTER TABLE events ATTACH PARTITION '202405';

6. Part 损坏检测与修复

6.1 CHECK TABLE

Part 的每个文件都带校验和,CHECK TABLE 会逐 Part 校验:

-- 返回 Ok 或错误信息
CHECK TABLE events;

-- 只检查一个分区
CHECK TABLE events PARTITION 202406;

如果返回错误,通常对应 system.parts 中异常 Part。Part 校验失败在 system.part_log 的 event_type = 'ChecksumFailure' 中也有记录。

6.2 损坏 Part 的处理

-- 把疑似损坏的 Part 移入 detached 目录,避免影响查询
ALTER TABLE events DETACH PART '202406_1_10_2';

-- 直接从 detached 中丢弃(先确认有备份或副本可恢复)
ALTER TABLE events DROP DETACHED PART '202406_1_10_2';
场景处理方式
单副本损坏DETACH PART 后从备份恢复该分区
多副本损坏损坏副本的 Part 会被复制线程自动补齐
索引损坏但数据完好ALTER TABLE ... DROP INDEX / 重建辅助索引

6.3 副本自愈

Replicated 引擎下,副本之间通过 system.replicas 跟踪队列,坏 Part 会被自动从健康副本拉取重放:

SELECT database, table, replica_name, is_leader,
       parts_count, queue_size, inserts_in_queue, merges_in_queue
FROM system.replicas
WHERE table = 'events';

副本自愈的前提是 ZooKeeper/Keeper 路径与 {replica} 标识配置正确。system.replicas.queue_size 长期不为 0 说明同步滞后,需要排查网络或 Keeper。

7. 突变(Mutations)

7.1 ALTER UPDATE / DELETE 的原理

UPDATE / DELETE 在 MergeTree 上是重写型操作,统称 Mutation:

-- 更新指定行的字段
ALTER TABLE events UPDATE value = value * 1.1
WHERE user_id = 42;

-- 删除指定行
ALTER TABLE events DELETE WHERE event_type = 'spam';

Mutation 并不修改旧 Part,而是:

  1. 记录一条 mutation 到元数据;
  2. 后台为每个含目标行的 Part 生成新版本 Part(mutation_num 进入 Part 名);
  3. 新 Part 原子替换旧 Part。

因此一条大范围 DELETE 的成本约等于把整个表重写一遍——不要把它当行式数据库的 DELETE 用。

7.2 system.mutations

SELECT database, table, mutation_id, command,
       create_time, parts_to_do, parts_done, is_done, latest_fail_reason
FROM system.mutations
WHERE table = 'events';
字段含义
parts_to_do / parts_done待处理/已处理 Part 数
is_done是否完成(1)
latest_fail_reason失败原因,如磁盘不足

若 parts_to_do 长期不降,说明合并线程被其他任务挤占,可调大 background_pool_size 或等待。

7.3 轻量删除(Lightweight Delete)

从 22.8 起,DELETE WHERE 在满足条件时走轻量删除路径——只写删除标记,不立即重写 Part:

-- 走轻量删除(默认 lightweight_delete 开启)
ALTER TABLE events DELETE WHERE user_id = 0;

-- 查询时自动过滤被删除的行;最终合并时才真正物理清理
SELECT count() FROM events WHERE user_id = 0;
特性全量 Mutation轻量删除
数据重写立即重写目标 Part延迟到后台合并
执行速度慢(重写全表)快(仅写标记)
可见性完成后一致查询时过滤,最终一致
适用大批 UPDATE/复杂过滤 DELETE简单等值 DELETE

8. 调优实践与总结

8.1 Part 数量与合并节奏

Part 数量是 MergeTree 健康度的核心指标。监控 system.parts:

SELECT partition, count() AS parts, sum(rows) AS total_rows
FROM system.parts
WHERE table = 'events' AND active = 1
GROUP BY partition
ORDER BY partition;
指标健康范围过高的后果
每分区 active Part 数< 50SELECT 打开文件多、延迟升高
写入频率 vs 合并速度写入 < 合并Part 堆积、Too many parts 报错
单 Part 大小50MB ~ 100GB过小合并频繁,过大合并耗时

8.2 关键设置清单

-- 生产常用合并与写入调优
SET max_partitions_per_insert_block = 100;      -- 单次 INSERT 分区数上限
SET max_insert_block_size = 1048576;            -- 单块行数上限
SET parts_to_throw_insert = 300;                -- 超过则拒绝新写入
SET parts_to_delay_insert = 150;                -- 超过则减慢写入
SET background_pool_size = 8;                   -- 合并线程数(重启生效)
SET index_granularity = 8192;                   -- 建表时定义,勿轻易调大

8.3 总结

主题核心结论
写入INSERT 只产生新 Part,绝不改旧数据;攒批写入避免垃圾 Part
Part不可变 + 列式 + 稀疏索引;Wide/Compact 按大小自动切换
索引ORDER BY 决定主键裁剪能力;非排序键列用 minmax 等跳过索引
合并后台异步,用 system.merges / system.part_log 观测
TTL过期删除或冷盘移动,分区级 DROP 最快
损坏CHECK TABLE 校验;DETACH + 副本/备份恢复
突变UPDATE/DELETE 是重写型操作;优先用轻量删除
调优盯 Part 数量,控制合并并发,设置写入保护阈值
理解「Part 不可变 + 后台合并」这对组合,就掌握了 ClickHouse 存储的 80%。所有上层现象——去重时机、TTL 生效延迟、Mutation 昂贵——都能从这里推导出来。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「数据库」更多文章

  1. 权限与安全加固
  2. Kafka 引擎与实时管道
  3. 备份恢复与容灾