订阅更新

数据倾斜

数仓建模 20 讲 · 第 17 讲 约 12 分钟读完

本文目录
  1. 倾斜的本质:一句话说清楚
  2. 怎么发现倾斜?
  3. 三种常见倾斜场景
  4. 四种解决方案
  5. Spark AQE:自动处理倾斜
  6. 面试怎么答?
  7. 总结

你有没有遇到过这种情况:一个ETL任务,大部分Task在3分钟内跑完了,但有1-2个Task卡了40分钟。整个任务的耗时取决于最慢的那个Task——其他99个Task早就结束了,大家都在等最后一个。

数据倾斜面试题.jpeg

这就是数据倾斜。

数据倾斜不是一个理论问题,而是数仓开发中最常遇到的性能问题之一。我在4家公司都遇到过,解决方法也积累了一整套。这篇文章我把倾斜的原理、发现方法、4种解决方案讲透,配上完整的SQL和真实案例。


倾斜的本质:一句话说清楚

大部分数据集中在少数几个Key上,导致Shuffle之后个别Reducer处理的数据量远超其他Reducer。

image.png

MapReduce/Spark/Flink的分布式计算模型,核心是把数据按Key分发到不同的Reducer/Task去并行处理。如果数据在Key上的分布严重不均匀——比如北京的订单占了总量的40%——那处理北京的那个Task数据量是其他城市的几十倍,自然就慢了。

正常分布:每个Task处理约100万条数据,3分钟完成
  Task-0 (北京):    100万条 → 3分钟 ✅
  Task-1 (上海):     90万条 → 3分钟 ✅
  Task-2 (广州):     80万条 → 2分钟 ✅
  ...

倾斜分布:北京的Task数据量是其他的40倍
  Task-0 (北京):   4000万条 → 40分钟 ❌ ← 整个任务卡在这里
  Task-1 (上海):     90万条 → 3分钟 ✅
  Task-2 (广州):     80万条 → 2分钟 ✅
  ...

其他99个Task早就完成了,CPU空闲着在等。集群资源白白浪费了97%。


怎么发现倾斜?

image.png

方法一:看Task耗时分布

在Spark/Hive的Web UI里,找到对应的Stage,看每个Task的执行时间和处理数据量。

如果大部分Task在几分钟内完成,但有1-2个Task耗时是平均值的10倍以上,基本就是倾斜了。

方法二:看Shuffle数据量分布

Spark Web UI的Stage详情里有每个Task的Shuffle Read Size。正常情况各Task应该差不多,如果某个Task的Shuffle Read是其他的几十倍,就是倾斜Key。

方法三:SQL分析Key分布

在写SQL之前,先分析一下Key的分布:

-- 分析GROUP BY / JOIN 键的数据分布
SELECT 
    city,
    COUNT(*) AS cnt,
    ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 2) AS pct
FROM fact_order
WHERE dt = '2025-01-15'
GROUP BY city
ORDER BY cnt DESC
LIMIT 20;

-- 结果可能是:
-- 北京   4000万  40%  ← 热点Key!
-- 上海   2000万  20%
-- 广州    500万   5%
-- ...
-- 其他300个城市各几万条

看到某个Key占比超过20%,就要警惕倾斜风险了。


三种常见倾斜场景

image.png

场景一:NULL值倾斜

这是最容易被忽视的倾斜场景。事实表的某个外键有大量NULL(比如未登录用户没有user_id),JOIN的时候所有NULL被分到同一个Reducer。

-- 表A有1亿行,其中user_id为NULL的有1500万行
-- LEFT JOIN时,1500万条NULL全部分到一个Reducer处理
SELECT a.*, b.user_name
FROM fact_event a
LEFT JOIN dim_user b ON a.user_id = b.user_id;
-- NULL的那个Reducer处理1500万行,其他Reducer各处理几十万行

我在短剧出海公司的埋点数据里遇到过这个问题。未登录用户的事件大约占总事件的15%,全部user_id为NULL。JOIN用户维度表的时候,NULL那个Task跑了30多分钟,其他Task都在5分钟内完成。

场景二:热点Key倾斜

某些Key的数据量天然就很大——大V用户、系统账号、默认分类、热门城市。

-- GROUP BY city统计GMV
-- 北京 4000万条、上海 2000万条、其他城市各几万条
SELECT city, SUM(amount) AS gmv
FROM fact_order
GROUP BY city;
-- 北京的Reducer要处理4000万条,其他Reducer只处理几万条

场景三:大表JOIN大表的笛卡尔积倾斜

两张大表JOIN,某些Key在两边都有大量数据,JOIN之后产生笛卡尔积:

表A中 user_id=1001 有 1000 条记录
表B中 user_id=1001 有 500 条记录
JOIN之后:1000 × 500 = 50万条(这一个Key就产生了50万行)
而其他Key可能JOIN后只有1-2行

这种情况不仅倾斜,还容易OOM。


四种解决方案

方案一:NULL值单独处理

思路: 把NULL值用随机数打散,避免集中到一个Reducer。

-- 方法A:COALESCE随机打散
SELECT a.*, b.user_name
FROM fact_event a
LEFT JOIN dim_user b
ON COALESCE(a.user_id, CONCAT('null_', CAST(RAND() * 1000 AS INT))) = b.user_id;
-- NULL被打散成null_0, null_1, ..., null_999,分布到1000个Reducer
-- 因为dim_user里没有这些Key,LEFT JOIN结果还是NULL,逻辑正确

-- 方法B:拆成两部分(更清晰)
-- Part1: user_id不为NULL → 正常JOIN
SELECT a.*, b.user_name
FROM fact_event a
JOIN dim_user b ON a.user_id = b.user_id
WHERE a.user_id IS NOT NULL

UNION ALL

-- Part2: user_id为NULL → 不JOIN,维度字段直接设默认值
SELECT a.*, '未登录用户' AS user_name
FROM fact_event a
WHERE a.user_id IS NULL;

image.png

方法B更清晰、性能也更好——NULL的部分压根不走JOIN了。

方案二:MapJoin小表广播

思路: 如果JOIN的一方是小表(< 500MB),直接把小表广播到每个Map端,在Map阶段完成JOIN,不走Shuffle。没有Shuffle就没有倾斜。

-- Hive设置
SET hive.auto.convert.join=true;
SET hive.mapjoin.smalltable.filesize=500000000;  -- 500MB以内的表自动MapJoin

-- 小表会被加载到内存,不走Shuffle
SELECT /*+ MAPJOIN(dim) */ f.*, d.user_name, d.city
FROM fact_order f
JOIN dim_user d ON f.user_id = d.user_id;
// Spark设置
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", 500 * 1024 * 1024)  // 500MB
// 或者手动广播
import org.apache.spark.sql.functions.broadcast
val result = factDF.join(broadcast(dimDF), "user_id")

image.png

我在出海硬件品牌做Databricks优化时,把autoBroadcastJoinThreshold从默认10MB调到了200MB。很多维度表的大小在10-200MB之间,调整后这些JOIN自动变成了Broadcast Join,多个作业的执行时间减少了40%。

适用条件: JOIN的一方足够小(通常< 500MB)能放进每个Executor的内存。

方案三:加盐打散(两阶段聚合)

思路: 给热点Key加随机后缀,分散到多个Reducer并行处理,然后再去掉后缀做最终聚合。

这是解决GROUP BY倾斜最经典的方法:

-- 原始SQL(有倾斜):
SELECT city, SUM(amount) AS gmv
FROM fact_order GROUP BY city;
-- 北京的4000万条全在一个Reducer,40分钟

-- 优化:两阶段聚合

-- 第一阶段:加盐分散
SELECT 
    CONCAT(city, '_', CAST(FLOOR(RAND() * 10) AS STRING)) AS city_salt,
    SUM(amount) AS partial_sum
FROM fact_order
GROUP BY CONCAT(city, '_', CAST(FLOOR(RAND() * 10) AS STRING));
-- 北京被分散成 北京_0, 北京_1, ..., 北京_9
-- 每个"子Key"各处理约400万条,分布到10个Reducer并行
-- 第一阶段产出:北京_0: 500万, 北京_1: 480万, ..., 共10行

-- 第二阶段:去盐汇总
SELECT
    SPLIT(city_salt, '_')[0] AS city,
    SUM(partial_sum) AS gmv
FROM stage1_result
GROUP BY SPLIT(city_salt, '_')[0];
-- 去掉盐值,合并10个partial_sum为最终结果
-- 第二阶段只处理10行,瞬间完成

优化效果: 北京原来40分钟一个Reducer扛,现在分散到10个Reducer各4分钟,总耗时从40分钟降到约4-6分钟。

image.png

这个方法也适用于JOIN倾斜——给大表的热点Key加盐,同时把小表的对应Key复制N份(每份带一个盐值),让JOIN也能分散。

方案四:倾斜Key单独处理

思路: 把热点Key单独拎出来,用特殊方式处理(比如MapJoin),普通Key正常处理,最后UNION ALL合并。

-- 第一步:找出热点Key
-- 通过分析发现 city IN ('北京','上海') 占了60%的数据
-- 这两个Key的dim_city数据可以直接硬编码

-- 第二步:分开处理

-- Part1: 热点Key → 直接赋值维度,不走JOIN
SELECT f.order_id, f.amount, f.city,
    CASE f.city 
        WHEN '北京' THEN '华北' 
        WHEN '上海' THEN '华东' 
    END AS region
FROM fact_order f
WHERE f.city IN ('北京', '上海')

UNION ALL

-- Part2: 普通Key → 正常JOIN(数据量小,不倾斜)
SELECT f.order_id, f.amount, f.city, d.region
FROM fact_order f
JOIN dim_city d ON f.city = d.city
WHERE f.city NOT IN ('北京', '上海');

image.png

这种方案看起来"笨",但在某些场景下非常有效——尤其是热点Key只有几个、且维度信息可以枚举的时候。


Spark AQE:自动处理倾斜

Spark 3.0之后引入了AQE(Adaptive Query Execution),可以自动检测和处理倾斜。

// 开启AQE和倾斜Join优化
spark.conf.set("spark.sql.adaptive.enabled", true)
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", true)
spark.conf.set("spark.sql.adaptive.skewJoin.skewedPartitionFactor", 5)
spark.conf.set("spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes", "256MB")

AQE会在运行时自动检测倾斜的Partition,把它拆成多个小Partition重新分配。相当于引擎帮你做了"加盐打散"。

image.png

我在Databricks上把AQE打开之后,之前那个数据倾斜的作业(某设备ID占30%数据)从40分钟自动降到了8分钟,不用改一行SQL。AQE是Spark最实用的优化特性之一。

但AQE不是万能的——它只能处理Join倾斜,对GROUP BY倾斜和NULL倾斜效果有限。了解原理、知道什么时候需要手动优化,依然是必要的。

image.png


面试怎么答?

高分回答

「我遇到过两次典型的倾斜问题。一次是GROUP BY倾斜——按城市统计GMV,北京和上海占了60%的数据,Task耗时从3分钟变成40分钟。我用两阶段聚合解决:第一步给城市Key加RAND()后缀打散到10个Reducer分别求和,第二步去掉后缀做最终汇总。优化后从40分钟降到6分钟。」

「另一次是NULL值倾斜——未登录用户的事件约占15%,user_id全部为NULL,JOIN时集中到一个Reducer。我把NULL的数据拆出来单独处理——不走JOIN,维度字段直接赋默认值,再UNION ALL合并回去。此外在Databricks上开启了AQE自动倾斜优化,覆盖了大部分Join倾斜场景。」

image.png

追问防线

追问1:怎么提前预防倾斜?

三个层面。第一,设计阶段——了解数据分布,如果知道某些Key数据量特别大,ETL逻辑提前考虑。第二,开发阶段——写JOIN前先跑一下Key分布分析(前面的SQL),识别潜在热点。第三,运行阶段——开启AQE让引擎自动处理,同时在监控中设置Task耗时不均匀的告警。

image.png

追问2:加盐打散对JOIN场景怎么用?

大表的热点Key加盐(比如user_id变成user_id_0到user_id_9),小表对应的记录复制10份(每份带一个盐值0到9)。这样JOIN的时候热点Key被分散到10个Reducer。复制小表的成本很低(只是热点Key对应的几行数据复制了10份),但倾斜的消除效果很好。


总结

  • 倾斜的本质:少数Key数据量远超其他,个别Task被拖慢
  • 发现方法:看Task耗时分布 + SQL分析Key分布
  • NULL倾斜:COALESCE随机打散或拆分单独处理
  • 小表JOIN:MapJoin广播到Map端,不走Shuffle
  • 热点Key:加盐打散两阶段聚合,经典有效
  • 倾斜Key:单独拎出来特殊处理,UNION ALL合并
  • Spark AQE:自动倾斜优化,能覆盖大部分Join场景

image.png

刷题进度保存在当前浏览器