1. 问题背景与现象描述
上周五凌晨3点,我们的ClickHouse生产集群突然出现Merge操作大面积阻塞的情况。当时我正在值班,监控系统突然开始疯狂报警——多个分片的后台Merge队列堆积超过1000个任务,磁盘I/O利用率飙升至90%以上,部分查询响应时间从平时的200ms恶化到20秒以上。
通过system.merges表查询,发现大量"Will not merge parts from 202307_1_100_3 to 202307_101_200_4 because part 202307_150_150_0 is not ready"这样的错误信息。更奇怪的是,这些被标记为"not ready"的part都集中在某个特定的日期分区,而这个分区恰好是两天前我们执行过ALTER TABLE修改Schema的表。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Schema变更与Merge机制的深度解析
2.1 ClickHouse的Schema变更实现原理
ClickHouse采用"最终一致"的Schema变更策略。当执行ALTER TABLE修改列定义时:
- 不会立即重写现有数据文件,而是在系统表system.mutations中创建一条变更记录
- 新写入的数据会立即采用新Schema
- 旧数据的转换通过后台Merge过程逐步完成
这种设计避免了像MySQL那样执行ALTER时的表锁问题,但也带来了Merge时的一致性问题。
2.2 Merge操作的核心约束条件
ClickHouse的Merge过程必须满足以下关键条件:
- Schema一致性:参与Merge的所有parts必须具有相同的表结构定义
- 版本连续性:parts的版本号必须连续且未被标记为失效
- 分区一致性:所有parts必须属于同一个分区
在我们的案例中,问题就出在第一条——由于Schema变更后新旧parts的结构定义不同,导致Merge被阻塞。
3. 问题排查与根因定位
3.1 排查工具链的使用
我们通过以下工具逐步缩小问题范围:
sql复制-- 查看当前正在执行的Merge任务
SELECT * FROM system.merges WHERE elapsed > 60;
-- 检查表结构变更记录
SELECT * FROM system.mutations
WHERE table = 'problem_table'
ORDER BY create_time DESC
LIMIT 10;
-- 查询parts状态
SELECT partition, name, active, marks, modification_time
FROM system.parts
WHERE table = 'problem_table'
AND disk_name = 'default'
ORDER BY modification_time DESC;
3.2 关键发现
排查发现两个关键现象:
- 所有被阻塞的Merge都涉及20230715分区的parts
- 该分区存在两种不同Schema版本的parts:
- 旧版本:包含被删除的列
- 新版本:不包含该列
3.3 根因结论
问题本质是Schema变更后,ClickHouse没有自动将旧parts转换为新Schema。当Merge需要同时处理新旧parts时,由于结构不一致而拒绝执行。这导致:
- 小parts无法合并成大parts
- 查询需要扫描更多parts
- 后台Merge队列堆积
- 系统资源逐渐被耗尽
4. 解决方案设计与实施
4.1 临时缓解措施
立即执行以下操作缓解系统压力:
sql复制-- 暂停后台Merge调度
SYSTEM STOP MERGES problem_table;
-- 增加Merge线程数
SET max_replicated_merges_in_queue = 20;
SET number_of_free_entries_in_pool_to_execute_mutation = 100000;
4.2 根本解决方案
我们设计了分阶段修复方案:
阶段一:强制完成Schema变更
sql复制-- 查看未完成的mutation
SELECT * FROM system.mutations
WHERE is_done = 0 AND table = 'problem_table';
-- 手动触发mutation完成
SYSTEM START MERGES problem_table;
SYSTEM SYNC REPLICA problem_table;
阶段二:修复孤立parts
对于无法自动转换的parts,采用重建策略:
sql复制-- 创建临时表存储受影响分区数据
CREATE TABLE problem_table_temp AS problem_table;
-- 导出问题分区数据
INSERT INTO problem_table_temp
SELECT * FROM problem_table
WHERE date = '2023-07-15';
-- 删除问题分区
ALTER TABLE problem_table DROP PARTITION '20230715';
-- 重新导入数据
INSERT INTO problem_table
SELECT * FROM problem_table_temp
WHERE date = '2023-07-15';
-- 清理临时表
DROP TABLE problem_table_temp;
4.3 验证步骤
修复后需要验证:
-
检查mutation状态:
sql复制SELECT is_done FROM system.mutations WHERE table = 'problem_table' ORDER BY create_time DESC LIMIT 1; -
监控Merge进度:
sql复制SELECT elapsed, progress, num_parts FROM system.merges WHERE table = 'problem_table'; -
确认parts状态:
sql复制SELECT COUNT() FROM system.parts WHERE table = 'problem_table' AND active;
5. 经验总结与最佳实践
5.1 Schema变更的黄金法则
通过这次事故,我们总结了ClickHouse Schema变更的注意事项:
- 变更时机:选择业务低峰期执行,避免与数据写入高峰重叠
- 批量操作:多个ALTER语句尽量合并执行,减少mutation记录
- 监控指标:重点关注system.mutations和system.merges表
- 版本规划:重大Schema变更应考虑创建新表而非修改现有表
5.2 生产环境操作清单
我们建立了新的变更流程:
-
预检查:
sql复制-- 预估影响范围 EXPLAIN ALTER TABLE problem_table DELETE COLUMN obsolete_column; -
变更执行:
sql复制-- 使用ON CLUSTER确保所有节点同步 ALTER TABLE problem_table ON CLUSTER main_cluster DELETE COLUMN obsolete_column; -
事后验证:
sql复制-- 检查所有副本状态 SELECT replica_name, is_session_expired FROM system.replicas WHERE table = 'problem_table';
5.3 监控体系增强
我们在Prometheus中新增了以下监控项:
clickhouse_mutations_unfinished:未完成的mutation数量clickhouse_merges_blocked:被阻塞的Merge任务数clickhouse_parts_per_partition:每个分区的parts数量
配置了分级告警:
- 当mutation超过1小时未完成触发Warning
- 当Merge队列超过500触发Critical
6. 深度优化建议
6.1 参数调优参考
根据这次经验,我们调整了以下关键参数:
xml复制<!-- 增加mutation并发处理能力 -->
<max_number_of_mutations_in_queue>100</max_number_of_mutations_in_queue>
<number_of_free_entries_in_pool_to_execute_mutation>1000000</number_of_free_entries_in_pool_to_execute_mutation>
<!-- 优化Merge策略 -->
<max_bytes_to_merge_at_max_space_in_pool>107374182400</max_bytes_to_merge_at_max_space_in_pool>
<max_replicated_merges_in_queue>30</max_replicated_merges_in_queue>
6.2 自动化修复方案
我们开发了自动化修复脚本,主要逻辑包括:
- 检测长期未完成的mutation
- 识别受影响的分区范围
- 自动创建临时表迁移数据
- 重建问题分区
- 验证数据一致性
python复制def fix_stuck_mutation(table_name):
# 获取未完成的mutation
mutations = ch_query(f"""
SELECT mutation_id FROM system.mutations
WHERE table='{table_name}' AND is_done=0
AND create_time < now() - interval 1 hour
""")
for mut in mutations:
# 找出受影响parts
parts = ch_query(f"""
SELECT partition_id FROM system.parts
WHERE table='{table_name}'
AND mutation_pointer={mut['mutation_id']}
GROUP BY partition_id
""")
for part in parts:
rebuild_partition(table_name, part['partition_id'])
6.3 长期架构建议
对于频繁Schema变更的场景,建议考虑:
- 宽表设计:使用JSON列代替频繁变更的列
- 版本化表:通过表名后缀区分不同Schema版本
- 物化视图:将易变字段抽取到单独表中
- 分布式DDL:确保所有节点同步执行Schema变更
