订阅更新

5张表连续JOIN,不知道倾斜在哪里——多表JOIN链路的系统性排查方法

数据倾斜手册 第 14 题 约 15 分钟读完

本文目录
  1. 先说结论
  2. 多表JOIN的执行计划分析
  3. 系统性排查步骤
  4. 多表JOIN倾斜的常见Pattern
  5. 多表JOIN的Broadcast策略
  6. 面试高频追问
  7. 实战踩坑
  8. 总结

先说结论

排查原则:从执行计划入手,逐Stage找最慢的Stage,再定位最慢Stage里数据量最大的JOIN,找到倾斜的根源表和key。不要凭直觉猜,一定要看数据。

多表JOIN的特殊性:多表JOIN的执行计划是树形的,Spark会把多个JOIN拆成多个Stage,每个Stage对应一次Shuffle。倾斜可能发生在任意一个Stage,也可能多个Stage都有倾斜但严重程度不同。

优化顺序:先解决最严重的倾斜(影响最大的Stage),上线后验证,再处理下一个。不要同时改多个地方,否则出了问题不知道是哪个改动导致的。

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

详细讲解

前面讲的倾斜场景基本是单个JOIN或GROUP BY的优化。但生产环境里更常见的是复杂的多表JOIN链路:一个SQL里JOIN了4-5张表,作业很慢,但你不知道倾斜发生在哪一个JOIN上,也不知道哪张表是「罪魁祸首」。这种情况的排查和优化,是高级数据开发工程师的核心能力。我在某短剧出海公司处理广告归因作业时,就有一个涉及广告平台表、用户行为表、归因规则表、素材表、渠道表的5张表JOIN链路,排查了2天才找到问题根源。

多表JOIN的执行计划分析

理解执行计划,是定位多表JOIN倾斜根源的第一步:

-- 示例:广告归因链路,5张表JOIN
SELECT
  a.ad_id,
  a.campaign_id,
  u.user_id,
  u.country,
  r.attribution_type,
  c.channel_name,
  m.creative_name,
  SUM(u.revenue) AS total_revenue
FROM ad_exposure a                              -- 大表:广告曝光,100亿行
JOIN user_behavior u ON a.click_id = u.click_id  -- 大表:用户行为,50亿行
JOIN attribution_rules r ON u.user_id = r.user_id -- 中表:归因规则,5亿行
JOIN channels c ON a.channel_id = c.channel_id    -- 小表:渠道,1万行
JOIN creatives m ON a.ad_id = m.ad_id              -- 小表:素材,50万行
GROUP BY a.ad_id, a.campaign_id, u.user_id, u.country,
         r.attribution_type, c.channel_name, m.creative_name;
 
-- 看执行计划(Spark SQL):
EXPLAIN EXTENDED
SELECT ...;  -- 上面的完整SQL
-- 或者在Databricks/Spark UI的SQL标签页看DAG图
# 从执行计划识别各Stage的JOIN方式:
# 关键字:
# BroadcastHashJoin → Broadcast Join(无Shuffle,通常很快)
# SortMergeJoin → Shuffle Join(可能倾斜)
# ShuffledHashJoin → Shuffle Hash Join(可能倾斜)
 
# 理想执行计划(大表在后,小表在前):
# Stage 1: BroadcastHashJoin(channels) → channels只有1万行,必然广播
# Stage 2: BroadcastHashJoin(creatives) → 50万行 × 每行200字节 = 100MB,可广播
# Stage 3: SortMergeJoin(ad_exposure, user_behavior) → 两张大表,Shuffle Join
# Stage 4: SortMergeJoin(result, attribution_rules) → 大表JOIN中表
# Stage 5: GROUP BY聚合
 
# Spark的Join顺序优化(CBO/AQE)会尽量把小表放到Broadcast,
# 但如果统计信息不准,可能选错Join策略

系统性排查步骤

面对多表JOIN的慢查询,按这个步骤系统排查:

# 步骤1:找到最慢的Stage
# Spark UI → Jobs → 点进慢的Job → 看各Stage的Duration
# 按Duration降序排,最慢的Stage就是优先排查对象
 
# 步骤2:分析最慢Stage的Task分布
# 点进最慢的Stage → Tasks列表
# 看max(duration) / avg(duration)比值
# 比值 > 5:倾斜
# 所有Task都慢:资源不足或Shuffle数据量过大
 
# 步骤3:识别这个Stage对应的是哪个JOIN
# 对照执行计划的DAG,找到这个Stage对应的操作
# 是哪两张表的JOIN?JOIN key是什么?
 
# 步骤4:查倾斜Task对应的key分布
# 对这个JOIN的key做分布分析(用之前讲过的方法)
SELECT key_col, COUNT(*) AS cnt
FROM table_name
GROUP BY key_col
ORDER BY cnt DESC
LIMIT 20;
 
# 步骤5:根据倾斜原因选方案
# 是热点key?用加盐或单独处理
# 是NULL?过滤NULL
# 小表?Broadcast

多表JOIN倾斜的常见Pattern

在多表JOIN链路里,倾斜通常有几种固定的Pattern:

Pattern典型场景排查特征处理方案
中间结果暴增两张大表JOIN后,中间结果集数据量远大于任何一张原表后续Stage的Shuffle数据量暴增重新审视JOIN条件,检查是否有笛卡尔积风险
传染性倾斜第一个JOIN产生倾斜的中间结果,后续JOIN使用这个结果时倾斜加剧越后面的Stage越慢从源头解决第一个JOIN的倾斜
NULL传播某张表的JOIN key有大量NULL,NULL传播到后续的中间结果里后续JOIN的Task有一个极大在第一个JOIN处过滤NULL
维度爆炸一对多的JOIN导致行数暴增,后续GROUP BY数据量极大GROUP BY Stage极慢但Task均匀在JOIN前先聚合,减少参与JOIN的行数
-- 维度爆炸示例:用户×标签,一个用户可能有100个标签
-- 原始(先JOIN再GROUP BY,中间结果暴增):
SELECT u.user_id, COUNT(DISTINCT t.tag_id) AS tag_cnt
FROM users u
JOIN user_tags t ON u.user_id = t.user_id  -- 1对100关系,行数×100
JOIN tag_info i ON t.tag_id = i.tag_id
GROUP BY u.user_id;
 
-- 优化(先聚合再JOIN,减少JOIN的数据量):
SELECT u.user_id, t.tag_cnt
FROM users u
JOIN (
  -- 先聚合:每个用户的标签数,不需要JOIN tag_info
  SELECT user_id, COUNT(DISTINCT tag_id) AS tag_cnt
  FROM user_tags
  GROUP BY user_id
) t ON u.user_id = t.user_id;
-- 优化后:参与JOIN的数据量从 用户数×平均标签数 → 用户数
-- 减少了100倍的JOIN数据量

多表JOIN的Broadcast策略

多表JOIN里,应该尽量把能广播的表都广播出去,减少Shuffle的次数:

-- 回到广告归因的5表JOIN,优化策略:
SELECT /*+ BROADCAST(c), BROADCAST(m) */
-- channels(1万行)和creatives(50万行,<200MB)都广播
  a.ad_id,
  a.campaign_id,
  u.user_id,
  u.country,
  r.attribution_type,
  c.channel_name,
  m.creative_name,
  SUM(u.revenue) AS total_revenue
FROM ad_exposure a
JOIN user_behavior u ON a.click_id = u.click_id
JOIN attribution_rules r ON u.user_id = r.user_id
JOIN channels c ON a.channel_id = c.channel_id     -- 广播
JOIN creatives m ON a.ad_id = m.ad_id               -- 广播
GROUP BY ...
 
-- 优化后:
-- Stage减少:channels和creatives的JOIN不再产生Shuffle Stage
-- 剩余需要Shuffle的只有ad_exposure × user_behavior × attribution_rules
-- 再针对这3张大表的倾斜做专项处理
-- 如果attribution_rules按user_id有倾斜,单独处理:
-- 方案:把attribution_rules按user_id分桶,利用Bucket Join
-- (两张表都需要按相同key分相同桶数)
 
-- 或者:对user_behavior的user_id做NULL过滤
SELECT /*+ BROADCAST(c), BROADCAST(m) */
  a.ad_id, ...
FROM ad_exposure a
JOIN (
  SELECT * FROM user_behavior WHERE click_id IS NOT NULL  -- 过滤NULL
) u ON a.click_id = u.click_id
LEFT JOIN (
  SELECT * FROM attribution_rules WHERE user_id IS NOT NULL  -- 过滤NULL
) r ON u.user_id = r.user_id
JOIN channels c ON a.channel_id = c.channel_id
JOIN creatives m ON a.ad_id = m.ad_id
GROUP BY ...;

面试高频追问

追问一:多表JOIN里,Spark的JOIN顺序是怎么决定的?能手动控制吗?

Spark默认用基于成本的优化器(CBO)决定JOIN顺序,CBO依赖表的统计信息(行数、大小、列基数等)来估算每种JOIN顺序的代价,选择代价最小的。如果统计信息不准(没有运行ANALYZE TABLE,或者统计信息过期),CBO可能选错JOIN顺序。手动控制方法:1)用BROADCAST Hint强制指定哪些表广播;2)在Spark SQL里,可以用LEADING Hint指定JOIN顺序(Spark 3.x支持):SELECT /*+ LEADING(a, u, r) */ ...,告诉Spark先JOIN a和u,再JOIN r;3)对关键表运行ANALYZE TABLE table_name COMPUTE STATISTICS FOR ALL COLUMNS,更新统计信息,让CBO做出更好的决策。

追问二:多表JOIN里某个JOIN的结果作为子查询,这个子查询里有倾斜,怎么处理?

子查询的倾斜处理方式和普通表一样,但要注意一个细节:在子查询里加盐打散后,外层JOIN要用打散后的key,不能再用原始key(因为原始key已经被修改了)。建议的处理方式:尽量把倾斜处理放在最内层的子查询里,让外层看到的是已经处理好的均匀数据。如果子查询里的倾斜无法在子查询内处理(比如需要外层数据参与),可以考虑把子查询物化为临时表,在临时表上做倾斜处理后,外层再JOIN临时表。Spark的临时视图(CREATE TEMP VIEW)或显式的DataFrame.cache()可以实现这个效果。

追问三:多表JOIN后GROUP BY,GROUP BY的结果也倾斜,怎么处理?

GROUP BY在多表JOIN之后,GROUP BY的key通常是几张表的组合维度。如果组合维度倾斜,说明某些维度组合的数据量特别多。处理思路:先检查GROUP BY之前的JOIN是否已经产生了数据膨胀(维度爆炸),如果是,先解决JOIN的数据膨胀;如果GROUP BY本身的key有热点,用两阶段聚合打散。多层链路里要从最上游的问题开始修,不要从GROUP BY开始——因为GROUP BY看到的数据量是JOIN之后的,如果JOIN产生了100倍的数据膨胀,GROUP BY就算处理好了也快不了多少。

实战踩坑

我在某短剧出海公司处理广告归因作业时,有一个5表JOIN的SQL,涉及广告曝光、用户点击、归因事件、素材信息、渠道信息。这个作业每天要跑4-5个小时,SLA要求是3小时内完成。

# 排查过程(耗时2天):
 
# Day 1 上午:看Spark UI,发现有3个Stage很慢
# 最慢的Stage:4小时20分,有2个Task分别跑了3小时和4小时
# 其他100个Task平均10分钟
# → 严重倾斜,比值26倍
 
# 对应执行计划:这个Stage是ad_exposure JOIN user_click的结果
# JOIN key是click_id
 
# 查click_id的分布:
# 发现:空点击(click_id=NULL)占总数据的18%
# → NULL倾斜!18%的click_id为NULL全部压到一个Task
 
# Day 1 下午:过滤NULL,作业从5小时降到2小时20分
# 但还没达到3小时SLA要求(当时感觉应该到了,实际测试发现还差)
 
# Day 2 上午:继续看其他慢Stage
# 第二慢的Stage:JOIN归因规则表,某个campaign_id有大量归因数据
# 这个campaign_id是某个爆款短剧的推广,当天集中大量广告投放
 
# 处理方案:campaign_id的归因数据加盐打散(salt=0-4,5份)
# 再加上把渠道表和素材表设为Broadcast
 
# 最终结果:
# 两个倾斜处理组合:作业从5小时降到1小时45分,满足3小时SLA
-- 优化后的SQL框架(简化版)
SELECT /*+ BROADCAST(c), BROADCAST(m) */
  a.ad_id,
  a.campaign_id,
  r.attribution_type,
  c.channel_name,
  m.creative_name,
  SUM(u.revenue) AS total_revenue
FROM (
  -- 大表1:过滤NULL click_id(消除NULL倾斜)
  SELECT * FROM ad_exposure WHERE click_id IS NOT NULL
) a
JOIN (
  -- 大表2:过滤NULL click_id
  SELECT * FROM user_click WHERE click_id IS NOT NULL
) u ON a.click_id = u.click_id
LEFT JOIN (
  -- 中表:对campaign_id加盐处理(消除热点campaign倾斜)
  SELECT
    CONCAT(CAST(FLOOR(RAND()*5) AS STRING), '_', campaign_id) AS salted_campaign,
    campaign_id,
    user_id,
    attribution_type
  FROM attribution_rules
) r ON CONCAT(CAST(FLOOR(RAND()*5) AS STRING), '_', a.campaign_id) = r.salted_campaign
  AND u.user_id = r.user_id
JOIN channels c ON a.channel_id = c.channel_id
JOIN creatives m ON a.ad_id = m.ad_id
GROUP BY a.ad_id, a.campaign_id, r.attribution_type, c.channel_name, m.creative_name;

复盘:多表JOIN的倾斜排查要有耐心,不要一次改所有的优化,要逐个找、逐个改、逐个验证。Day 1解决了NULL倾斜(最严重),Day 2解决了热点key倾斜(次严重),两步组合才达到目标。如果Day 1就同时改两个,就不知道哪个优化贡献了多少,也不好判断下一步方向。

总结

多表JOIN倾斜的排查:从Spark UI找最慢Stage → 分析Task分布 → 对照执行计划定位是哪个JOIN → 查数据分布找根因 → 针对性选方案。多表JOIN的常见Pattern:NULL传播、维度爆炸、传染性倾斜,每种Pattern有对应的处理策略。优化顺序:解决最严重的,上线验证,再处理下一个。面试时能讲出「逐Stage排查、逐个处理、逐个验证」的工作方式,展示了系统性工程思维而不是凭感觉调优。

最后一篇(第15篇)聊一个有点哲学的问题:什么时候数据倾斜是「故意」的?某些设计里,适度的倾斜反而是最优解——什么场景下不需要消除倾斜?

你们遇到过多表JOIN链路倾斜的排查经历吗?最后发现根源在哪张表或哪个key?评论区分享~

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

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