1. ClickHouse Schema修改引发的Merge阻塞问题深度解析
作为一名长期与ClickHouse打交道的工程师,我最近遇到一个由Schema修改引发的Merge阻塞问题,整个过程堪称教科书级的分布式系统故障排查案例。本文将详细记录从问题发现到最终解决的完整过程,并深入分析背后的技术原理。
1.1 问题背景与现象
我们在生产环境中对emo_mdm_flowwork_pt1m_local表执行了添加5个String类型列的DDL操作:
ALTER TABLE default.emo_mdm_flowwork_pt1m_local ON CLUSTER cluster ADD COLUMN IF NOT EXISTS `host` String DEFAULT '' AFTER connType, ADD COLUMN IF NOT EXISTS `path` String DEFAULT '' AFTER host, ADD COLUMN IF NOT EXISTS `referrer` String DEFAULT '' AFTER path, ADD COLUMN IF NOT EXISTS `title` String DEFAULT '' AFTER referrer, ADD COLUMN IF NOT EXISTS `referrerHost` String DEFAULT '' AFTER title;操作看似简单,却引发了连锁反应:
- Mutation阻塞:数据删除操作在部分分片上永久阻塞
- Merge失败:后台Merge任务大量失败,错误日志显示类型转换异常
- 查询异常:涉及新增列的查询会触发相同错误
1.2 初步排查与应急处理
1.2.1 资源池调整无效
我们首先怀疑是MergeAndMutatePool资源不足导致。将background_pool_size从32调整到48后:
# 监控指标变化 ClickHouseMetrics_BackgroundMergesAndMutationsPoolSize: 64 → 96虽然部分分片恢复,但仍有分片持续阻塞,说明不是根本原因。
1.2.2 错误日志分析
关键错误信息反复出现:
Bad cast from type DB::ColumnNothing to DB::ColumnLowCardinality while reading from part /path/to/part堆栈显示问题发生在VerticalMergeStage,且与复杂类型处理相关。特别值得注意的是:
- 报错的不是新增的5个String列
- 问题只出现在添加列之前创建的part中
- 涉及LowCardinality类型的复合列(Map/Array/Tuple)
2. 深入诊断过程
2.1 问题复现条件验证
通过以下查询验证问题触发条件:
-- 不包含新增列的查询 → 正常 SELECT count(1) FROM table WHERE timestampMs BETWEEN... AND flowId IN(...) -- 包含新增列的查询 → 报错 SELECT count(1) FROM table WHERE timestampMs BETWEEN... AND referrer = 'value'2.2 Part元数据分析
对比新旧part的列存储情况:
# 新part包含新增列 ls /path/to/new_part | grep referrer referrer.bin referrer.cmrk2 ... # 旧part缺少新增列 ls /path/to/old_part | grep referrer [无输出]2.3 系统表查询
通过系统表定位受影响范围:
-- 查找所有报错的part SELECT database, table, partition_id FROM system.parts WHERE name IN ( SELECT DISTINCT result_part_name FROM system.merges WHERE last_exception LIKE '%Bad cast%' )结果显示问题集中在emo_mdm_flowwork_*系列表,且只影响添加列前创建的part。
3. 根本原因分析
3.1 ClickHouse的Merge机制
当执行Merge操作时,ClickHouse会:
- 确定Merge算法(Horizontal/Vertical)
- 读取源part数据
- 处理缺失列(新增列场景)
- 写入新part
在Vertical Merge模式下,列是按需读取的,这触发了缺失列的默认值填充逻辑。
3.2 默认值填充的两阶段过程
当读取的part缺少表中存在的列时:
第一次补列:
fillMissingColumns()- 为简单类型填充类型默认值
- 为复合类型准备offset信息
// 示例:为Map类型准备默认数据结构 ColumnPtr column = data_type->createColumn(); column->insertDefault();第二次补列:
evaluateMissingDefaults()- 计算表达式默认值
- 此处发生了
ColumnNothing到ColumnLowCardinality的错误转换
3.3 复合类型的Substream处理
复杂类型的列(如Map(LowCardinality(String), String))通过Substream机制处理:
- SubstreamPath:标识嵌套结构中的具体子列
- Serialization:各类型实现自己的序列化逻辑
SerializationMapSerializationArraySerializationTuple
当处理缺失的复合列时,类型系统未能正确处理LowCardinality子列的默认值构造。
4. 解决方案与验证
4.1 临时解决方案
强制使用Horizontal Merge算法:
SET vertical_merge_algorithm_min_rows_to_activate = 2000000000; SET vertical_merge_algorithm_min_bytes_to_activate = '536870912000';然后执行OPTIMIZE:
OPTIMIZE TABLE emo_mdm_flowwork_pt1m_local PARTITION '20250925' FINAL;4.2 根本解决
通过代码分析发现问题根源:
- 缺失列处理时未正确维护LowCardinality类型信息
- 复合类型的Substream路径处理存在边界条件缺陷
添加调试日志确认问题:
LOG_DEBUG(log, "Missing column: {}, type: {}", column.name, column.type->getName());最终通过以下方式彻底解决:
- 升级到修复该问题的ClickHouse版本
- 对历史part执行强制重写
5. 经验总结与最佳实践
5.1 问题处理流程
- 优先恢复服务:通过调整Merge策略快速止血
- 保留现场:在重启前收集堆栈、日志和指标
- 根因分析:通过代码走查和日志验证假设
- 彻底修复:升级或应用补丁
5.2 ClickHouse运维建议
Schema变更时:
- 避免在生产高峰执行
- 提前评估影响范围
- 考虑使用
ALTER TABLE ... MODIFY SETTING调整Merge策略
监控关键指标:
SELECT * FROM system.mutations WHERE is_done = 0; SELECT * FROM system.replication_queue WHERE last_exception != '';复杂类型使用:
- 谨慎使用多层嵌套的LowCardinality
- 为复杂类型显式指定默认值
5.3 深度技术理解
通过这次事故,我们深入理解了:
Merge算法选择逻辑:
if (sum_rows > settings.vertical_merge_algorithm_min_rows && sum_bytes > settings.vertical_merge_algorithm_min_bytes) return MergeAlgorithm::Vertical;Substream处理流程:
- 通过
ISerialization::enumerateStreams()收集子列 - 每个子列对应独立的存储文件
- 通过
类型系统转换:
ColumnNothing作为占位符- 需要显式处理到目标类型的转换
这次问题的解决过程再次证明:在分布式系统中,表面简单的操作(如加列)可能引发深层次的系统交互问题。关键在于建立系统的全局视角,并掌握从现象到本质的分析方法。