订阅更新

数据倾斜导致标签计算任务跑了4小时——怎么优化到30分钟?

用户画像手册 第 16 题 约 11 分钟读完

本文目录
  1. 先说结论
  2. 画像场景的典型倾斜案例
  3. 方案一:过滤NULL值——最简单有效
  4. 方案二:加盐打散——热门key的通用解法
  5. 方案三:AQE自动处理——Spark 3.0+的福音
  6. 三种方案对比
  7. 面试高频追问
  8. 实战踩坑
  9. 总结

先说结论

数据倾斜的本质是某些key的数据量远大于其他key,导致个别Task处理的数据量巨大。Spark UI上的表现是:199个Task在1分钟内跑完了,最后1个Task跑了3个多小时。

解决倾斜的三板斧:过滤异常值、加盐打散、AQE自动处理。先看数据分布,判断是什么原因导致的倾斜,再选择对应的解决方案。

画像场景最常见的倾斜原因有两个:NULL值聚集和热门用户数据量过大。

先把这段讲顺;面试官追问时,再展开下面的原理和细节。

详细讲解

数据倾斜是大数据开发绕不开的问题,在用户画像场景里尤其常见——因为画像计算大量使用GROUP BY和JOIN,而用户行为数据天然分布不均匀(头部用户的行为量是长尾用户的几百倍)。我在某知名出海硬件品牌做画像标签计算时,有一个任务跑了4小时才出结果,排查发现就是数据倾斜导致的。

画像场景的典型倾斜案例

我在出海硬件品牌遇到的具体场景:计算用户行为标签时,需要把用户行为表和用户基础信息表做JOIN。

-- 倾斜SQL:用户行为表 JOIN 用户基础表
-- 问题:user_id有15%是NULL(未登录的匿名行为)
SELECT
  b.user_id,
  COUNT(*) AS event_cnt_7d,
  COUNT(DISTINCT event_name) AS event_types
FROM dwd_user_event a
JOIN dim_user_base b ON a.user_id = b.user_id
WHERE a.dt BETWEEN DATE_SUB('${bizdate}', 7) AND '${bizdate}'
GROUP BY b.user_id;
 
-- Spark UI表现:
-- Stage 3: 199/200 tasks completed in 2min
-- Stage 3: 1/200 tasks running for 3h 47min  <-- 这个Task处理了所有NULL

问题就在于user_id有15%是NULL——未登录的匿名用户行为。Shuffle时,所有NULL值的数据被分到了同一个Reducer,这一个Task要处理几千万条数据,其他Task只处理几万到几十万条。

方案一:过滤NULL值——最简单有效

如果NULL值对业务没有意义(匿名用户不需要建画像),直接过滤掉是最简单的方案。

-- 方案一:过滤NULL值
SELECT
  b.user_id,
  COUNT(*) AS event_cnt_7d,
  COUNT(DISTINCT event_name) AS event_types
FROM dwd_user_event a
JOIN dim_user_base b ON a.user_id = b.user_id
WHERE a.dt BETWEEN DATE_SUB('${bizdate}', 7) AND '${bizdate}'
  AND a.user_id IS NOT NULL    -- 过滤NULL,消除倾斜
  AND b.user_id IS NOT NULL
GROUP BY b.user_id;
 
-- 效果:过滤掉15%的NULL数据后
-- 执行时间从4小时降到40分钟(NULL占了大量数据且全堆在一个Task)

适用场景:NULL值对业务没有意义时。画像场景下,未登录用户通常不需要建画像标签。

注意事项:如果业务需要保留NULL行为(比如分析匿名用户转化率),就不能简单过滤,需要用方案二。

方案二:加盐打散——热门key的通用解法

当倾斜不是NULL导致的,而是某些热门用户的数据量特别大时,需要用加盐打散。原理是给热门key加一个随机后缀,把一个key拆分成多个key,让数据分散到多个Reducer。

-- 方案二:加盐打散
-- 场景:某测试设备device_id占了30%的数据(每天产生千万条事件)
 
-- Step 1: 识别热门key
SELECT device_id, COUNT(*) AS cnt
FROM dwd_device_event WHERE dt = '${bizdate}'
GROUP BY device_id ORDER BY cnt DESC LIMIT 20;
-- 发现device_id='test_device_001'占了30%数据
 
-- Step 2: 大表加盐(给热门key加随机后缀)
SELECT
  CASE
    WHEN device_id = 'test_device_001'
      THEN CONCAT(device_id, '_', CAST(FLOOR(RAND() * 10) AS STRING))
    ELSE device_id
  END AS device_id_salted,
  event_name, event_time
FROM dwd_device_event WHERE dt = '${bizdate}';
 
-- Step 3: 小表膨胀(维度表复制10份,和盐值对应)
SELECT CONCAT(device_id, '_', salt) AS device_id_salted, device_name, device_type
FROM dim_device
CROSS JOIN (SELECT EXPLODE(SEQUENCE(0, 9)) AS salt) t;
 
-- Step 4: 用salted key做JOIN
-- 原来1个Task处理30%数据 -> 现在10个Task各处理3%

适用场景:能明确识别出哪些key是热门key时。如果热门key数量少(几十个),加盐效果好;如果热门key数量多或者不确定,用方案三更方便。

方案三:AQE自动处理——Spark 3.0+的福音

Spark 3.0引入了Adaptive Query Execution(AQE),可以自动检测和处理数据倾斜。开启后不需要手动加盐,Spark会自动把倾斜的分区拆分成多个小分区。

-- 方案三:开启AQE自动处理倾斜
SET spark.sql.adaptive.enabled = true;
SET spark.sql.adaptive.skewJoin.enabled = true;
 
-- AQE倾斜检测参数
SET spark.sql.adaptive.skewJoin.skewedPartitionFactor = 5;
  -- 分区数据量超过中位数的5倍就认为是倾斜
SET spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes = 256m;
  -- 分区数据量超过256MB才触发倾斜处理
 
-- AQE还能自动合并小分区、自动选择JOIN策略
SET spark.sql.adaptive.coalescePartitions.enabled = true;
SET spark.sql.adaptive.localShuffleReader.enabled = true;
 
-- 开启AQE后,直接跑原始SQL即可,不需要手动加盐
SELECT b.user_id, COUNT(*) AS event_cnt_7d
FROM dwd_user_event a
JOIN dim_user_base b ON a.user_id = b.user_id
WHERE a.dt BETWEEN DATE_SUB('${bizdate}', 7) AND '${bizdate}'
GROUP BY b.user_id;

我在出海硬件品牌的实测效果:开启AQE后,那个4小时的倾斜任务自动降到了40分钟左右。再加上过滤NULL值,最终降到12分钟。AQE是Spark 3.0以上版本的标配,强烈建议生产环境默认开启。

三种方案对比

维度过滤NULL加盐打散AQE自动处理
适用场景NULL导致的倾斜热门key导致的倾斜通用倾斜场景
实现复杂度低(加WHERE条件)高(改SQL逻辑)低(加配置参数)
对业务影响丢弃NULL数据无影响无影响
Spark版本要求无无Spark 3.0+
效果非常好(消除根因)好(手动拆分)好(自动拆分)
我的选择能过滤就过滤AQE解决不了时用默认开启

面试高频追问

追问一:怎么判断一个任务是不是数据倾斜导致的慢?

看Spark UI的Stage详情页。如果某个Stage的Task时间分布极不均匀——比如Median是10秒但Max是2小时——那大概率是倾斜。再看Shuffle Read Size,如果Max远大于Median(比如Max是10GB,Median是50MB),倾斜就确定了。然后找到那个最慢的Task,看它处理的Key是什么,就能定位倾斜的原因。

追问二:GROUP BY也会倾斜吗?不只是JOIN?

会的。GROUP BY的倾斜和JOIN类似——某些key的数据量特别大,导致对应的Reducer任务特别重。解决方案是两阶段聚合:先加随机前缀做局部聚合(第一阶段),再去掉前缀做全局聚合(第二阶段)。AQE对GROUP BY的倾斜也有效,它会自动拆分大分区。

-- GROUP BY两阶段聚合
-- 第一阶段:加盐做局部聚合
WITH stage1 AS (
  SELECT
    CONCAT(country, '_', CAST(FLOOR(RAND()*10) AS STRING)) AS country_salted,
    SUM(amount) AS partial_sum, COUNT(*) AS partial_cnt
  FROM dwd_order_detail WHERE dt = '${bizdate}'
  GROUP BY CONCAT(country, '_', CAST(FLOOR(RAND()*10) AS STRING))
)
-- 第二阶段:去盐做全局聚合
SELECT
  SUBSTRING(country_salted, 1, LENGTH(country_salted)-2) AS country,
  SUM(partial_sum) AS total_amount, SUM(partial_cnt) AS total_cnt
FROM stage1
GROUP BY SUBSTRING(country_salted, 1, LENGTH(country_salted)-2);

追问三:MapJoin能解决倾斜吗?

如果倾斜发生在大表JOIN小表的场景,MapJoin(Broadcast Join)是最好的解决方案——直接把小表广播到所有Executor,大表不需要Shuffle,自然就没有倾斜了。我在出海硬件品牌把autoBroadcastJoinThreshold从默认10MB调到200MB后,很多之前倾斜的JOIN自动变成了Broadcast Join,作业时间减少了40%。但MapJoin有个前提:小表要足够小(一般<200MB),否则广播本身就很慢。

实战踩坑

我在某出海硬件品牌遇到的那个4小时的任务,排查过程是这样的:

第一步,看Spark UI发现Stage 3有200个Task,199个在2分钟内完成,1个跑了3小时47分钟。

第二步,点进那个慢Task,发现Shuffle Read Size是12GB,而其他Task的平均值只有60MB。

第三步,怀疑是某个user_id的数据量特别大,跑了一个分布SQL,发现user_id IS NULL的数据占了15%——大约3000万条全部分到了同一个Task。

第四步,和业务确认后,NULL是未登录的匿名行为,不需要建画像。加了WHERE user_id IS NOT NULL后,任务从4小时降到40分钟。再开启AQE后,进一步降到12分钟。

教训:排查倾斜的标准流程是:看Spark UI → 找到慢Task → 查Shuffle数据分布 → 定位倾斜Key → 选择对应方案。不要一上来就加盐,先搞清楚倾斜的原因。很多时候一个WHERE条件就能解决问题,不需要复杂的加盐逻辑。

总结

画像场景的数据倾斜常见于NULL值聚集和热门用户数据量大。解决三板斧:过滤NULL(最简单)、加盐打散(通用)、AQE自动处理(最推荐)。排查流程固定四步:Spark UI看Task分布→找慢Task→查数据分布→定位根因。面试时能讲清楚排查过程和优化效果(4小时→12分钟),比说「我用了AQE」有说服力得多。

下一篇聊一个画像和数仓结合的话题:用户生命周期怎么定义?从新客到流失的状态机设计。

你们遇到过最严重的数据倾斜是什么场景?怎么解决的?评论区聊聊~

关注「Wood聊大数据」| 9年大数据老兵,聊技术、聊职场、聊成长

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