做ClickHouse集群维护时间长了,你会发现"数据倾斜"这四个字根本绕不开。分布式表看起来是一张逻辑大表,数据实际上被打散到多个分片上,只要分片策略和分区策略没设计好,某几个节点的磁盘和CPU就会莫名其妙飙高,而其他节点闲得无事可做。更要命的是,倾斜不会像节点宕机那样直接报警,它藏在查询变慢、后台merge堆积、副本同步延迟这些"慢性病"里,等你察觉的时候,整体性能已经被拖累得不成样子。
这篇文章我会把ClickHouse里倾斜问题的原理、排查套路、设计对策和一次真实的治理过程完整梳理一遍。文章里的SQL语句和数据样例我都做了脱敏处理,但逻辑可以直接复用到你现在的集群上。不管你是刚接触分布式表的新手,还是正在为凌晨跑批越来越慢发愁的维护老兵,这篇内容都应该能帮上忙。
1. 先搞清楚:ClickHouse里的数据倾斜到底长什么样
1.1 分布式表的数据路由机制到底是怎么回事
ClickHouse的分布式表(Distributed引擎)本身不存储任何数据,它只是一个路由转发层。写入时,分布式表会根据你在建表时指定的分片键(sharding key)计算出一个哈希值,然后把这个值映射到某个分片上,数据最终落到该分片对应的本地表里;查询时,分布式表把请求下推给所有分片,等各分片返回结果后再做汇总。
这里有个最容易踩的认知误区:很多人觉得分片键选了高基数字段,数据就一定均匀。实际上ClickHouse对分片键做哈希取模之后,数学上确实能做到"每个分片拿到的哈希值数量差不多",但"哈希值数量均匀"不等于"业务数据均匀"。举个例子,你按用户ID做分片键,大致的哈希分布很均匀,但如果业务中头部用户产生的日志量是普通用户的几千倍,那么这些头部用户对应的哈希值被分到哪个分片,哪个分片的存储就会膨胀,查询流量也会跟着集中过去。
这里要特别强调一个概念:ClickHouse的倾斜分为两种,一种叫存储倾斜,表现为各分片的数据量差异巨大;另一种叫查询倾斜,数据分布本身没问题,但查询条件里的热点值让某几个分片承担了绝大部分扫描和计算量。很多人排查倾斜时只盯着前者,结果把表结构改了、数据重导了,查询还是慢,就是因为没分清到底是哪种倾斜在捣乱。
1.2 倾斜的典型表现:从磁盘曲线到跑批超时
存储倾斜最常见的现象是各节点磁盘使用率差距明显。正常情况下,一个设计合理的集群各分片磁盘使用率差距应该在几个百分点以内,一旦某个分片比其他分片高出20%以上,基本就可以判断存储倾斜已经存在。你会在监控面板上看到一条"阶梯状"的磁盘曲线,其中一个节点的曲线远远高出其他节点,而且这个差距还会随着时间推移越拉越大。
比磁盘更隐蔽的是parts数量倾斜。ClickHouse的每个分区由若干不可变的part文件组成,后台线程会不断把这些part合并成更大的part。如果某个分片的数据写入量特别大,它的parts生产速度就会超过合并速度,表现为该分片的parts数量持续上涨,而其他分片是平稳的。parts数量多了以后,查询时要打开的文件数变多,扫描效率下降,ZooKeeper上的元数据同步压力也会变大,最终整个集群的稳定性都会被拖累。
查询倾斜的表现则集中在CPU和查询延迟上。我见过不少案例:集群整体CPU使用率只有30%,但某个节点的CPU已经打满,而且所有慢查询都出现在跟这个节点相关的分片上。用system.query_log一查,你会发现同样的查询发到各分片后,热点分片的read_rows是其他分片的几十倍,查询总耗时被这个慢分片死死卡住——ClickHouse的分布式查询是短板效应,最慢的那个分片决定了整条SQL的响应时间。
2. 为什么ClickHouse更容易遇到倾斜
2.1 分片键选错是最大的坑
ClickHouse的分布式表在创建时如果显式指定了分片键,比如ENGINE = Distributed(cluster, db, table, user_id),那么写入时相同user_id的数据一定会被路由到同一个分片。问题就出在这里:业务访问模式天然存在热点,少数热门用户、热门项目、热门店铺贡献了大部分数据流量。
很多从关系型数据库迁移过来的团队,第一反应是"选业务主键做分片键,这样同一个业务实体的数据能落在一起,查询方便"。这句话本身没错,但对于数据倾斜问题来说,业务主键恰恰是最危险的选择。以我见过的一个日志系统为例,他们按项目ID分片,结果三个头部项目的日志量占了全量数据的80%,这三个项目的分片很快被打爆,存储、CPU、内存全线告急,而剩下的十几个分片加起来才用了一点点资源。
本质上,ClickHouse的分布式表没有像一些MPP数据库那样提供动态数据重分布机制。它把分片决策完全前置到了写入那一刻,一旦分片键设计得不合理,后期改造成本极高。这也是为什么很多人把ClickHouse的分片键形容为"一次设计、长期买单"。
2.2 分区键也会制造局部热点
分片键决定数据落在哪个节点,分区键决定数据在本地表里怎么组织。两者职责不同,但分区键选错了同样会造成倾斜——只不过这时候的倾斜不是发生在节点之间,而是发生在单个节点内部。
如果你用某个业务维度字段做分区键,比如按店铺ID分区,那么某个超级店铺的数据在该节点本地会被单独划分成一个巨大的分区目录。后台的merge任务在处理这个巨大分区时,CPU和磁盘IO开销会明显高于其他分区,而查询如果经常命中这个分区,单节点内部的扫描热点就形成了。
另外,分区键的粒度也需要克制。粒度过粗(比如只按年份分区),一年数据堆积在同一个分区里,part文件巨大,合并任务权重失衡;粒度过细(比如按小时分区),分区数量膨胀,每个分区都要在ZooKeeper上注册元数据,元数据压力反而成了瓶颈。从我自己的实践经验看,日志类和时序类数据按天分区是性价比最高的选择,既能控制分区数量,又能为TTL删除提供细粒度操作空间。
2.3 副本同步与大查询的放大链路
ClickHouse的高可用依赖多副本机制,通常使用ReplicatedMergeTree表引擎。在副本模式下,每个shard内的主副本写入新part后,需要通过ZooKeeper协调把part同步给其他副本。这里有一个很多人没意识到的放大效应:如果某个分片因为分片键设计不合理而写入量特别大,那么它产生的part数量、part大小都远超其他分片,副本同步流量和ZooKeeper上的操作频率都会跟着放大。
这会造成一个恶性循环:存储倾斜引发副本同步倾斜,副本同步倾斜又拖慢ZooKeeper的整体响应,进而影响所有分片的元数据操作。我遇到过一例,集群里某个分片落后了其他分片几个小时,排查到最后发现根因就是写入倾斜导致该分片的part生产速度太快,复制队列堆积,甚至把整个ZooKeeper集群的读写延迟都拉高了。这种"一根针捅破一片天"的故障模式,在ClickHouse的分布式架构里相当典型。
2.4 不要忽视"最终聚合节点"的倾斜
分布式表做GROUP BY聚合时,ClickHouse的执行过程是两阶段的:先在各个分片上做局部聚合,再把局部聚合结果汇总到发起查询的节点上做最终聚合。如果聚合维度本身是个低基数字段,但某个值的数据量特别大(还是那个大租户的问题),那么最终聚合节点接收到的数据量也会严重不均衡。
这种倾斜在字节数上看不出来,因为原始数据分布是均匀的,但聚合计算和网络传输的压力会集中在最终聚合节点上。表现就是查询一开始各分片都很快,到了最后的聚合阶段突然卡住,或者发起查询的那个节点OOM。定位这种问题不能只靠system.parts,而是要结合慢查询日志和执行计划来分析。
3. 排查与定位:怎么判断你的集群已经倾斜了
3.1 从system表出发,先做定量体检
排查倾斜的第一步是量化现状,不要凭感觉。最简单的办法是登录到每个分片,查询本地表的数据量分布。如果分片数量不多,可以直接在每个节点上执行:
SELECT hostName() AS node, sum(bytes_on_disk) AS total_bytes, count() AS part_count, sum(rows) AS total_rows FROM system.parts WHERE (database = 'default') AND (table = 'your_local_table') AND active GROUP BY node ORDER BY total_bytes DESC;把各节点结果放在一起看,很快就能判断出是否存在存储倾斜。我一般会盯着两个指标:一个是各节点total_bytes的最大值与最小值之比,如果大于1.5,说明已经有明显的存储倾斜;另一个是part_count的差异,如果某个节点的parts数量特别多,说明合并速度跟不上写入速度,这个节点正在"积食"。
如果表是通过分布式表写入的,还可以直接用cluster表函数在一条SQL里完成多分片统计。新版本ClickHouse支持这种方式,写起来非常方便:
SELECT shard_num AS shard, sum(rows) AS total_rows, formatReadableSize(sum(bytes_on_disk)) AS total_size, count() AS parts FROM cluster('your_cluster', system, parts) WHERE (database = 'default') AND (table = 'your_local_table') GROUP BY shard ORDER BY shard;3.2 用一行SQL量化你的倾斜度
量化倾斜度不需要复杂的监控系统,在SQL里直接算就行。我习惯用一个简化版的"倾斜系数"来度量:倾斜度 = (最大值 - 最小值) / 平均值。这个值越接近0说明分布越均匀,大于0.5就值得关注,大于1基本可以确认需要治理了。
WITH stats AS ( SELECT shard, sum(rows) AS total_rows FROM cluster('your_cluster', system, parts) WHERE (database = 'default') AND (table = 'your_local_table') GROUP BY shard ) SELECT max(total_rows) AS max_rows, min(total_rows) AS min_rows, avg(total_rows) AS avg_rows, round((max(total_rows) - min(total_rows)) / avg(total_rows), 2) AS skew_factor FROM stats;这个查询跑出来的结果可以作为治理前后的对比基线。我在实际项目中会把这条SQL做进巡检脚本,每周自动跑一次,一旦skew_factor超过阈值就触发告警。倾斜是渐进式问题,越早发现越容易处理,拖到大分片磁盘整块占满再动手,就只能停机重导数据了。
这里也提醒一句:如果查询的是system表里某个大宽表,统计时要在WHERE条件里加上active过滤,因为被合并后标记为inactive的part还在磁盘上,不加active会把已经被合并掉的历史part也统计进去,导致结果虚高。
3.3 从查询日志里揪出热点分片
存储倾斜好排查,查询倾斜则需要看system.query_log。我常用的办法是找一个典型的慢查询,对比它在不同分片上的扫描行数。ClickHouse的分布式查询日志里会记录每个分片的执行详情,你可以通过查询ID来聚合:
SELECT shard_num AS shard, sum(read_rows) AS total_read_rows, sum(read_bytes) AS total_read_bytes, max(query_duration_ms) AS max_duration_ms FROM system.query_log WHERE (query_id = 'your_query_id') AND (event_time >= today() - 1) GROUP BY shard ORDER BY total_read_rows DESC;如果某个分片的total_read_rows明显高于其他分片,而且这个分片恰好在之前的存储检查里也是"偏胖"的那个,那基本可以断定是分片键选型导致的查询倾斜。
还有一种情况值得注意:数据分布是均匀的,但查询条件中的高频值让某个分片承担了更多扫描。比如你按事件ID分片,数据分布很均匀,但业务里某个头部事件每天被查询几百次,每次查询都要扫描存储该事件的那几个分片,这几个分片的CPU自然会被打高。这种倾斜从磁盘上看不出来,必须要结合查询频率来定位。
4. 解决方案:从写入端到表结构设计的完整对策
4.1 分片键的三种安全打法
先说结论:分片键的选择优先级应该是"高基数的自然字段 > 业务主键加盐 > 随机分片",而不是反过来。
第一种,如果业务里存在一个天然高基数且分布均匀的字段,比如订单ID、事件ID、消息ID,直接拿它做分片键是最省心的。这类字段每个取值的数据量差异不大,哈希之后能保证各分片的写入和查询都比较均匀,而且查询按这个字段路由时还能精确命中单个分片,减少了跨分片扫描的开销。
第二种,如果不得不使用业务主键(比如经常按用户ID查明细),就要引入加盐机制,我下面单独讲。
第三种,如果查询模式根本没有明确的单值路由需求,大部分查询都是全表聚合、按时间过滤,那么直接用rand()做分片键也无妨,它能让写入在数学上绝对均匀。我之前维护的一套日志系统,查询基本都是按天做聚合统计,很少要求"查某个用户的全部数据",这种情况下用rand()反而最稳。
这里要提醒一下:很多人会混淆"分片键"和"分区键"。分片键控制数据分布在哪些节点,分区键控制数据在单节点内如何组织。设计表结构时两者要独立考虑,不要为了少写一个参数就把分片键和分区键设成同一个字段。
4.2 加盐策略的取舍与落地细节
加盐的核心思路是:给热点数据"拆人",让原本只能落在单个分片的数据块,通过多个盐值分散到不同分片。具体做法是在原始业务ID后面拼接一个盐值,比如把user_id=10001变成10001_1、10001_2、10001_3,分片键用拼接后的字符串哈希。
加盐策略在实现上要解决两个问题:哪些数据需要加盐,以及加多少个盐。我在实际项目里用的策略是:对小数据量的ID不加盐,只对头部的大ID加盐。具体做法是先用SQL统计出数据量Top N的ID,比如Top 200,给这200个ID各分配8到16个盐值,普通ID保持原样。加盐后的分片键表达式可以写成这样:
ENGINE = Distributed( your_cluster, default, your_local_table, xxHash32(concat(toString(user_id), '_', toString(salt_column))) )这里的salt_column需要在写入时由应用层计算好,或者通过物化视图的default表达式自动生成。一个常用的物化字段写法是:
ALTER TABLE your_local_table ADD COLUMN salt_column UInt8 DEFAULT 0;然后在写入端控制逻辑:如果是头部ID,随机生成1到N之间的盐值,普通ID的盐值固定为0。
加盐之后需要有底线认知:加盐和"精确路由"是矛盾的,二者不可兼得。加了盐的ID,查询时你无法确定它落在哪个分片,所以查询条件必须从WHERE user_id = 10001改成WHERE user_id = 10001 AND salt_column IN (0,1,2,...,N),或者干脆不加salt条件,接受跨全分片扫描。在实际业务里,头部ID的查询往往本身就需要处理大数据量,多扫描几个分片其实可以接受,换来的均匀分布反而让整体查询更快。这也是加盐策略能成立的根本原因。
4.3 分区键粒度怎么定才不给自己埋雷
分区键的设计原则可以用一句话概括:分区键只负责"裁剪",不负责"分布"。它的核心价值是让查询在扫描时能快速跳过无关数据,以及让TTL删除能按块清理。所以分区键应该选择查询中最常见的过滤维度,通常是日期字段。
对于按天分区的表,分区表达式写法很固定:
ENGINE = MergeTree() PARTITION BY toYYYYMMDD(event_time) ORDER BY (user_id, event_time)这里不建议用toYYYYMM(event_time)按月分区,除非你的单月数据量特别小。按月分区会导致一个分区里包含几十上百个part,查询时扫描的文件数量膨胀,合并压力也大。我踩过的坑是:一开始图省事按月分区,结果单月数据量过亿,每天写入的新part都在同一个大分区里互相合并,后台merge线程长期高负荷运行,查询也明显变慢,后来改成按天分区后问题立刻缓解。
还有一点:不要在分区键里放高基数字段。比如按user_id的某个维度分区,会造成几十万个分区目录,ZooKeeper元数据承受不了,整个集群性能都会断崖式下跌。分区键一定要选低基数的、适合做范围裁剪的字段。
4.4 写入端均匀化的实操办法
有时候表结构已经定死了,分片键也改不了,这时候想缓解倾斜,能动手的就是写入端。
第一个办法是在写入任务层面做轮询。如果你的数据源本身有多个分区或分片,在往ClickHouse写入时,可以按数据源分区轮流写入,避免单批数据长时间集中打向同一个ClickHouse分片。比如Kafka消费程序,可以根据消息的key做二次哈希,把消息分散到不同的写入批次里,再交给分布式表。
第二个办法是利用分布式表的异步写入机制。ClickHouse分布式表写入时,数据会先缓存在本地,然后由后台线程转发到各个分片。你可以通过设置distributed_background_insert_batch_size和distributed_background_insert_number_of_tasks来调节转发节奏。不过这个方法对倾斜的帮助有限,它只是平滑了写入压力,不会改变数据最终的路由结果。
第三个办法是重新审视数据入库前置处理。很多倾斜源于上游数据本身分布不均匀,比如某个Kafka分区里天然集中了某个大租户的数据。这种时候在接入层做"打散"比在ClickHouse层做任何操作都有效:解析消息时重新计算一个临时ID,用临时ID做写入分片键,同时把原始业务ID作为普通列存下来,查询时再用原始ID过滤。
4.5 已倾斜的历史数据怎么救
分片键和分区键的修改只对新建数据生效,已经倾斜的历史数据只能通过重新导入来纠正。方法是把旧表数据读出来,按新分片键写入一张新表。具体两步走:
第一步,建好新表和新分布式表,分片键按修正后的方案设置。
第二步,用INSERT INTO ... SELECT从旧表搬数据。如果数据量很大,建议分批执行,比如按日期段划分任务,避免一次性导入把集群资源打爆:
INSERT INTO new_local_table SELECT * FROM old_local_table WHERE event_date = '2024-06-01'; INSERT INTO new_local_table SELECT * FROM old_local_table WHERE event_date = '2024-06-02';注意这个操作要在每个分片上对本地表执行,而不是通过分布式表执行。通过分布式表写入会再次按照分布式表的分片键做路由,可能导致数据再次不均匀——除非你已经把分布式表的分片键改对了。所以稳妥的流程是:先在每个分片上暂停写入,再通过本地表之间的INSERT INTO ... SELECT把旧数据按新分区键导入,最后切换分布式表指向新本地表,并把旧表清理掉。
如果是ReplicatedMergeTree表,可以直接利用ClickHouse的remote表函数做跨节点数据搬迁,也可以用备份恢复的方式导入,但核心逻辑都一样:重新入库,让数据按新策略分布。
5. 实战案例复盘:一次日志系统的倾斜治理过程
5.1 故障现象:一个凌晨必现的"慢性病"
这个案例是我们一个模拟项目X的日志分析系统,场景很典型:上游日志统一进入Kafka,再通过消费程序写入ClickHouse,查询端主要是按项目ID和时间范围统计日志量、错误率等指标。
系统上线初期一切正常,运行两个月后开始出现规律性故障:每天凌晨的定时跑批任务越来越慢,从最初的30分钟涨到将近3小时,而且几乎每次都是同一个分片节点CPU先打满,报出大量慢查询。白天实时查询的响应时间也在恶化,部分看板接口从秒级响应退化到十几秒。
5.2 定位过程:从现象到根因的三步诊断
第一步是确认是否存在存储倾斜。用前面的system.parts统计SQL跑了一遍,结果让人意外:各分片的字节数和行数差距并不大,最大最小值比在1.3左右。初步判断存储倾斜不是主因。
第二步查查询日志。凌晨跑批的SQL固定是按project_id做GROUP BY统计,我把跑批的query_id拿出来,对比各分片上的read_rows,发现有一个分片读取了全量数据的45%,而其他分片平均只有10%左右。很明显,这是典型的查询倾斜。
第三步深入查数据分布。我按project_id统计了一下各项目的日志量,发现Top 10项目的日志量占了全量的75%,其中最大的项目占了33%。由于分片键用的是project_id,这10个大项目的数据被哈希到少数几个分片,这几个分片在凌晨跑批时承受的扫描和聚合量远超其他分片,成了名副其实的热点分片。
5.3 调整方案:换分片键与预聚合双管齐下
诊断明确后,我们做了三个动作。
第一个动作:把分片键从project_id改成拼接盐值的方式。由于这个系统的主要查询是按项目ID + 时间范围做聚合,我们只对日志量Top 200的项目ID做加盐处理,每个大项目分配16个盐值,普通项目盐值为0。分片键表达式用xxHash32(concat(toString(project_id), '_', toString(salt)))。这样大项目的数据被铺开到多个分片,跑批查询就不会再把压力集中在一两个分片上。
第二个动作:新建一张预聚合表。跑批任务改成先写入按天、按项目、按错误类型维度的预聚合表,业务看板直接查询预聚合结果。这样即使底层明细数据仍然存在一定热点,高频查询也不再直接打明细表。
第三个动作:调整查询路由。加盐之后,精确命中单分片已经不可能了,所以跑批SQL改为跨全分片扫描,然后利用分布式表的分布式聚合能力在本地分片做部分聚合,再汇总最终结果。
5.4 治理结果与后续观察
改造完成后,各分片在凌晨跑批期间的CPU使用率从最高100%降到了平均55%左右,跑批总耗时从接近3小时压缩到40分钟内。各分片的扫描行数分布也趋于均匀,最大最小的比值从4.5降到了1.2左右。
更重要的一点是,这次治理让整个集群的稳定性上了一个台阶。之前因为热点分片长期高负荷,其他分片还要被动承受ZooKeeper元数据同步变慢的连带影响;改造后各分片负载均衡,部分副本落后、parts堆积这些衍生问题也一并消失了。
这次项目给我留下的教训是:分布式环境下做设计,永远要把"数据分布"放在"查询便利"前面想。分片键选型时多花半小时评估数据分布特征,后面能省下好几个通宵的排查时间。
6. 常见问题速查与避坑清单
| 问题现象 | 常见原因 | 推荐解法 |
|---|---|---|
| 某分片磁盘使用率远高于其他分片 | 分片键选了低基数或热点集中的字段 | 改为高基数字段或加盐策略,必要时重导数据 |
| parts数量只增不减 | 单分片写入过快,后台merge跟不上 | 检查分片键均衡性,调整分布式表写入参数 |
| 凌晨跑批时间远超白天查询 | 定时SQL扫描了大分区,热点分片拖慢整体 | 增加预聚合表,优化查询SQL,考虑分片键改造 |
| 某节点CPU持续打满,其他节点空闲 | 查询条件命中了少数热点分片 | 结合query_log定位热点值,对热点值加盐打散 |
| 查询在最终聚合阶段卡住或OOM | 聚合维度存在超大取值,汇总数据集中 | 使用两阶段聚合,对大key加盐,或改用物化视图预聚合 |
| 分区目录数量爆炸 | 分区键粒度太细或用了高基数字段 | 分区键改用日期且控制在天的粒度 |
再补充几条实际经验:
- 排查倾斜不要只盯着磁盘字节数,part数量、查询扫描行数、ZooKeeper操作频率都要看,多维度交叉验证才不容易误判。
- 加盐后一定要同步改造下游查询SQL,否则会出现明明数据在库里却查不出来的问题。修改前先在测试环境把新旧分片键的查询路径完整对比一遍。
- 对大表做分片键改造时,尽量在业务低峰期执行,而且要先准备好回滚方案。重导1亿条数据和重导10亿条数据是完全不同的工程。
- ClickHouse的
rand()分片适合高吞吐写入、全表聚合场景,但它牺牲了单值精确路由的能力。不要为了均匀而盲目使用,要结合查询模式做取舍。 - 如果你的查询经常以某个业务ID做精确过滤,又不希望这个ID造成热点,务实的方案是接受跨分片扫描,用并行度换取均衡性,而不是幻想让单分片既承担热点又保持高效。
最后再分享一个我在多次排查中总结出来的习惯:把倾斜巡检做成自动化。用cron定期跑那些system表和query_log的统计SQL,把倾斜系数和热点分片排行输出到一张监控表里,再配上告警。数据倾斜不是一次性故障,它是一种持续累积的状态问题,只有靠定期巡检才能把苗头扼杀在早期。