Petabyte 级 ClickHouse 集群上做了一次看似合理的分区调整,结果核心计费任务集体卡死。监控面板一片绿,错误日志寥寥无几——问题不在数据量,不在磁盘 IO,而是藏在查询规划器里的一把锁。
从"正常"到"停滞"只有一步
故事的开头很典型:团队为了优化查询性能,调整了一张大表的分区键。变更上线后,写入和日常查询看起来一切正常,但每天凌晨跑的计费聚合任务突然从 30 分钟拖到数小时,甚至无法完成。
第一反应是看系统表 system.metrics、system.processes,CPU 和内存占用平稳,磁盘 IO 没有突增。system.query_log 里也没有报错——查询都在"运行中",就是不结束。
这种"无声的卡死"比报错更难定位。报错至少给你方向,卡死只给你沉默。
逐层剥开:从查询卡住到锁竞争
排查的第一步是看哪些查询在等什么。在 ClickHouse 中,可以通过 system.processes 观察当前活跃查询,再结合 system.query_log 的 query_duration_ms 做趋势对比。
关键发现:计费任务涉及的多条聚合查询几乎同时进入"长时间运行"状态,但 CPU 占用极低。这意味着查询不是在计算,而是在等。
接下来用 ClickHouse 自带的追踪手段进一步确认:
-- 查看当前所有正在执行的查询及其等待时间
SELECT
query_id,
query,
query_start_time,
now() - query_start_time AS elapsed_seconds,
thread_ids,
peak_memory_usage
FROM system.processes
ORDER BY elapsed_seconds DESC
LIMIT 20;
结果显示多条查询的 elapsed_seconds 持续增长,peak_memory_usage 却几乎为零——典型的"等锁"特征。
进一步,通过 ClickHouse 的 system.trace_log(需事先开启查询追踪)可以看到查询规划阶段的耗时分布:
-- 如果开启了 trace_log,查看规划阶段耗时
SELECT
trace_type,
count() AS cnt,
avg(duration_ns) / 1e6 AS avg_ms
FROM system.trace_log
WHERE query_id IN ('<卡死查询的query_id>')
GROUP BY trace_type
ORDER BY avg_ms DESC;
追踪数据指向查询规划器(QueryPlan 构建阶段)耗时异常。结合分区变更的上下文,根因浮出水面:新的分区键导致查询规划器需要处理远更多的分区裁剪路径,而规划器内部的全局锁在并发场景下被激烈争抢。
锁在哪:ClickHouse 查询规划器的瓶颈机制
ClickHouse 的查询规划流程大致是:解析 SQL → 构建逻辑计划 → 优化(分区裁剪、投影选择等)→ 生成物理执行计划。在分区数量激增后,优化阶段的分区裁剪逻辑需要遍历更多分区元数据,这个过程持有一把内部互斥锁。
当数十个计费聚合查询同时进入规划阶段,每个查询都要争抢这把锁来读取和裁剪分区信息。结果是:查询排队进入规划器,规划时间从毫秒级膨胀到秒级甚至分钟级,而执行阶段反而很快——但整体耗时被规划阶段完全拖垮。
这不是数据量问题,不是 IO 问题,是并发规划时的锁竞争问题。单条查询跑没问题,并发一上就卡死。
实操:如何检测和缓解 ClickHouse 规划器锁竞争
以下是一套可落地的检测与缓解方案,适用于任何大规模 ClickHouse 集群。
1. 开启查询追踪,捕获规划阶段耗时
在 config.xml 中启用追踪:
<!-- /etc/clickhouse-server/config.xml -->
<query_trace_log>
<database>system</database>
<table>trace_log</table>
<flush_interval_milliseconds>7500</flush_interval_milliseconds>
</query_trace_log>
重启后,system.trace_log 会记录查询各阶段的耗时。定期检查规划阶段占比:
-- 统计最近1小时内查询规划阶段耗时占比
SELECT
query_id,
sum(duration_ns) / 1e9 AS total_trace_sec,
sumIf(duration_ns, trace_type = 'QueryPlan') / 1e9 AS plan_sec,
plan_sec / total_trace_sec AS plan_ratio
FROM system.trace_log
WHERE event_date = today()
AND event_time >= now() - INTERVAL 1 HOUR
GROUP BY query_id
ORDER BY plan_ratio DESC
LIMIT 10;
如果 plan_ratio 超过 0.5 且 plan_sec 远大于单查询正常值(通常 < 0.1s),就要警惕锁竞争。
2. 监控并发规划查询数
-- 实时查看处于规划阶段的查询数量(通过极低内存+长时间运行特征推断)
SELECT count() AS stuck_in_planning
FROM system.processes
WHERE peak_memory_usage < 1000 -- 几乎未分配内存
AND now() - query_start_time > 5; -- 运行超过5秒但内存极低
这个数字如果和你的并发任务数接近,基本可以确认规划器锁是瓶颈。
3. 缓解措施
短期:降低并发度。 计费任务如果用并发聚合加速,改为串行或低并发(2-3 个查询同时跑),锁竞争立即缓解:
# 示例:将原本10并发改为3并发执行计费聚合
# 假设原始脚本用 xargs -P10 并发,改为 -P3
cat billing_queries.sql | xargs -I{} -P3 clickhouse-client --query "{}"
中期:减少分区数量。 评估分区键是否过度细分。ClickHouse 单表分区超过几千个时,元数据遍历成本急剧上升:
-- 检查当前表的分区数量
SELECT count() AS partition_count
FROM system.parts
WHERE database = 'billing_db' AND table = 'billing_events'
AND active = 1;
如果 partition_count 超过 5000,考虑合并分区键粒度(例如从 toYYYYMMDD 改为 toYYYYMM):
-- 重新定义分区键(示例:从按天改为按月)
ALTER TABLE billing_events
MODIFY PARTITION BY toYYYYMM(event_time);
注意:
MODIFY PARTITION BY会触发底层数据重组,Petabyte 级表需在低峰期操作并预留充足时间。
长期:补丁修复。 原文团队的做法是向上游 ClickHouse 提交补丁,将规划器中的全局锁改为更细粒度的锁(例如按表或按分区组加锁),从根本上降低争抢范围。如果你有 C++ 能力,可以参考 ClickHouse 源码中 QueryPlanOptimize 相关的互斥锁逻辑,提交或应用社区 patch。
4. 自动化检测脚本
把检测写成定时任务,每天跑一次:
#!/bin/bash
# check_planning_lock.sh — 每小时检测规划器锁竞争迹象
CH_CLIENT="clickhouse-client --host=ch-node1 --user=monitor --password=xxx"
STUCK_COUNT=$($CH_CLIENT --query="
SELECT count() FROM system.processes
WHERE peak_memory_usage < 1000 AND now() - query_start_time > 10
")
PARTITION_COUNT=$($CH_CLIENT --query="
SELECT count() FROM system.parts
WHERE database = 'billing_db' AND table = 'billing_events' AND active = 1
")
echo "$(date): stuck_in_planning=$STUCK_COUNT, partition_count=$PARTITION_COUNT"
# 告警阈值:超过5个查询疑似卡在规划阶段 或 分区数超过3000
if [ "$STUCK_COUNT" -gt 5 ] || [ "$PARTITION_COUNT" -gt 3000 ]; then
echo "ALERT: potential planner lock contention" >&2
# 可接入 PagerDuty / 钉钉 / Slack 告警
fi
经验与权衡
| 做法 | 收益 | 风险/代价 |
|---|---|---|
| 降低计费任务并发度 | 立即缓解锁竞争,零改动 | 总耗时可能略增(串行化) |
| 合并分区键粒度 | 减少分区数,降低规划器遍历成本 | 查询裁剪精度下降,需评估业务影响 |
| 应用细粒度锁补丁 | 根治并发规划瓶颈 | 需维护自定义分支,跟进上游版本 |
| 开启 trace_log | 持续可观测规划阶段耗时 | 额外存储和微量性能开销 |
核心教训: ClickHouse 的性能瓶颈不一定在执行阶段。分区变更后,务必关注分区数量对查询规划器的影响,尤其在高并发场景下。标准监控指标(CPU、IO、错误率)对锁竞争几乎无感——你需要专门看"查询在等什么",而不是"查询在算什么"。
下次做分区变更前,跑一遍上面的分区数量检查和并发规划检测脚本,比事后排查省几天时间。