2026 年 1 月某个周一上午,数据组 oncall 同事在群里求救:"ClickHouse 集群查询全线变慢,过去 P99 200ms 的报表跑了 35 秒还没出来,简单的 count 都要 8 秒。前端数据看板全部超时。"我打开 system.metrics 看了一眼,看到一个让我倒吸凉气的数字——system.parts 表里 some_events 这个核心表有 47,832 个 part。正常情况下,一个分区每天 1-3 个 part 就够了,我们 60 天数据应该最多 200 个 part 量级。50000 这个数字直接说明:parts merge 已经追不上写入了。
接下来 6 天我们带着数据组把 ClickHouse 的 MergeTree 引擎、parts 合并机制、写入批次策略整体过了一遍,定位到这是一个慢性病急性发作——业务方逐步把 ClickHouse 接入了更多数据源,写入频率从每分钟 60 次涨到 2000 次,每次写入产生一个 part,而我们 merge 策略停留在 6 个月前的默认配置;再加上某些表的分区粒度太细(按小时分区导致每小时几百 part),merge 引擎彻底跑不动。修法是一组组合拳:批量写入 + 分区策略调整 + merge 配置激进化 + 监控建立。最终 parts 从 47832 降回 218,查询恢复到 P99 180ms。这篇是完整复盘,涵盖 MergeTree 内部原理、parts 累积的根因、4 种修复方案的取舍、以及落地的《ClickHouse 写入与 merge 纪律》。
背景:这个挣扎中的 ClickHouse 集群
| 维度 | 数值 |
|---|---|
| 业务 | SaaS 业务数据分析后端,接收用户行为 + 业务事件,出报表 |
| 技术栈 | ClickHouse 24.x(无 Keeper,用 ZooKeeper),3 shard × 2 replica |
| 规模 | 日均写入 10 亿行,核心表 some_events 总量 1800 亿行 |
| 查询负载 | BI 看板 + 客户 API,日均 12 万查询,P99 200ms |
| 事故现象 | 查询全线变慢,P99 30s+,简单 count 8s+ |
| system.parts 状态 | some_events 表 47,832 parts(正常应 < 200) |
事故时间线:从查询超时到根因落地的 6 天
| 时刻 | 事件 |
|---|---|
| 01-13 上午 | BI 看板超时告警,数据组初判为"数据量涨了" |
| 01-13 中午 | 我查 system.parts,看到 some_events 表 47832 parts |
| 01-13 下午 | 临时缓解:手动触发 OPTIMIZE TABLE some_events,跑了 4 小时,parts 降到 12000(但仍然太多) |
| 01-14 | 读 ClickHouse MergeTree 源码 + 文档,理解 parts 合并的 5 个相关参数 |
| 01-15 | 分析根因:写入频率高 + 分区粒度太细 + merge 配置保守 |
| 01-16 | 设计修复方案 — 批量写入 + 分区粒度从小时改天 + 调激进 merge 参数 |
| 01-17 | 预发环境实施,跑 48 小时验证 parts 增长可控 |
| 01-18 ~ | 分阶段上线生产,parts 稳定 < 300 |
第一反应:"OPTIMIZE TABLE 一下就行"
很多 ClickHouse 用户对 parts 多的反应是 OPTIMIZE TABLE ... FINAL ——强制合并所有 parts。这条命令理论上能把 47000 parts 压回 1 个,但代价巨大:
- 需要扫描所有 parts(我们 some_events 表 800GB),读 + 写 + 排序
- 占用大量磁盘 IO(几小时)
- 对应的 disk space 临时需要 2x 表大小
- 不解决"为什么 parts 这么多"的根因,几天后又会回来
事故当天我让它跑了一次 OPTIMIZE,4 小时把 parts 从 47832 降到 12000——但同时新的小 parts 还在被写入产生,我们这种"边救火边漏水"的方式只是缓解,不解决根本。必须找到"为什么 parts 累积"的真正原因。
四层叠加的因果链:为什么 47832 parts 这个数字会出现
这张图最关键的信息是四个因素互相放大:单条 INSERT 让 part 生产速度极高 / merge 默认配置保守跟不上 / 分区按小时让 merge 无法集中 / parts_to_throw 被调大让积压无止境。任何一个单独存在不会致命,叠加就把"原本设计上每天 3 个 part / 表的轻松写入场景"扭曲成"每天 8 万 part / 表的灾难"。我们后来内部叫这种问题"列式数据库写入反模式",任何一项 ClickHouse 事故复盘都强制画一张这种因果图,确保不会"看到 parts 多就只想 OPTIMIZE 不查根因"。OPTIMIZE 只是"急性病吃止疼药",根因不修几天后 parts 必然回到原位甚至更高。
真凶 1:ClickHouse MergeTree 的 parts 机制
简单说明 MergeTree 的运行模型:
- 每次 INSERT 写入数据,ClickHouse 立即创建一个新 part(磁盘上是一个目录,包含列文件)
- 后台 merge 线程定期把"相邻"的小 parts 合并成大 parts
- merge 策略基于 part 大小:小 parts 优先合并,大 parts 不再合并(避免开销)
- 查询时,ClickHouse 要扫描所有相关 parts(过滤后),每个 part 的数据范围查 partition index + primary index
问题就在第 1 步:每次 INSERT 创建一个 part。如果你每秒 INSERT 100 次,每秒产生 100 个 part。merge 速度有上限(取决于 CPU + IO),如果写入速度长期高于 merge 速度,parts 必然累积。
累积的危害:
- 查询要扫描更多 parts,每个 part 都要打开/关闭文件、加载 index、过滤,常数开销×N
- parts 数超过
parts_to_throw_insert(默认 3000)时,INSERT 直接拒绝(Too many parts) - parts 数超过
parts_to_delay_insert(默认 1500)时,INSERT 会被人为 sleep,降速 - ZooKeeper 元数据膨胀(每个 part 在 ZK 里有一份记录)
事故当时我们的 47832 parts 远远超过这两个阈值,只是因为某种原因没触发"throw insert" — 后来发现是这两个参数被业务方提高了(以为"调大就能写更多",其实是把雪球做更大)。
真凶 2:写入频率高 + 单次 batch 小
看业务方的写入代码,长这样:
# Python 写入 ClickHouse
async def log_event(event):
await client.insert(
'some_events',
[event], # ❌ 每个事件单独 insert
('user_id', 'event_type', 'event_time', ...)
)
每个事件都单独 INSERT,意味着QPS 上万的服务每秒产生上万个 parts。即使 ClickHouse 强大,merge 也跟不上。
修法 1:业务方做批量缓冲,攒一批再写:
class EventBuffer:
def __init__(self, max_size=10000, max_age_s=10):
self.buffer = []
self.max_size = max_size
self.max_age_s = max_age_s
self.last_flush = time.monotonic()
async def add(self, event):
self.buffer.append(event)
if len(self.buffer) >= self.max_size or \
time.monotonic() - self.last_flush > self.max_age_s:
await self.flush()
async def flush(self):
if not self.buffer: return
batch = self.buffer
self.buffer = []
await client.insert('some_events', batch, COLUMNS)
self.last_flush = time.monotonic()
10000 行一批 / 10 秒一次,QPS 上万的应用每秒就 1-3 个 INSERT。parts 产生速度直接降几个数量级。
修法 2:用 ClickHouse 自带的Buffer 表引擎,内置批量缓冲:
CREATE TABLE some_events_buffer (...)
ENGINE = Buffer(default, some_events, 16, 10, 100, 10000, 1000000, 10000000, 100000000);
-- 16 个 buffer / 最少 10 秒 / 最多 100 秒 / min 10000 行 / max 1000000 行 / ...
业务写 buffer 表,ClickHouse 自动按规则 flush 到底表。这种方式业务代码完全不变。代价是 buffer 表的数据在 flush 前不在底表可见 / 不持久(节点重启丢失)。
真凶 3:分区粒度太细
我们 some_events 表的分区是按小时:
PARTITION BY toStartOfHour(event_time)
这看起来"细粒度有利于查询",实际上是parts merge 的灾难——不同分区的 parts 不会合并。一天 24 个分区,每个分区独立合并。如果某个小时只有几百 part,即使按规则可以合并,merge 引擎也未必优先处理(因为小 parts 多)。
修法:按天分区:
PARTITION BY toDate(event_time)
这样每天一个分区,parts merge 在分区内可以充分进行,典型每天 1-3 个 part 就够了。60 天数据 200 个 part 量级。
这个改动需要重建表(分区是 schema 的一部分),我们用 INSERT INTO new_table SELECT FROM old_table 迁移,跑了一个周末。
真凶 4:merge 配置过于保守
ClickHouse 的 merge 行为由一系列参数控制,默认值偏保守(适合"小集群,数据量不大"):
| 参数 | 默认 | 我们改成 | 含义 |
|---|---|---|---|
| max_bytes_to_merge_at_max_space_in_pool | 150 GB | 50 GB | 单次 merge 最大数据量,小一些 merge 更频繁 |
| parts_to_delay_insert | 1500 | 3000 | parts 超此值开始减速写入 |
| parts_to_throw_insert | 3000 | 5000 | parts 超此值拒绝写入 |
| max_replicated_merges_in_queue | 16 | 32 | 并行 merge 任务数 |
| number_of_free_entries_in_pool_to_lower_max_size_of_merge | 8 | 16 | 什么时候降低大 merge 规模让小 merge 跑 |
| merge_with_ttl_timeout | 14400 | 3600 | TTL 触发 merge 的间隔 |
这些参数的调优逻辑:让 merge 引擎更主动、更并行、更频繁。代价是 CPU 和 IO 上升(merge 是重活儿),但能保持 parts 总数在控制范围。
修法验证:48 小时压测
| 指标 | 修复前 | 修复后 |
|---|---|---|
| parts 总数(some_events) | 47832 | 218 |
| 写入 QPS | 2000(产生 part) | 20(产生 part,业务 QPS 不变) |
| 查询 P99 | 30+s | 180ms |
| 简单 count() 耗时 | 8s | 120ms |
| ClickHouse CPU 使用 | 65% | 75%(merge 多了) |
| 磁盘 IO | 320 MB/s | 450 MB/s |
| ZooKeeper 节点数 | ~ 200 万 | ~ 10 万 |
CPU 和 IO 都涨了一些(代价),换来的是parts 数 200 倍下降,查询性能 100+ 倍提升。这是非常划算的 trade-off。
6 天里被否决的方案
| 方案 | 看似可行 | 否决理由 |
|---|---|---|
| 每天定时 OPTIMIZE TABLE ... FINAL 压实 parts | 简单粗暴 一行 cron | OPTIMIZE 是 O(N) 全表读写 800GB 表跑 4 小时 + 期间 IO 打满影响业务查询 + 不解决新 parts 继续产生的根因 治标不治本 |
| 把 ClickHouse 集群从 3 shard 扩到 6 shard | 多机器分摊压力 | 扩容月增 8 万云成本 + 数据重平衡 2 周 + 写入 QPS 平均分到每 shard 还是 333 仍然超过 merge 能力 治标不治本 |
| 切到 StarRocks / Doris 等同类列存 | 这些库声称写入吞吐更高 | 团队 0 经验 迁移 6 个月 + 现有 ClickHouse SQL 不兼容要全部重写 + 同样不批量写也会爆 是工程文化问题不是技术选型 |
| 所有事件先入 Kafka 再批量消费写 ClickHouse | 天然批量化 | 方案对 但工程量大 需要 Kafka 集群 + Consumer + 监控 + 故障恢复 至少 3 周 我们 6 天要交付 留作长期方案 短期先做 buffer 缓冲 |
| 每个事件先入 Redis ZSet 缓冲 | 毫秒级 buffer | Redis 容量有限 + 缓冲数据节点重启会丢 + 业务方对"延迟可见"接受度低 不如直接在应用进程内做 buffer 简单 |
| 放弃 ClickHouse 改回 PostgreSQL 单库 | 避免列式数据库复杂性 | PG 在 1800 亿行数据规模下根本扛不动 单表查询要分钟级 完全无法支撑 BI 看板 SLA 这是退化不是解决方案 |
每条否决都让我们更清楚"真正要修什么"。最后选定的"buffer 缓冲 + 分区改天 + merge 调激进 + 监控"既是技术最优,也是组织成本最低——业务代码改 10 行 buffer,运维改几个参数,基础设施不动。后来产品和老板问"为什么不一次性扩容一劳永逸",我们直接甩这张表 5 分钟说服全场。这种"否决记录"在长期看比"选定方案"价值还大,新人入职第二周遇到类似问题翻一下表就有思路,不需要从头讨论。
决策树:新业务接入 ClickHouse 该怎么配
这棵决策树后来嵌进了数据组的接入流程:任何新业务接入 ClickHouse 的需求,必须先填一张"接入评估表"说清楚走了哪条分支。这个小改动让团队对"列式数据库接入"的纪律性提升一个量级——以前是"业务方自己学着 INSERT 就 merge",现在是"DBA 先帮你估容量定方案再实施"。code review 也因此变得更有抓手,半年下来类似的"接入半年后 parts 爆"工单从季度 2-3 起降到 0 起,新人 DBA 入职第二周就能跟着这棵树独立做接入评估。
顺手做的几件事
1. parts 实时监控
-- 监控 SQL, 每分钟跑一次, 推 Prometheus
SELECT
database,
table,
sum(active) AS active_parts,
sum(rows) AS total_rows,
sum(bytes_on_disk) AS bytes,
max(modification_time) AS last_modify
FROM system.parts
WHERE active = 1
GROUP BY database, table
HAVING active_parts > 100
ORDER BY active_parts DESC;
告警:
- P3:某表 active parts > 500
- P2:某表 active parts > 1500(接近 delay 阈值)
- P1:某表 active parts > 3000(接近 throw 阈值)
2. 写入速率 + merge 速率监控
-- 写入速率
SELECT
formatReadableQuantity(sum(written_rows)) AS rows_per_sec
FROM system.events_history
WHERE event = 'InsertedRows'
AND event_time >= now() - INTERVAL 1 MINUTE;
-- merge 速率
SELECT
formatReadableQuantity(sum(merged_rows)) AS merged_per_sec
FROM system.events_history
WHERE event = 'MergedRows'
AND event_time >= now() - INTERVAL 1 MINUTE;
核心规则:merge 速率必须 ≥ 写入速率。否则迟早积压。
3. 写入前 buffer 强制要求
所有业务接入 ClickHouse 的代码必须用统一的 buffer 库,禁止直接 INSERT 单条数据。这条规则写进了 wiki,新业务接入时 review。
立的《ClickHouse 写入与 merge 纪律》
- 禁止单条 INSERT,所有写入必须批量(至少 1000 行 / 10 秒一次)。业务用统一 buffer 库或 Buffer 表引擎。
- 分区粒度按天,绝大多数业务场景按天分区就够,不要按小时甚至按分钟。
- parts 数必须监控,> 500 P3 告警,> 1500 P2,> 3000 P1。
- merge 速率必须监控,merge 速率 / 写入速率 < 1.2 触发预警(说明 merge 接近极限)。
- 新表创建必须做 capacity review:估算日写入行数 + 分区策略 + merge 容量需求。
- OPTIMIZE TABLE 仅用于一次性救火,不能作为日常运维手段。
- 大集群必须用 ClickHouse Keeper 替代 ZooKeeper(Keeper 性能更好,parts 多时 ZK 容易成瓶颈)。
- 禁止用 ReplacingMergeTree 做"实时去重"(去重要等 merge 才发生,不可靠),业务层做幂等或用其他引擎。
给读者的几条自查清单
- 跑
SELECT count() FROM system.parts WHERE active=1,如果 > 1000 你的集群已经"亚健康"。> 5000 是病重。 - 看你的业务代码,有没有"每个事件单独 INSERT"的模式。有就基本踩雷。
- 看 PARTITION BY 是否按天。按小时 / 分钟的分区策略在数据量大时是定时炸弹。
- 看 system.merges,有没有大量 pending merge 排队。如果 merge 一直在跑但 parts 不降,说明 merge 速度跟不上。
- 检查 max_parts_in_total / parts_to_delay_insert / parts_to_throw_insert 三个参数,确认值合理(不要被业务方"为了能写"调大)。
- 大集群考虑切到 ClickHouse Keeper,parts 多时 ZK 成本太高。
- 定期(每周)review 慢查询日志,看 read_rows 和 read_bytes,扫描数据量异常大的查询往往是分区 / 索引问题。
这次事故让我对"列式数据库"有了更深的敬畏:ClickHouse 的强大来自精心设计的 LSM-like 存储 + 列式编码,但前提是"按它的方式喂数据"。如果你按行式数据库的习惯单条 INSERT,基本是在每秒打它一拳——它能挨打,但挨不了几年。
另一个心得:"数据库选型 + 配套基础设施"是一体的。引入 ClickHouse 时大家关注"它多快"、"它多省空间",但很少有人讲"它的写入必须配 buffer / 它的分区必须按数据量设计 / 它的 merge 容量必须做规划"。这些"隐性要求"如果没人系统化教育业务方,迟早出问题。我们事后做了一份《ClickHouse 业务接入指引》,新业务接入前必须读 + 考核——这种"使用前知识门槛"是技术中台该做的事。
整体效果 + 长期收益
| 维度 | 修复前 | 修复后 90 天 |
|---|---|---|
| parts 总数(some_events) | 47832 失控 | 218 稳定 |
| 查询 P99 | 30+ 秒 看板全线超时 | 180 ms 稳定 |
| 简单 count 耗时 | 8 秒 | 120 ms |
| BI 看板可用率 | 62 percent 业务方天天投诉 | 99.97 percent 0 投诉 |
| ZooKeeper 节点数 | 200 万 ZK 自身告警 | 10 万 ZK 健康 |
| 新业务接入失败率 | 40 percent 接入后半年内必爆 parts | 0 percent 按决策树走 |
| OPTIMIZE 救火操作 | 每周 2-3 次 | 0 次 90 天无救火 |
| 磁盘使用 | 2.8 TB 含大量未合并冗余 | 1.9 TB 紧凑存储 省 32 percent |
| 云成本(ClickHouse 集群) | 4.2 万元 / 月 | 3.1 万元 / 月 省 1.1 万 |
| 顺手扫到的同类隐患表 | 0 | 主动扫到 9 个表 全部已迁分区按天 |
磁盘省 32% 这一项是意外收获——原以为修复是"消除查询慢",结果是"消除查询慢 + 省磁盘空间"。原因是 parts 多的时候每个 part 都有列文件 + 索引 + 元数据,小 parts 的元数据开销占比可达 20%+,合并后冗余消失。一次 6 天的深度调优省下的钱够数据组全员去一趟 PyData,这种 ROI 在 SRE 项目里很难得。
认知更新:对 ClickHouse / 列式数据库的 4 个新认知
- "列式数据库"和"行式数据库"是两种完全不同的物种,接入方式不能复用。PostgreSQL / MySQL 你单条 INSERT 是日常,因为它们的存储引擎是为"每行立刻持久化 + 立刻可见"设计的。ClickHouse 是为"批量写入 + 列式压缩 + LSM 合并"设计的,单条 INSERT 是它最不擅长的工作模式。但很多团队接入 ClickHouse 时把 MySQL 的写入习惯直接搬过来,几个月后必然出 parts 爆炸事故。新业务接入第一课必须讲清这点,这个认知没建立的团队迟早踩坑。
- "调大限制参数"是最危险的临时方案。当业务方发现 INSERT 报"Too many parts"时,第一反应往往是把 parts_to_throw_insert 从 3000 调到 50000,以为"调大就能写更多"。实际上这是把雪球做更大——parts 越多查询越慢、merge 越追不上、最终雪球会大到 OOM。任何"调大限制让错误消失"的操作都要警惕,真正的修法永远是"消除产生错误的根因"。这条铁律后来写进我们运维 SOP,半年挡掉了至少 3 次类似的"调大限制饮鸠止渴"操作。
- "分区粒度细 = 查询快"是错觉。直觉上"按小时分区"听起来比"按天分区"细致,查询时可以精确定位到具体小时。实际上 ClickHouse 的查询优化基于 partition index + primary index,按天分区 + event_time 主键能给你同等的查询过滤精度,且让 merge 引擎能在分区内充分合并。"分区粒度"的选择应该按"数据量 + merge 容量"来定,不按"查询直觉"来定——这是新人最常踩的坑。绝大多数业务场景按天分区是最佳实践,按小时甚至按分钟是反模式。
- "修这个事故"和"修这类事故"是两件事。原本我们计划改完 some_events 这张表就收工,后来主动扫了所有 ClickHouse 表,挖出 9 个有类似"按小时分区 + 单条 INSERT"的隐患表。一次复盘的真正价值不是修当下,是把同类问题在它们爆雷前都摸出来。这种"主动扫雷"耗时大约是修一个 bug 的 5 倍,但避免 9 次类似事故 + 9 次值班半夜起夜——ROI 极其划算。我们后来在数据组设了固定流程,每次 P1 / P2 数据库事故复盘后必须做"同类扫雷",这套流程半年下来主动避免了 13 次潜在事故,口碑提升非常明显。
第三个心得是关于"基准测试的真实性"。ClickHouse 官方 benchmark 跑的是 ClickBench 这类标准数据集,在我们真实业务场景下(混合 BI 查询 + 高频写入 + ZooKeeper 协调)跑出来的 parts 行为和官方 benchmark 完全不一样——官方 benchmark 看 INSERT 性能能到 10W/s,我们生产因为 ZK 写入开销实际只有 2W/s。"官方 benchmark 快"和"你的业务 benchmark 快"是两件事,选型时一定要用自己真实业务负载跑一遍,别信通用 benchmark。这个习惯后来扩展到所有数据库选型——MongoDB / Cassandra / TiDB 都先在自己真实业务负载上跑一遍,再做决策。半年下来挡掉了 3 次"看官方 benchmark 漂亮生产慢爆"的坑。
最后再补一个工程文化层面的反思:这次事故触发前其实有过很多次小信号——BI 看板偶发超时被运营手动重试、数据组同事吐槽过"some_events 这表查询有点慢"、运维同学定期 review 资源占用时标过"ZooKeeper 节点数有点多"、新人 onboarding 时问过"为什么按小时分区不按天",每次大家都用"还能查"、"是历史选择"、"先这样"绕过去。所有大事故都有它的"预热信号",区别只在团队有没有把它当回事。我们后来在事故管理里加了"小信号月度复盘"机制——把过去 30 天的所有低优先级告警 + 业务抱怨 + 新人提的"为什么这样"问题集中拉一遍,挑出可能值得深挖的提前修。半年下来这个机制至少提前避免了 4 次类似量级的数据库事故,投入产出比远超事后排查。希望读到这里的你也能在自己团队里建立类似的"小信号雷达",别再让一个看似无害的"按小时分区"把数据组 6 个月后的某个周一上午毁掉。
下次有人在 ClickHouse 里建新表时,别只想着"分区粒度细一点查询会快",顺手按下面三条配上:PARTITION BY toDate / 应用层 buffer batch=10000 / parts 数监控告警。这套配置花你 10 分钟,但能让你未来 6 个月不被"parts 爆 + 查询变慢"工单叫起来。修完之后你会发现,同样的业务逻辑、同样的硬件配置,集群突然就稳定到"再也没人吐槽过它"——其实表设计没变多少,变的只是你终于按 ClickHouse 的设计哲学喂它数据。这种"零业务侵入却带来 SLO 跃迁"的工程红利,在列式数据库场景里非常常见,值得每个数据团队投入一次彻底的复盘。如果你在自家 ClickHouse 上也做了类似的 parts 治理,欢迎在评论区分享你的 system.parts 监控截图、最终的 P99 数据,以及踩到的其他 MergeTree 反模式——ClickHouse 容量治理这块,中文社区沉淀的实战经验还很稀缺,每一份数据都是后来者的灯塔,愿我们的 6 天踩坑能换你 30 分钟就内化成自己团队的工程默认值,把每一份 IOPS 都用在真正的业务查询上,而不是浪费在本可以避免的"merge 追赶 part 雪球"的应急行为里。
—— 别看了 · 2026