ClickHouse系列(五):ClickHouse 写入链路全解析(Insert 到 Merge)

定位:深入写入机制,理解为什么小 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 的生产级写入架构

相关推荐
大数据新鸟2 小时前
微服务之Spring Cloud OpenFeign
spring cloud·微服务·架构
leijiwen2 小时前
BDCM(比干数商模型):打造 Web4.0 会员数商模型,帮企业进入数字商业文明,重构实体经济
大数据·人工智能·重构
Fang_YuanAI2 小时前
AI正在重构电商行业
大数据·人工智能·ai·重构·aigc·教育电商·电商
测试开发Kevin2 小时前
Pandas 2.x核心技术—— Apache Arrow 高性能数据处理的基石
大数据·pandas
枫叶v.2 小时前
Prompt Engineering、Context Engineering、Harness Engineering:它们到底是什么关系呢
大数据·人工智能·prompt
财经汇报2 小时前
从“供应链金融科技“到“全球贸易金融基础设施“的十年蜕变
大数据·科技·金融
heimeiyingwang2 小时前
【架构实战】时序数据库选型:InfluxDB vs TDengine
架构·时序数据库·tdengine
Elastic 中国社区官方博客2 小时前
Elasticsearch:语义搜索,现在默认支持多语言
大数据·数据库·人工智能·elasticsearch·搜索引擎·ai·全文检索
财经资讯数据_灵砚智能2 小时前
基于全球经济类多源新闻的NLP情感分析与数据可视化(日间)2026年4月8日
大数据·人工智能·python·信息可视化·自然语言处理