5张表连续JOIN,不知道倾斜在哪里——多表JOIN链路的系统性排查方法
先说结论
排查原则:从执行计划入手,逐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年大数据老兵,聊技术、聊职场、聊成长