定位:深入写入机制,理解为什么小 Part 会拖垮系统
一次 Insert 在 ClickHouse 内部发生了什么
很多人把 ClickHouse 当成"快速的 MySQL"来用,每秒发几百个单条 INSERT。结果没多久系统就开始报 Too many parts,查询也变得异常缓慢。要理解这个问题,必须搞清楚一次 INSERT 在内部到底经历了什么。
一条 INSERT 语句到达 ClickHouse 后,完整链路如下:
Client INSERT
→ 解析 SQL,提取数据
→ 按 Partition Key 拆分数据
→ 每个 Partition 的数据形成一个 Block
→ Block 写入内存,排序 + 压缩
→ 落盘为一个新的 Part(目录)
→ 后台异步触发 Merge
关键点:每次 INSERT 至少产生一个 Part,如果数据跨多个分区,则每个分区各产生一个 Part。这是 MergeTree 引擎的核心设计------写入即追加,不做原地更新。
sql
-- 假设按月分区
CREATE TABLE events (
event_date Date,
user_id UInt64,
action String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (user_id, event_date);
-- 这条 INSERT 如果包含 3 个月的数据,会产生 3 个 Part
INSERT INTO events VALUES
('2024-01-15', 1001, 'click'),
('2024-02-20', 1002, 'view'),
('2024-03-10', 1003, 'buy');
可以通过系统表观察 Part 的生成:
sql
SELECT name, partition, rows, bytes_on_disk, active
FROM system.parts
WHERE table = 'events' AND active
ORDER BY modification_time DESC;
Block / Granule / Part 的关系
这三个概念经常被混淆,但它们处于完全不同的层级:
| 概念 | 层级 | 说明 |
|---|---|---|
| Block | 内存中的列式数据块 | INSERT 数据在内存中的组织形式,是处理的基本单位 |
| Part | 磁盘上的数据目录 | 一个 Block 落盘后形成一个 Part,包含列文件、索引、校验等 |
| Granule | Part 内部的读取单位 | 默认 8192 行为一个 Granule,是稀疏索引的最小粒度 |
它们的包含关系:
Part(磁盘目录)
├── primary.idx -- 稀疏索引(每个 Granule 记录一个索引值)
├── column1.bin -- 列数据(按 Granule 压缩存储)
├── column1.mrk2 -- Mark 文件(Granule 到 bin 文件的偏移映射)
├── count.txt -- 行数
├── checksums.txt -- 校验和
└── ...
Granule 大小由 index_granularity 控制(默认 8192)。这意味着:
- 一个只有 100 行的 Part,也只有 1 个 Granule,但仍然是一个完整的目录结构
- 一个 100 万行的 Part,约有 122 个 Granule,查询时可以通过稀疏索引跳过大量无关 Granule
max_block_size 与 min_insert_block_size 的作用
这两个参数直接影响 Part 的大小和数量:
| 参数 | 默认值 | 作用 |
|---|---|---|
max_block_size |
65536 | SELECT 查询时每个 Block 的最大行数 |
min_insert_block_size_rows |
1048576(约 100 万) | INSERT 时累积到多少行才形成一个 Block 落盘 |
min_insert_block_size_bytes |
268435456(256MB) | INSERT 时累积到多少字节才形成一个 Block 落盘 |
min_insert_block_size_rows 的实际意义:当你通过 INSERT INTO ... SELECT 或流式写入大量数据时,ClickHouse 不会每读一批就写一个 Part,而是在内存中累积到阈值后才落盘。
sql
-- 大批量导入时,可以适当调大以减少 Part 数量
SET min_insert_block_size_rows = 2097152; -- 200 万行
INSERT INTO target SELECT * FROM source;
但注意:直接 INSERT VALUES 不受此参数控制 。每次 INSERT INTO t VALUES (...) 都会立即产生 Part,无论数据量多小。
小 Part 风暴是如何产生的
理解了上面的机制,小 Part 风暴的成因就很清晰了:
场景一:高频单条/小批量 INSERT
python
# 反模式:每条数据一个 INSERT
for row in data_stream:
client.execute("INSERT INTO events VALUES", [row])
# 每次调用 = 1 个 Part,1000 QPS = 每秒 1000 个 Part
场景二:数据跨大量分区
sql
-- 按天分区,一次 INSERT 包含 365 天的数据
-- 结果:一次 INSERT 产生 365 个 Part
INSERT INTO events SELECT * FROM external_source;
场景三:Kafka Engine 消费参数不当
sql
-- kafka_max_block_size 设太小
CREATE TABLE kafka_source (...)
ENGINE = Kafka
SETTINGS kafka_max_block_size = 100; -- 每 100 条就产生一个 Part
小 Part 风暴的危害是连锁反应:
大量小 Part
→ 文件描述符暴增(每个 Part 多个文件)
→ 稀疏索引膨胀(查询需要扫描更多索引)
→ Merge 压力剧增(后台线程忙于合并)
→ 查询变慢(需要读取合并大量小文件)
→ 最终触发 parts_to_throw_insert 保护
查看当前 Part 数量和大小分布:
sql
SELECT
partition,
count() AS part_count,
sum(rows) AS total_rows,
formatReadableSize(sum(bytes_on_disk)) AS total_size,
min(rows) AS min_rows,
max(rows) AS max_rows
FROM system.parts
WHERE table = 'events' AND active
GROUP BY partition
ORDER BY part_count DESC;
parts_to_throw_insert 的保护意义
ClickHouse 内置了一套 Part 数量的保护机制,防止系统被小 Part 拖垮:
| 参数 | 默认值 | 行为 |
|---|---|---|
parts_to_delay_insert |
150 | 单个 Partition 活跃 Part 数达到此值,INSERT 开始被人为延迟 |
parts_to_throw_insert |
300 | 单个 Partition 活跃 Part 数达到此值,INSERT 直接报错拒绝 |
当你看到这个错误时,说明系统已经处于危险状态:
DB::Exception: Too many parts (312). Merges are processing
significantly slower than inserts.
正确的应对方式不是调大阈值,而是从根源解决:
sql
-- 1. 查看哪个分区 Part 最多
SELECT partition, count() AS cnt
FROM system.parts
WHERE table = 'events' AND active
GROUP BY partition
ORDER BY cnt DESC
LIMIT 5;
-- 2. 手动触发合并(紧急情况)
OPTIMIZE TABLE events PARTITION '202401' FINAL;
-- 3. 根本解决:改为批量写入,每批 10000-100000 行
Merge 对 CPU / IO / 内存的影响
Merge 是 ClickHouse 的后台核心操作,它将多个小 Part 合并为大 Part。这个过程并非免费的:
CPU 消耗:Merge 需要对数据重新排序(按 ORDER BY),如果使用了 ReplacingMergeTree 或 AggregatingMergeTree,还需要执行去重/聚合逻辑。
IO 消耗:Merge 是读取所有源 Part → 合并 → 写入新 Part 的过程,涉及大量顺序读写。
内存消耗:Merge 时需要在内存中持有参与合并的数据块,Part 越大内存占用越高。
关键的 Merge 控制参数:
| 参数 | 默认值 | 说明 |
|---|---|---|
background_pool_size |
16 | 后台 Merge 线程数 |
max_bytes_to_merge_at_max_space_in_pool |
150GB | 单次 Merge 的最大数据量 |
number_of_free_entries_in_pool_to_execute_mutation |
20 | 预留给 Mutation 的线程数 |
监控 Merge 状态:
sql
-- 当前正在执行的 Merge
SELECT
table, partition_id,
elapsed, progress,
num_parts, result_part_name,
formatReadableSize(total_size_bytes_compressed) AS size,
formatReadableSize(memory_usage) AS mem
FROM system.merges;
-- Merge 历史统计
SELECT
event_date, event_type,
count() AS cnt,
sum(duration_ms) / 1000 AS total_sec
FROM system.part_log
WHERE event_type IN ('MergeParts', 'MutateFinish')
GROUP BY event_date, event_type
ORDER BY event_date DESC;
写入最佳实践总结
| 实践 | 说明 |
|---|---|
| 批量写入 | 每批 10K-100K 行,避免单条 INSERT |
| 控制分区粒度 | 按月分区优于按天,避免数据散落到过多分区 |
| 监控 Part 数量 | 单分区活跃 Part 超过 50 就需要关注 |
| 合理设置 Kafka 参数 | kafka_max_block_size 建议 ≥ 65536 |
| 避免高频 OPTIMIZE | OPTIMIZE FINAL 会触发全量重写,仅在紧急时使用 |
理解了写入链路,你就能回答这个核心问题:ClickHouse 的写入性能瓶颈不在写入速度,而在 Part 管理。控制好 Part 的数量和大小,才是保障系统稳定运行的关键。
下一篇:第 6 篇 - Kafka 到 ClickHouse 的生产级写入架构