资讯动态

ClickHouse数据倾斜全攻略:从分片键设计到预聚合优化

发布时间:2026/10/21 12:10:31 来源:尧图企业网站定制
数据倾斜这种事在ClickHouse集群里见得多了。表面现象特别典型几十台机器查一个大宽表一台节点CPU打满、内存告急其他节点闲得发呆查询总耗时被这台热点机器拽得死死的。如果业务是销售分析或者日志统计还会出现那个数据量最大的客户一跑报表整个集群都跟着遭殃的局面。这篇文章把我这些年处理ClickHouse倾斜问题的经验整理了一遍覆盖了分片键设计、查询参数调优、表结构预聚合改造以及一套可复现的实验方法和排查命令。适合正在维护ClickHouse集群的DBA、做数据开发的工程师以及刚把ClickHouse引入生产、担心后期倾斜风险的朋友。不用翻文档翻到眼花按文章里的思路走至少能少踩一半的坑。1. 数据倾斜在ClickHouse里到底是什么问题1.1 从一个线上事故说起去年帮一个做流媒体统计的团队排查过一次典型事故。他们的集群一共12个分片存的是播放行为日志单日新增几十亿行。某个周四下午运营跑头部创作者内容播放排行日报查询直接超时。当时点开系统面板看到的情况是分片7的CPU持续100%内存冲到80%磁盘IO打满而分片3、分片9近乎空闲CPU只有个位数。一开始大家以为是查询写得有问题各种调SQL、调max_threads折腾了一下午没效果。后来查了每个分片上的数据行数才发现分片7上存了全集群43%的日志。根因就一句话分布式表的分片键选了创作者ID而头部创作者的日志量是普通创作者的几千倍按创作者ID做hash分布热点ID自然全落到了一个分片。这就是典型的ClickHouse数据倾斜而且它比Hadoop/Spark里的倾斜要更隐蔽一些。MapReduce的倾斜主要发生在Shuffle阶段你有直观的Stage可以看到。ClickHouse里倾斜至少发生在三个位置存储层倾斜分片键选得不好导致数据在分片之间分布不均匀某几个分片磁盘疯涨。计算层倾斜GROUP BY或JOIN时某个key对应的数据量巨大单个分片聚合压力过大拖慢整个查询。分区层倾斜按时间或某低基数列分区某个分区的数据量异常大合并任务耗时和查询扫描量都受影响。三种倾斜经常相互纠缠。比如建表时分区键用app_id同时分片键也用app_id那存储和计算会一起倾斜排查时容易被表象迷惑。1.2 倾斜的本质是负载不均在处理这种问题之前你得先认清一个事实ClickHouse是一个MPP架构的列式数据库它的分布式能力是通过Distributed表引擎体现的。Distributed表本身不存数据只是一个逻辑路由层。写入时它根据分片键表达式计算hash值把行分发到不同分片上的本地表查询时协调节点也就是你连接的那个节点把SQL下推到所有分片再把结果合并返回。这套机制天然假设了一个前提每个分片收到的数据量和查询负载大致相等。一旦分片键的基数低、分布偏斜或者查询条件本身集中这个假设就崩了。ClickHouse的分片没有像Spark那样的动态负载均衡机制它不会自己把热点分片上的数据挪到空闲分片。所以应对倾斜的核心思路就是一句话在设计阶段尽量让数据均匀在查询阶段尽量让热点可控在极端场景用预聚合绕开实时大聚合。不过也别慌ClickHouse虽然不会自动重平衡但它的分片键.查询参数.表引擎特性给足了手动优化的空间。下面逐个说。2. 分片键选不好后面全白搭2.1 高基数、均匀分布、对齐查询条件分片键是ClickHouse分布式表最重要的设计决策没有之一。很多人建表时随便选一个业务主键跑两周发现P99延迟直线上升再想改就得重导全量数据代价非常大。选分片键有三个原则作为压测选型标准第一高基数。基数是这个字段有多少种不同的值。比如订单状态这个字段可能只有十几种值用这种字段做分片键数据最多被打到十几个分片上分片数买再多也没用。而用户ID、设备ID、订单ID这类字段动辄几千万到几十亿的基数hash后才能均匀散列。第二分布均匀。高基数不等于分布均匀。典型反例是内容平台里的创作者ID数量确实很多但头部创作者产生的数据量可能占了八成hash之后头部ID固定落在某几个分片这几个分片就被撑爆了。判断这类字段是否适合做分片键可以先按候选字段做一次GROUP BY统计看看不同取值的记录数是不是接近正态分布如果前10个值占了超过30%的行数这字段单独做分片键就危险。第三和常用查询条件对齐。分片键不光决定数据分布还决定查询能否做分片裁剪。如果你有一个按租户ID查询的高频报表分片键选租户ID配合optimize_skip_unused_shards查询只需命中一个分片比全集群扫描快几十倍。如果分片键和查询条件毫无关系每次查询都要全分片扫描再怎么调优都有限。具体到建表语句Distributed表的最后一个参数就是分片键表达式CREATE TABLE app_logs_dist ON CLUSTER two_shards AS app_logs_local ENGINE Distributed(two_shards, default, app_logs_local, cityHash64(app_id));这里cityHash64(app_id)就是把app_id转成一个64位整数再对这个整数取模分片。ClickHouse不会把原始值直接取模所以不建议写app_id % 2这种而是用hash函数打散。2.2 低基数、单调递增、热点ID三个致命伤我见过最多的错误实践是拿低基数字段做分片键。比如某团队拿客户渠道取值就五六种做分片键集群明明买了20个分片实际只有6个分片上有数据。再比如拿时间戳做分片键看起来分布挺自然但实际上写入的实时数据永远落在今天这个分片形成典型的写倾斜。还有个隐蔽问题拿单调递增字段配合最简单的取模。假设分片键是自增IDID从1涨到10万那么1号数据随机分布还好但如果是批量导入、按ID范围连续生成的同一批次的数据会密集落在同一个分片。这种问题在用number或自增序列生成测试数据时特别容易被忽略生产环境如果你从上游同步一个自增主键表也可能踩中。如果业务上确实需要一个有倾斜风险但又是高频查询条件的字段怎么办两个补救思路一是组合分片键。比如cityHash64(tenant_id, rand())这样同一个租户的数据会被打散到多个分片避免了租户级热点同时由于哈希组合值不重复整体分布也均匀。代价是查某个租户时不能只扫一个分片了需要权衡。二是保底方案核心业务数据先经过一层缓冲表写的时候通过rand()随机分发然后再由定时任务做数据重分布到业务分片表。这种双层结构能有效削峰但会增加一层存储和任务调度不是特别推荐作为第一方案只有当写入端的倾斜已经明显影响写入性能时才值得上。关于rand()这里多说一句。用rand()做分片键写入时是纯随机分发每个分片的数据量一定均匀但代价是查询时所有分片都得参与没法裁剪。所以随机分片不能当作万金油它适合数据模型没法选好分片键、又没有高频精确查询条件的场景。3. 查询端优化让分布式聚合不再被热点拖死3.1 分布式GROUP BY的三个阶段先搞清楚ClickHouse跑一条分布式聚合查询时都干了什么否则调参全靠猜。假设你有一条SQLSELECT user_id, count() FROM logs_dist GROUP BY user_id。协调节点会把这条查询广播到所有分片每个分片上的本地表先按user_id做一次局部聚合Partial Aggregate然后把聚合结果注意不是明细数据返回给协调节点协调节点再做一次全局合并Final Aggregate最后把结果返回给客户端。这个流程里有两个关键点第一每个分片只处理自己那部分数据的聚合如果某个user_id的数据恰好集中在某个分片那个分片的局部聚合压力就会远大于其他分片。第二协调节点合并的是聚合状态而不是全量明细所以正常情况协调节点的压力不会太大但数据量大时聚合状态也可能上GB。倾斜发生时往往是分片层面已经存储不均加上局部聚合又把热点key压在同一节点上。这时最直接的诉求是让每个分片尽量预聚合得更彻底减少返回给协调节点的状态量让协调节点别做太重的合并让查询只访问必要的分片。3.2 四个关键参数逐个调第一个参数是distributed_group_by_no_merge。默认是0也就是分片先做局部聚合再返回聚合状态这是合理的。如果把这个参数改成1分片不预聚合直接把明细数据返回给协调节点统一聚合这会导致协调节点内存暴涨在数据倾斜场景下几乎是自杀行为。除非你明确知道查询命中的数据量很小否则不建议改。有人觉得改成1能让协调节点获得完整数据、算得准其实默认方案的结果精度是一样的没必要牺牲性能。第二个参数是optimize_distributed_group_by_sharding_key。当你的GROUP BY字段恰好和分片键表达式一致时不同分片之间不存在相同的分组键每个分片聚合的结果就是全局结果协调节点只需要拼接不需要合并。开启这个参数能省掉全局合并这步实测在分片键对齐查询的场景下能减少20%到40%的协调节点耗时。注意如果SQL里有WITH TOTALS或ROLLUP这类需要跨分片计算总数的语句优化不会生效。第三个参数是optimize_skip_unused_shards。这是应对查询条件精确命中少数分片的利器。当WHERE条件里包含分片键的等值表达式时开启它可以让协调节点只把查询发到相关分片大概率能把全集群扫描降成单分片扫描。需要注意它只在等值条件、IN下生效对范围条件、也有效但对表达式匹配要求严格。而且如果查询条件被优化器识别不了比如条件表达式和分片键写法不完全一致也不会生效。第四个参数是max_bytes_before_external_group_by。这个参数应对的是聚合状态超大导致内存OOM的问题。倾斜查询里热点key会产生超大聚合状态内存吃紧时就崩了。设置一个阈值比如SET max_bytes_before_external_group_by 200000000200MB当聚合状态超过这个值就溢写到磁盘。注意这个机制跟max_threads有联动实际生产里一般配在100MB到500MB之间太小会频繁写磁盘太大又容易OOM需要压测时调几次。举个实际调优的例子。我用12个分片做GROUP BY查询开optimize_skip_unused_shards1optimize_distributed_group_by_sharding_key1后精确查询单租户报表时扫描的分片数从12降到1查询耗时从8秒降到1秒不到。而如果只开distributed_group_by_no_merge1反而因为协调节点数据量暴增耗时更长了。所以参数的组合很重要不要乱试。4. 表结构设计与预聚合从源头削峰4.1 分区键、ORDER BY键、SAMPLE键的协同说实话很多人把分区键和分片键搞混然后在倾斜问题里越绕越深。分片键决定了数据去哪个物理节点分区键决定了单个节点上数据按什么规则做物理分段。两个都会影响性能但倾斜风险点不同。分区键通常是日期字段按天或按月分区。假设某个大促日的数据量是平时的20倍那么大促日这个分区的数据量就会异常大合并任务跑得慢查询也容易扫描大量数据。ClickHouse对单个分区的大小没有硬性上限但分区太大会影响MergeTree的后台合并效率查询时的partition pruning收益也会下降。所以分区键要选粒度适中的时间窗口日志类一般按天超大流量业务可以按时分。ORDER BY键同时也是主键的作用常被低估。在MergeTree里数据会根据ORDER BY键排序然后按排序键建立稀疏索引。如果ORDER BY键前缀选择了一个低基数列比如status那么同一个status的数据在物理存储上聚在一起某个status对应的数据量大时读取那个status的查询就要扫描大段数据。反过来ORDER BY键前缀选高基数列比如user_id索引过滤效果好但压缩率会下降磁盘占用上升。这里推荐一个经验公式ORDER BY键不要贪多优先选查询WHERE里最常用的等值过滤列且尽量选基数适中的。如果必须要压压缩率再往后追加日期或者低基数列。实际设计上(app_id, event_time)比(event_time, app_id)好因为大多数分析都是先按app过滤。SAMPLE键是MergeTree里容易被忽略的维度。如果你有SAMPLE BY子句那么查询SAMPLE 0.1时ClickHouse会按SAMPLE键做确定性抽样。但如果SAMPLE键的值分布不均抽样结果会有偏差。通常建议用高基数的ID作为SAMPLE键而不是用时间。4.2 物化视图 AggregatingMergeTree绕开实时大聚合如果存储和查询层面的优化都做了某个高频统计仍然因为热点key而慢那就别再硬扛实时聚合了。预聚合是应对倾斜最釜底抽薪的办法。ClickHouse做预聚合有两个经典搭档物化视图和AggregatingMergeTree表引擎。物化视图会在数据写入时自动执行一段SELECT把结果增量写入目标表。AggregatingMergeTree则专门存储聚合状态后台合并时会合并相同分组键的聚合结果。具体套路是这样的。假设原始表还是之前的app_logs_local你想按小时统计每个app的播放时长和次数可以建一张预聚合表CREATE TABLE app_logs_hourly ( app_id UInt32, hour DateTime, play_count AggregateFunction(count), total_duration AggregateFunction(sum, UInt64) ) ENGINE AggregatingMergeTree() PARTITION BY toYYYYMMDD(hour) ORDER BY (app_id, hour);然后建物化视图把增量数据转成聚合状态CREATE MATERIALIZED VIEW app_logs_hourly_mv TO app_logs_hourly AS SELECT app_id, toStartOfHour(event_time) AS hour, countState() AS play_count, sumState(duration_ms) AS total_duration FROM app_logs_local GROUP BY app_id, hour;查询的时候就不要再碰原始大表了直接查预聚合表用countMerge和sumMerge收起聚合状态SELECT app_id, hour, countMerge(play_count) AS play_cnt, sumMerge(total_duration) AS total_dur FROM app_logs_hourly GROUP BY app_id, hour;这套方案的精妙之处在于无论原始数据里某个app的热度有多高预聚合表里每个app每小时最多一行热点key天然被折叠了。同样是跑头部创作者播放排行实时聚合需要扫描几亿行预聚合后只需要扫描几千行聚合结果。我在一个日增50亿行日志的系统里用这套方案把日报查询从2分钟压到2秒效果立竿见影。代价自然也有预聚合表会占用额外的存储和写入时间物化视图是异步消费的数据写入后到视图更新有短暂延迟不强实时。另外如果你在预聚合查询里加的维度比如再加个地区字段不在物化视图的GROUP BY里就得重新建另一张预聚合表灵活性比纯实时聚合低。所以一般只对高频、固定维度的报表用这套。5. 实测构造一个倾斜场景并逐步优化5.1 复现倾斜的建表与造数纸上谈兵没意思我拿一个两分片集群做示例完整走一遍从倾斜到优化对比的流程。假设集群名为two_shards每分片一个副本。先建本地表分区按天主键按(app_id, event_time)CREATE TABLE app_logs_local ON CLUSTER two_shards ( app_id UInt32, user_id UInt64, event_time DateTime, duration_ms UInt64 ) ENGINE MergeTree() PARTITION BY toYYYYMMDD(event_time) ORDER BY (app_id, event_time);建两张分布式表一张按app_id哈希分片一张随机分片用于对比CREATE TABLE app_logs_dist_by_app ON CLUSTER two_shards AS app_logs_local ENGINE Distributed(two_shards, default, app_logs_local, cityHash64(app_id)); CREATE TABLE app_logs_dist_random ON CLUSTER two_shards AS app_logs_local ENGINE Distributed(two_shards, default, app_logs_local, rand());现在模拟倾斜数据制造100个app其中app_id1到3是头部热点每个占200万行其余app各占一两万行。用numbers函数和if条件批量插入INSERT INTO app_logs_dist_by_app SELECT if(number % 100 3, number % 100 1, number % 97 4) AS app_id, number AS user_id, toDateTime(2025-01-01 00:00:00) number % 86400 AS event_time, number % 1000 AS duration_ms FROM numbers(10000000);同样插入到随机分片表保证两张表数据总量一致方便对比。插入完以后查看每个分片上的数据分布SELECT shard_num, hostName() AS host, count() AS row_count, formatReadableSize(sum(bytes)) AS total_size FROM cluster(two_shards, system, parts) WHERE table app_logs_local GROUP BY shard_num, host ORDER BY shard_num;在按app_id分片的表上大概率看到第一个分片对应热点app有七八百万行第二个分片只有两三百万行倾斜度明显。在随机分片表上两个分片的数据量会非常接近差值通常在5%以内。5.2 四轮优化对比第一轮直接跑聚合查询看原始耗时。SELECT app_id, count(), sum(duration_ms) FROM app_logs_dist_by_app GROUP BY app_id ORDER BY count() DESC LIMIT 10;记录执行时间和每个分片的read_rows。我的测试环境里按app分片的版本耗时大约6.2秒热点分片读取了780万行非热点分片读取220万行协调节点的网络接收量明显偏向热点分片。第二轮把分片键改成随机分片跑同样的查询SELECT app_id, count(), sum(duration_ms) FROM app_logs_dist_random GROUP BY app_id ORDER BY count() DESC LIMIT 10;由于两个分片都各存了500万行查询并行度上去了耗时降到3.8秒左右两个分片读取行数各为500万上下相对均衡。但注意这次查询两个分片都扫描了和第一轮相比总扫描量没变只是热点被拆开了。第三轮在按app_id分片的基础上开启分片裁剪并查询只关注某个热点appSET optimize_skip_unused_shards 1; SET optimize_distributed_group_by_sharding_key 1; SELECT app_id, count(), sum(duration_ms) FROM app_logs_dist_by_app WHERE app_id 1 GROUP BY app_id;由于分片键是cityHash64(app_id)app_id1只会落在其中一个分片开启裁剪后另一个分片完全不参与查询。这一轮耗时掉到0.5秒以下并且协调节点只收到一个分片的结果。第四轮用预聚合表把热点彻底折叠。建一张按app_id和小时聚合的预聚合表插入全量数据再跑同样的每个app播放排行查询SELECT app_id, countMerge(play_count) AS play_cnt, sumMerge(total_duration) AS total_dur FROM app_logs_hourly GROUP BY app_id ORDER BY play_cnt DESC LIMIT 10;这轮耗时稳定在0.1秒以内无论原始数据倾斜多严重预聚合表里每个app每小时一行查询开销只和app数量与时间范围有关。四种方式的对比汇总如下策略存储均衡全量聚合耗时单key过滤耗时适用场景按app_id分片差慢快查询条件固定且app热度均匀rand()分片好中等全分片扫描无法选好分片键的业务分片键裁剪参数差慢极快精确等值查询为主预聚合表好极快极快高频固定维度报表这个对比告诉我们一个结论没有绝对最优的分片策略只有对具体业务负载匹配的方案。如果业务查询大部分是单租户精确查按租户ID分片配裁剪参数是收益最高的如果业务查询是全量聚合分析居多随机分片加预聚合才是正路。6. 排查命令和注意事项速查6.1 定位倾斜的SQL和日志做ClickHouse运维一定要养成用system表做体检的习惯。数据倾斜问题不能只靠猜以下几条SQL我几乎每周都会用到。查看各分片数据量分布确认是否存在存储倾斜SELECT shard_num, hostName() AS host, count() AS row_count, formatReadableSize(sum(bytes)) AS size FROM cluster(two_shards, system, parts) WHERE table app_logs_local AND active GROUP BY shard_num, host ORDER BY shard_num;如果row_count或size的差距超过20%基本可以断定分片键有问题。注意这里用system.parts查的是本地表的parts分区是真实物理数据量比较可靠。查看慢查询和各分片读取行数确认是否存在计算倾斜。先查慢查询SELECT query, query_duration_ms, read_rows, read_bytes, memory_usage FROM cluster(two_shards, system, query_log) WHERE event_time now() - INTERVAL 1 DAY AND query_kind Select ORDER BY query_duration_ms DESC LIMIT 10;如果发现某条查询read_rows特别大但整体返回结果很少说明它可能扫了过多的数据结合倾斜现象排查。再进一步把某条SQL发到所有分片看每个分片实际花了多少时间。可以用remote()把明细发到分片执行配合format看每个分片耗时SELECT hostName() AS host, count() AS row_count, sum(duration_ms) AS total_dur FROM remote(127.0.0.{1,2}, default, app_logs_local) GROUP BY host ORDER BY host;这种方式能直观看到热点分片是不是拖了后腿。6.2 实战避坑经验处理ClickHouse倾斜问题有几个坑我反复踩过写出来提醒一下。第一个坑只看分片数不看权重。ClickHouse的分片可以在集群配置里设置weight默认所有分片权重是1。如果某几个分片是后扩容的老机器或者磁盘明显小于其他分片可以调高权重让后续写入更多打到空闲分片缓解倾斜。但注意这只能缓解写入分布已经有大量数据的物理分片没法靠权重自动平衡该迁移还是得迁移。第二个坑改分片键不是改个参数就完事。分布式表的分片键在建表时通过ENGINE Distributed(...)指定是不可以ALTER的。你想改分片键只能新建分布式表和新本地表然后做数据迁移。迁移方案一般是新表建好后写入程序切到新表同时后台用INSERT INTO ... SELECT把旧表数据按时间分批回放过去最后做行数校验再切读流量。整个过程是有损切换风险最高的环节建议在低峰期做并且提前做一遍演练。第三个坑optimize_skip_unused_shards不生效的情况。我见过不少同事开了这个参数但查询毫不见效排查原因大多是WHERE条件没有直接对上分片键。比如分片键是cityHash64(app_id)查询写WHERE app_id 1这个表达式是可以被识别并安全跳过的但如果查询写WHERE cityHash64(app_id) 123或者把app_id套了一层函数再比较优化器可能无法判断裁剪就失效了。另外IN子句的裁剪要求子查询是常量列表如果子查询是动态的也不生效。第四个坑物化视图的异步延迟。物化视图在ClickHouse里是同步写入到目标表其实更准确地说对于MergeTree表物化视图在INSERT时同步执行写入目标表但对Distributed表则可能有异步分发。有很多人误以为物化视图能看到实时数据在生产环境用它做了准实时报表结果延迟一到几分钟被业务投诉。使用预聚合方案前一定要确认业务能接受这个延迟。第五个坑排序键乱改引发全量重写。有人为了优化倾斜查询尝试直接ALTER表的ORDER BY结果ClickHouse会重写整个分区在数据量大时不仅慢还会导致短暂查询性能下降。最好是在建表阶段就把ORDER BY键设计好或者通过ALTER TABLE ... MODIFY ORDER BY并在低峰期操作。如果你打算用预聚合原始表的ORDER BY键没那么重要因为高频查询都打到预聚合表了。还有一个容易被忽略的点磁盘水位监控一定要按分片单独看。集群总空间还有50%不代表健康某个分片磁盘如果用了90%合并任务会开始拒绝写入最终影响整个集群。我见过不止一次因某个分片磁盘满导致全集群写入阻塞的事故。所以监控ClickHouse集群一定要把每个分片的磁盘使用率、CPU、内存拆开看不能只看集群平均值。写在最后的一个小经验我自己在多次处理倾斜问题后的最大感触是ClickHouse的分布式能力是尽力而为的它把均匀分布的责任完全交给了使用者。不要指望建完分布式表后数据会自动均衡也不要把所有希望寄托在某个查询参数上。最稳的做法还是那句老话设计阶段多想五分钟运行阶段少折腾五小时。具体来说新接入一个业务时我先花半天时间分析数据分布和查询模式确定分片键和分区键再用模拟数据做一轮倾斜压力测试上线后每周自动跑一次分片数据量对比脚本发现偏差超过阈值就告警。这套流程虽然朴素但确实帮我挡住了好几次潜在的倾斜事故。另外再分享一个调试小技巧当怀疑某条查询存在倾斜时可以同时跑EXPLAIN查看分布式查询的计划再配合system.query_log看每个分片的read_rows和read_bytes数据会告诉你热点到底在哪个分片。顺着这条线通常很快就能定位到底是谁在拖慢整个集群。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑