数据倾斜根治指南:从现象识别到六大解决方案的完整实践
发布时间:2026/10/20 12:19:39 锦皓数字建站

聊到数据倾斜很多人的第一反应是头大。一个跑了十几个小时的离线任务明明一百个并发偏偏有一个task卡了一整夜其他task早就跑完了进度条就是卡在99%不动。你重启也好、加资源也好问题照旧最后只能硬着头皮调参数运气好能糊弄过去运气不好直接OOM整个任务失败重来。做大数据开发这些年我可以说数据倾斜是分布式计算里最磨人、也最普遍的问题之一。它不像代码逻辑错误那样有明确的报错堆栈而是以各种隐晦的方式出现——任务慢、内存溢出、GC频繁、CPU和磁盘忽高忽低。你很难一眼定位但它就在那里像鞋里的一粒沙子不致命却让你每一步都难受。这篇文章我会把数据倾斜这件事彻底讲透现象怎么识别、根因有哪些、解决方案怎么选、真实场景怎么排查以及如何提前预防。无论你是刚接触Hive/Spark的初学者还是已经写过不少离线任务的工程师这篇文章都能帮你建立一套完整的应对框架。毕竟面对倾斜问题真正拉开差距的往往不是你会多少种方案而是你能否在最短时间内判断出“当前这个场景该用哪一招”。1. 先别急着优化把倾斜现象认准1.1 什么是数据倾斜一个生活化类比数据倾斜这个概念很多人背过定义但真正理解的人不多。我习惯用快递分拣来类比。想象你有一个快递仓库每天有十万件包裹要分拣。仓库里有十条分拣线按理说每条线分一万件大家同时开工效率最高。但如果其中有一条线的分拣员手气特别差分到的包裹全是同一个大客户的——整整四万件剩下六万件分给其他九条线那结果是什么其他九条线早早下班唯独这条线干到凌晨还在搬货。分布式计算里的“分拣线”就是一个个并行的task。每条线的“包裹”就是被分配到该task的数据分区。数据倾斜的本质是数据在各个分区之间的分布严重不均导致大量任务早早结束、少数任务拖到天荒地老或者干脆被巨大的数据量压垮。这个类比里还有一个容易被忽略的细节倾斜不一定总是一成不变的。有时候是某个key一直很热比如平台上的头部商家订单量远高于长尾商家有时候是数据在某个时间段特别集中比如大促期间的流量比平时高出百倍。前者是“结构性倾斜”后者是“波动性倾斜”两种处理思路完全不同后面会单独展开。1.2 四个容易忽略的倾斜信号很多朋友问我“我到底怎么判断我的任务是不是倾斜了”其实倾斜从来不会毫无征兆地出现它一定会在运行过程中留下蛛丝马迹。我总结出四个最常见的信号你在看任务的Spark UI 或 YARN 日志时可以重点盯住这几个地方。信号一任务进度条长期卡在99%。如果100个task里有99个显示Success只剩1个还在Running或者显示succeeded但耗时呈数量级差距这基本可以断定倾斜了。你点开这个task的详情看它的输入记录数往往会发现它一个人吃掉了全量数据的百分之七八十。信号二同一Stage中task的输入数据量差异巨大。在Spark UI的Stage页面查看每个task的Shuffle Read Size和Records。正常情况下各task的数据量应该在一个量级内如果出现某个task的Shuffle Read Size是其他task的数十倍甚至百倍这就是典型的key倾斜。信号三频繁的GC超时与内存溢出。倾斜的task在读取和处理超大分区时内存压力剧增JVM的GC时间会急速上升最终出现ExecutorLostFailure或java.lang.OutOfMemoryError。有时候你看到某个executor被remove第一反应是“内存不够”但根因往往是数据倾斜把单分区数据量撑爆了。信号四CPU与磁盘IO表现不一致。正常情况下并行task的CPU使用率相对均衡。如果某个节点CPU被打满、磁盘读写居高不下而其他节点CPU利用率只有个位数说明负载集中到了个别节点上往往与数据倾斜有关。1.3 倾斜的代价为什么必须根治有些新手可能觉得任务慢就慢呗多等几个小时不就行了如果你只是跑一个临时的测试SQL这么想问题不大。但在生产环境里倾斜问题不根治代价是持续的、叠加的。最直接的是资源浪费。集群的资源是共享的一个倾斜的任务占着大部分计算资源不释放其他正常任务就得排长队。在并发度高的数仓环境里A团队的一个倾斜任务可能拖累B团队整批任务延迟影响面远不止一个任务本身。我曾经处理过一个案例某核心业务的任务每天晚上固定倾斜每天凌晨的报表延迟两小时业务方投诉到数据团队排查了一周才发现是一个join的热点key在作祟。这种问题不根治值班的人每天都要睡不安稳。其次是稳定性风险。倾斜会加剧OOM——数据量集中在单个task上内存中放不下从磁盘spill到再读回来性能成倍退化最终可能导致整个Executor崩掉。一旦运行中的executor丢失Spark会重新调度计算有时候还会连锁引发更大的雪崩。这种问题在生产环境出现一次就足以让人失去对整个作业管线的信任。所以认识到数据倾斜的存在和危害是治理它的第一步。接下来我们需要知道它到底是怎么产生的才能对症下药。2. 五大成因拆解倾斜不是偶然2.1 数据源天然不均从源头就歪了这是最直白的成因也是很多人容易忽略的第一步。在做任何优化之前先回到数据源头看一眼你的数据分布本身是不是就是畸形的。举个典型例子在电商订单数据中某个头部商家的订单量可能占全平台的30%。在日志数据中某些热门APP的访问量可能是长尾APP的几百倍。在爬虫采集的数据中某几个IP的请求量可能占了大半。这些数据的“热点key”是天然的不因你的计算引擎而改变。你可能会说数据源就是这样的我总不能去改业务数据吧对你确实改不了数据源但你可以选择在计算前先做一次探查。我个人的习惯是在接手一个陌生的数据任务时第一件事不是看SQL逻辑而是先sample一下输入数据用group bycount统计一下key的分布。如果头部key的数据量占比超过20%甚至超过50%那么后续任何计算都可能在这个key上出问题。提前做到心里有数后面遇到倾斜就不会手忙脚乱。2.2 shuffle机制计算引擎如何把倾斜放大很多人不理解为什么明明代码逻辑没问题处理得好好的一到reduce或join阶段就疯狂倾斜这就要说到分布式计算的shuffle机制了。Shuffle简单理解就是让数据“跨节点重新分发”。在Map阶段各个节点并行处理自己的数据块可一旦进入reduce或join阶段相同key的数据必须被汇总到同一个节点上——因为聚合运算需要把所有相同key的值放到一起才能计算。这个汇总的过程靠的是对key做哈希取模后分配到不同的分区。问题就在这里如果某个key的数量极其庞大无论你怎么哈希它都会被分到同一个分区、同一个task上。最终表现就是这个task需要处理海量数据其他task却在“喝咖啡等下班”。所以可以说shuffle是数据倾斜的“放大器”——单纯的数据分布不均在并行任务里可能并不明显但经过shuffle的强制汇总后所有压力都压到了少数几个task身上。这也是为什么减shuffle、改分桶、预聚合这些方案本质都是在想办法“错开”或“弱化”shuffle的集中效应。2.3 分组聚合与join中的热点key最常踩的坑先看分组聚合。比如你有一个用户行为日志表要按城市统计每个城市的访问量。如果这是一份全国性的数据大部分城市的访问量分布是相对均匀的但北京、上海这些超一线城市的访问量可能比四五线城市多出上百倍。那么在group by city后的reduce阶段北京和上海的分区就成为“热点”分区处理时间远高于其他分区。再看join。用一个经典场景解释有一张“订单大表”比如几亿行和一张“用户维度小表”比如几百万行要关联出用户信息。假设订单大表里有一个“用户ID”是某个特殊账号比如官方账号或测试账号它的订单量占了全部订单的20%。在shuffle join时这张大表会按照user_id做hash分发所有这个“特殊账号”的订单必然进入同一个分区。那么这个分区要和其他所有分区做关联匹配它就妥妥地成为热点分区。我在生产环境里遇到过一次很典型的案例订单表和商品表的join有个售货渠道的订单里大量记录都是“商品ID为空”的默认值。由于空值在哈希后也会聚集到一个分区整个reduce阶段被这一个分区拖了整整三个小时。后来把空值单独过滤出来按照其他逻辑处理再和其他正常数据union回去问题迎刃而解。2.4 空值与默认值被误伤的“伪倾斜”讲到空值就必须单独挑出来说。很多倾斜问题其实并不是业务上有热点而是空值、默认值、脏数据造成的“伪倾斜”。比如日志表里很多字段为空你在做group by时把空字符串也当成了一个分组key。那么所有空值记录全部被随机塞到一个固定分区哈希算法会把相同key映射到同一分区这个分区可能塞进了全表80%的空记录。你以为你在做正常的聚合统计实际上这个分区的数据量严重超标任务因此被拖死。这种问题的解法其实异常简单在计算前把空值、默认值、异常值都处理掉。要么过滤掉要么在分组时单独标记要么赋予一个随机前缀再分组。这里不需要复杂的技术关键是有没有这个意识——做数据分析前先审视字段的取值分布是减少这类伪倾斜最有效的方式。2.5 并行度设置不当一张关键参数的对照表最后一个常见成因是并行度设置和任务规模不匹配。有些任务跑得慢真不是数据倾斜而是你的并行度太低白白浪费了集群资源。举个例子你有一份100GB的中间结果需要做shuffle聚合Spark默认的spark.sql.shuffle.partitions是200也就是说shuffle会分成200个分区、200个task来处理。如果这100GB的数据分布不均200个task里面哪怕只有一个热点分区它要处理的数据量可能就是10GB甚至更多。而如果把这个参数调到2000每个分区的数据量会被切碎热点task的绝对数据量也相应下降倾斜的感知就不那么明显了。参数调整确实是最快的止血手段但也要当心副作用。分区数增加意味着task数变多调度开销和shuffle文件数也会增加。如果小文件过多后续读取的task反而会变慢这属于典型的“按下葫芦浮起瓢”。所以并行度的设置需要结合数据量来评估。这里我整理了一张经验对照表供大家参考数据规模shuffle分区数经验值说明1GB以下50-100数据量小分区太碎反而慢1GB-10GB100-200默认配置通常够用10GB-100GB200-1000根据热点程度适当调大100GB以上1000-2000建议分桶或做倾斜专项处理这只是一个基础参考真实场景中还要结合集群资源和热点分布再决定。我记得有一次一个日处理500GB的任务分区数从默认200调到800后一个半小时的任务缩短到了不到二十分钟可见参数的作用有多直接。3. 解决方案大全从止血到根治3.1 方案一空值与异常值过滤这个方案便宜、直接、见效最快适合所有基于SQL的离线和实时任务。它的核心思路是在进入真正的聚合或join之前先把会导致倾斜的异常值单独处理掉。具体操作很简单。原始SQL是这样的-- 未处理空值的原始写法 SELECT user_id, count(*) FROM user_log GROUP BY user_id;可以先做如下改造-- 方式一过滤空值 SELECT user_id, count(*) FROM user_log WHERE user_id IS NOT NULL AND user_id ! GROUP BY user_id; -- 方式二给空值单独分桶避免所有空值挤在同一分区 SELECT CASE WHEN user_id IS NULL OR user_id THEN unknown ELSE user_id END AS user_id_new, count(*) FROM user_log GROUP BY user_id_new;方式一适合业务上不会用到空值分组结果的场景直接过滤最干净。方式二适合还需要保留空值统计的场景它把一个可能巨大的空值key替换成了有业务含义的unknown标签同时也让空值数据不至于全部挤在一个默认分区里。我自己的习惯是在写任何聚合任务之前都会先用一条count把空值占比查出来。如果空值占比超过10%就一定要处理如果低于1%那即便不处理造成的影响也在可接受范围内。3.2 方案二两阶段聚合先分区预聚合再全局汇总两阶段聚合可以说是解决group by倾斜最经典、最有效的通用方案也是最值得反复练习的一种方法。它的核心思路是给每个key加一个随机前缀让同一个key先被打散到多个分区在分区内完成第一轮聚合然后去掉前缀再对带有原key的结果做第二轮聚合。这样原本集中在单个分区的压力被均匀分摊到了所有分区。以Spark Scala代码为例import org.apache.spark.sql.functions._ // 第一阶段加盐打散局部聚合 val saltedDF df .withColumn(salt, (rand * 10).cast(int)) // 生成0-9的随机盐值 .withColumn(salted_key, concat(col(user_id), lit(_), col(salt))) val partialAgg saltedDF .groupBy(salted_key) .agg(count(action).as(cnt)) // 第二阶段还原key全局聚合 val finalAgg partialAgg .withColumn(user_id, split(col(salted_key), _)(0)) .groupBy(user_id) .agg(sum(cnt).as(total_cnt))这里有两个需要重点说明的细节。第一随机盐值的范围不能拍脑袋定它要和热点key的绝对数据量匹配。比如热点key有1000万条数据你加10个盐每个分区100万条可能还是太大加到50或100个盐每个分区10万到20万条就合理多了。第二两阶段聚合只适用于group by类的聚合运算count、sum、avg这类操作都能正确还原因为sum和count是可加的。但如果是去重统计count(distinct)用随机前缀就会出问题——同一个key的distinct值被拆分到多个分区后你没法用简单的sum还原去重结果。遇到count(distinct)倾斜需要用Bitmap等去重数据结构或者换用近似去重算法。3.3 方案三map join与广播变量让大表join小表不再shuffle对于join类型的倾斜一个屡试不爽的招数是把小表广播出去避免shuffle。我们回想一下上面的shuffle原理join之所以产生倾斜是因为大表和小表都要按join key做哈希分发热点key会把大量数据集中到一个分区。但如果我们把小表广播到每个executor上让每个executor在本地内存里直接做关联大表的数据完全不需要shuffle分发每个task各自处理自己的那部分数据即可。热点key哪怕有1亿条也被分散到了所有并行task中每个task只用查一下本地内存里的关联关系就行了。Spark SQL中开启广播的写法很简单-- Spark SQL侧开启广播join SELECT /* BROADCAST(small_table) */ * FROM big_table b JOIN small_table s ON b.key s.key;或者在使用DataFrame时import org.apache.spark.sql.functions.broadcast val result bigDF.join(broadcast(smallDF), Seq(key), inner)这里必须说明一个适用边界广播方案只有在“小表”真的足够小的时候才有效。这个“小”取决于executor内存一般建议是单表数据在百MB级以内。如果小表本身有几十GB强行广播会导致executor直接OOM效果适得其反。另外如果大表和小表的数据量都不小那就要考虑换成分桶joinbucket join或者倾斜key拆分的方式了。3.4 方案四倾斜key拆分与单独处理治标又治本这个方案是所有方案中最精细、最灵活、也是效果最好的一个可以应对绝大多数类型的倾斜——前提是你愿意多写一点代码把逻辑拆细。核心思路分三步。第一步先通过采样识别出哪些key是热点key。第二步把热点key和非热点key分为两条计算路径。非热点key走正常的shuffle join或聚合热点key则单独处理——给它们附加随机前缀把该key对应的数据打散到多个分区后再join。第三步把两条路径的结果union起来得到最终结果。之前处理一个电商订单大表与用户表的join时我就是这么做的// 识别热点key采样统计 val hotKeys df.filter(col(cnt) 100000).select(user_id).distinct() // 大表拆分为热点与非热点 val bigHot bigDF.join(hotKeys, Seq(user_id), inner) .withColumn(salt, (rand * 50).cast(int)) .withColumn(join_key, concat(col(user_id), lit(_), col(salt))) val bigCold bigDF.join(hotKeys, Seq(user_id), left_anti) // 小表热点key也要加盐保持与大表一致复制50份 val smallHot smallDF.join(hotKeys, Seq(user_id), inner) .withColumn(salt, explode(typedLit((0 until 50).toList))) .withColumn(join_key, concat(col(user_id), lit(_), col(salt))) val smallCold smallDF.join(hotKeys, Seq(user_id), left_anti) // 分别join再union val result bigHot.join(smallHot, Seq(join_key), left) .union(bigCold.join(smallCold, Seq(user_id), left))这个过程看起来很繁琐但它的本质逻辑并不复杂热点key被复制多份并打散原本集中在一个点上的压力被均匀分散到了多个分区。这种思路不仅适用于join也同样适用于group by聚合。唯一需要注意的是热点key的识别阈值需要结合集群内存和实际数据量来定不能一成不变。如果热点key列表过大复制份数过多小表因为复制而急剧膨胀会反向导致广播成本和任务调度开销上升所以要权衡。3.5 方案五分桶与预排序让Hive与Spark少走弯路如果是Hive数仓中的大表关联分桶bucket是一个很值得考虑的架构级方案。分桶的原理是在写入表时就对数据按照某个key做hash分桶保证同一个key的数据固定落入某个桶文件并且每个桶内有排序。之后在做分桶join时引擎可以直接根据桶的对应关系进行匹配不需要全量的shuffle分发这样join的压力就大幅减小。建表语句大致长这样CREATE TABLE orders_bucketed ( order_id string, user_id string, amount double ) CLUSTERED BY (user_id) SORTED BY (user_id) INTO 32 BUCKETS; CREATE TABLE users_bucketed ( user_id string, user_name string ) CLUSTERED BY (user_id) SORTED BY (user_id) INTO 32 BUCKETS;这样做的前提是你预先知道join的key并且愿意在建表和ETL流程里付出一次性的分桶成本。好处是今后的关联查询会非常稳定而且对倾斜的抵抗能力远强于普通表。它适合那种被反复查询的事实表与维度表关联场景。如果你做的是即席查询或者临时分析再花时间去建桶表反而得不偿失。3.6 方案六参数调整与并行度优化最后的止血工具如果以上方案你暂时没有时间实施或者倾斜程度本身不严重那可以先用参数调整兜底。常见的参数包括-- Hive SET hive.map.aggrtrue; -- 开启map端聚合 SET hive.groupby.skewindatatrue; -- 数据倾斜时负载均衡本质就是两阶段聚合 SET hive.optimize.skewjointrue; -- 开启倾斜join优化 -- Spark SET spark.sql.shuffle.partitions480; -- 调大shuffle分区数 SET spark.sql.adaptive.enabledtrue; -- 开启自适应查询执行 SET spark.sql.adaptive.coalescePartitions.enabledtrue;需要特别解释的是Spark 3.0之后引入的自适应查询执行AQE。开启后Spark会在运行中动态调整shuffle分区数如果某个分区数据量过大它会自动再拆分如果某些分区数据量过小它会自动合并。这不仅降低了人工调参的成本还在一定程度上缓解了倾斜非常适合动态数据分布的日常场景。不过AQE对真正严重的热点倾斜例如单个key占总数据50%并没有根治能力还是要结合上述的拆分方案才能彻底解决。4. 案例复盘两次真实的倾斜治理实录4.1 案例一用户行为日志按渠道统计中的严重倾斜某次接到一个业务需求统计某应用各渠道的日活用户数。原始表有三个字段date、channel、user_id数据量大约每天8亿行需要跑一张日活报表。第一次跑任务时40个reduce task其中3个task运行了一个多小时还没结束而其余task五分钟就已完成任务整体时间被拖到了近两个小时业务方抱怨不断。我的排查过程是这样的。先打开MR/Spark UI查看这3个慢task的输入记录数发现它们处理的记录数分别达到了3亿、2.5亿、1.8亿行而正常task也就几百万行差距上百倍。随后对这些task的输入数据分区做一次group by channel的统计发现其中的“某短视频渠道”的流量占全表的35%是其他渠道的50倍以上。至此根因确认单渠道热点导致了reduce端倾斜。我当时用的是两阶段聚合方案。先用随机盐值范围1-100给channel打散做局部聚合再去掉盐值做全局汇总并在中间加一个map端预聚合。最终原本两个小时的倾斜任务缩短到了十六分钟。整体效果立竿见影没有破坏任何业务语义。这里有个经验值得分享两阶段聚合中盐值的范围不是越大越好。当你把盐值设为100时任务会多出100倍的中间reduce文件调度和序列化开销也会上升。所以我通常的做法是先确认热点key的数据量确保加盐后单个分区数据量在数千万行以内即可不追求极端均分。4.2 案例二订单大表join用户维度表时的热点账号问题另一个经典场景是订单大表要和用户维度表做关联。订单表有2亿行用户表只有300万行按user_id关联。跑任务时整个join阶段卡了近三个小时有一个task在反复GC。排查时发现订单表中有一个user_id是全角空格字符串“guest”是历史遗留的脏数据占全部订单量的15%导致这一个key将大量订单数据全部冲进了同一个分区。这个问题的解法相对简单——先对user_id做清洗归一化把全角空格、null、空字符串统一转为unknown再单独统计unknown的订单量。清洗后该热点key的数据量从3000万行骤降为零剩余数据分布均匀任务时长从三小时降到二十分钟。比起第一个案例这个案例的技术含量不高但它揭示了一个非常重要的道理很多倾斜问题根子就不在计算逻辑上而在数据质量上。如果你收到一个新表、新字段没有先做数据质量校验就直接开跑后面等着你的大概率就是一个加了班的凌晨。5. 让倾斜不再反复预防与监控体系5.1 上线前数据探查三步法与其等任务跑起来慢了再去排查不如在上线前就把倾斜扼杀在摇篮里。我给自己定了一个规矩凡是新写的SQL任务上线前必做三步数据探查。第一步探查输入数据的key分布。对最终join或group by的key做一次采样统计直接看每个key的数据量占比识别出可能的热点key和空值占比。第二步探查表的大小与行数比例。一张小表和一张大表join优先考虑广播两张超大表join要考虑分桶和倾斜key拆分。第三步预估shuffle数据量。根据中间结果的行数和字段大小估算shuffle数据总量判断当前的shuffle分区数是否合理。这三步跑完大部分倾斜问题都能提前暴露不必等到生产环境踩坑。5.2 运行时建立倾斜识别与告警机制在离线调度平台上建议增加几条基础的监控告警规则自动发现倾斜任务而不是等人肉去盯。常见的倾斜识别阈值可以这样设置track某个Stage中所有task运行时间的P95/P50比值如果P95时间大于P50的3倍以上就判定为疑似倾斜或者查看单个task的Shuffle Read Size是否超过全Stage平均值的10倍。一旦触发系统自动给值班工程师发告警附上对应的stageId和taskId。这套监控逻辑并不复杂无非是把人工排查的经验固化成代码但它的价值极大。我经历过很多次“任务凌晨2点倾斜早上9点业务方发现报表没出”的事故如果有一套自动识别机制就完全可以在凌晨就把问题暴露出来甚至触发自动重试或跳过。5.3 定期巡检与代码规范长期来看预防倾斜最有力的武器是代码规范。在你们团队的SQL和Spark代码评审清单里我强烈建议加入以下几条规定所有group by的key必须有数据分布说明空值占比要明确。join之前优先识别小表并显式使用广播hint。禁止在join key上直接使用函数处理如substr、concat这会破坏已有的分桶或映射关系还可能引发shuffle放大。热点表必须打入数据质量监控如果热点占比突然上升立即触发告警。这些规范看起来像“我们部门要求”这种枯燥条文但一旦真正落地你会发现生产环境的任务稳定性会有一个质的提升。写在最后数据倾斜是一个底层原理清晰、出现形式多样的问题。要真正搞定它光靠背几个解决方案远远不够关键还在于建立一套完整的思考路径拿到一个任务先看输入数据分布再判断是否可能倾斜然后根据实际的倾斜程度和计算类型从过滤空值、两阶段聚合、广播join、热点key拆分、分桶设计、参数调整这些手段里选出最适合的组合。我个人在实际操作中最深的体会是不要迷信某一种“万能”方案。两阶段聚合解决不了count(distinct)的倾斜广播join解决不了大表join大表的场景参数调整只能止血不能根治。真正高效的做法是在动手前花三分钟想清楚“倾斜的本质是什么”“我用的方案会不会引入新的问题”然后才去改代码。这套思路带给我的回报远比我记忆里的那些具体SQL片段要大得多。如果这篇文章能帮你在下一次遇到任务卡99%的时候少一点慌乱、多一点从容那就值了。也欢迎你在实践中遇到其他奇特的倾斜场景后拿来与我一起探讨——很多新问题往往就藏在旧经验的边界上。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。