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 的完整路径如下:
- 解析与物化:客户端把 SQL 文本发送给服务器,服务器把数据组装成
Block(按列组织的内存结构)。 - 排序:数据按
ORDER BY指定的列在内存中排序。 - 分区切分:根据
PARTITION BY把排序后的数据划分到不同的分区目录。 - 刷盘为 Part:每个分区内的数据写成一个不可变 Part,先写临时目录再原子重命名到最终目录。
- 校验与可见:写入
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}_{最小块号}_{最大块号}_{合并层级}。
| 名称段 | 示例 | 含义 |
|---|---|---|
| 分区 ID | 202406 | 分区键值(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_reason | NotWorth(大小不均衡)/ 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_size | 3 | 后台任务(含合并)线程数 |
background_merges_mutations_concurrency_ratio | 2 | 合并与突变并发比例 |
max_bytes_to_merge_at_max_space_in_pool | 150GB | 合并 Part 的最大总大小 |
number_of_free_entries_in_pool_to_lower_max_size_of_merge | 8 | 线程繁忙时降低合并大小的阈值 |
merge_max_block_size | 8192 行 | 合并时读取块的行数 |
-- 限制单次合并的规模,避免大 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' | 指定具体磁盘 |
| 列级 TTL | TTL 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,而是:
- 记录一条 mutation 到元数据;
- 后台为每个含目标行的 Part 生成新版本 Part(
mutation_num进入 Part 名); - 新 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 数 | < 50 | SELECT 打开文件多、延迟升高 |
| 写入频率 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 昂贵——都能从这里推导出来。 |
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。