JOIN字段里有15%是NULL——NULL值倾斜怎么处理?
本文目录
先说结论
NULL在Shuffle里的特殊行为:NULL也是一个key,所有NULL值的数据会被分配到同一个分区(Task),形成天然的数据堆积。如果一张表里有10%的NULL,这10%的数据全部压到一个Task里。
NULL在JOIN里的特殊行为:NULL ≠ NULL(三值逻辑),所以JOIN ON col = col时,NULL行在两张表都无法匹配,LEFT JOIN里NULL行会直接输出NULL;INNER JOIN里NULL行会被直接丢弃。
处理方案:一般情况下,JOIN字段为NULL的行应该在JOIN前先过滤掉,单独处理;或者用COALESCE替换为一个唯一值(如随机UUID),避免NULL值聚集。
先把这段讲顺;面试官追问时,再展开下面的原理和细节。
详细讲解
NULL值导致的数据倾斜是一个很容易被忽视的问题,但在面试里却是高频考点——因为它能区分「知道数据倾斜」和「真的在生产环境踩过坑」的候选人。NULL的行为在JOIN里和普通值不一样,处理方式也完全不同。我在某出海硬件品牌做ETL时就专门踩过这个坑,数据量从40分钟优化到12分钟,用的就是过滤NULL这一招。
NULL值倾斜是怎么发生的?
理解这个问题,需要先清楚Spark的Shuffle是按key的哈希值来分配分区的。NULL值的哈希值是一个固定值(通常是0),所以所有NULL都会被分到同一个Shuffle分区,对应同一个Task来处理。
-- 假设orders表,user_id字段有15%的NULL(未登录用户的匿名订单)
-- 统计NULL占比
SELECT
COUNT(*) AS total,
COUNT(user_id) AS non_null_cnt,
COUNT(*) - COUNT(user_id) AS null_cnt,
ROUND((COUNT(*) - COUNT(user_id)) * 100.0 / COUNT(*), 2) AS null_pct
FROM orders;
-- 如果null_pct > 5%,JOIN时就要注意NULL倾斜问题
-- 下面这个JOIN会倾斜:
SELECT o.order_id, o.amount, u.user_name
FROM orders o
LEFT JOIN users u ON o.user_id = u.user_id;
-- orders表里15%的NULL全部进同一个Task,这个Task数据量是其他Task的N倍
这里有一个很重要的认知:NULL倾斜的严重程度和NULL的比例成正比。如果NULL占5%,轻度倾斜;占15%,严重倾斜;占50%,灾难级倾斜。我见过某些埋点不完整的业务表,user_id的NULL率高达40%,直接导致作业OOM。
方案一:JOIN前过滤NULL,单独处理NULL行(推荐)
这是最干净的方案。理清楚业务逻辑:JOIN字段为NULL的行,在JOIN里本来就不会有匹配结果(INNER JOIN直接丢弃,LEFT JOIN保留但关联字段全是NULL)。所以可以把NULL行提前过滤,不参与JOIN,处理完有值的行之后再UNION回来。
-- 推荐方案:过滤NULL,正常JOIN非NULL行,最后UNION NULL行
-- 路径A:user_id有值的订单,正常JOIN
SELECT
o.order_id,
o.user_id,
o.amount,
u.user_name
FROM orders o
LEFT JOIN users u ON o.user_id = u.user_id
WHERE o.user_id IS NOT NULL -- 只处理非NULL行,消除NULL倾斜
UNION ALL
-- 路径B:user_id为NULL的订单,直接返回(不JOIN,省去无意义的Shuffle)
SELECT
order_id,
user_id, -- NULL
amount,
NULL AS user_name -- 关联字段NULL,JOIN结果也是NULL
FROM orders
WHERE user_id IS NULL;
-- 效果:原来15%的NULL集中到一个Task的问题消失了
-- 路径B直接返回,不走JOIN,不触发Shuffle
方案二:COALESCE替换NULL为随机唯一值
如果业务上NULL是有意义的(比如NULL表示某种特殊状态,需要跟某个默认记录关联),可以用COALESCE把NULL替换成一个随机值,让原来聚在一起的NULL分散到不同的分区。
-- 方案:用随机UUID替换NULL,打散NULL行
SELECT
o.order_id,
o.amount,
u.user_name
FROM (
SELECT
order_id,
amount,
COALESCE(user_id, UUID()) AS user_id_filled -- NULL替换为随机UUID
FROM orders
) o
LEFT JOIN users u ON o.user_id_filled = u.user_id;
-- 注意:UUID()每次调用生成随机字符串,NULL行不会匹配到任何users记录
-- LEFT JOIN结果中,原来的NULL行对应的user_name仍然是NULL
-- 但每个NULL行被分配了不同的UUID,Shuffle时分散到不同Task
-- 这个方案的缺点:UUID()函数计算有开销,大量NULL时性能不如过滤方案
什么时候用这个方案:NULL行需要参与JOIN逻辑(比如LEFT JOIN需要保留NULL行并且还要做其他计算),且NULL行很多,过滤后UNION的方案代码太复杂时。但通常过滤方案更清晰。
方案三:直接在JOIN条件里处理NULL(小技巧)
有时候倾斜不严重(NULL比例在5%以下),或者代码改动成本高,可以在JOIN条件里加一个条件让NULL行提前散开。不过这只是减轻倾斜,不能彻底消除。
-- 在JOIN ON里加NULL处理:让NULL也能匹配但结果正确
-- 注意:这种写法改变了JOIN语义,需要结合业务判断是否适用
-- 写法一:过滤后JOIN(推荐)
SELECT o.order_id, u.user_name
FROM orders o
JOIN users u ON o.user_id = u.user_id
WHERE o.user_id IS NOT NULL; -- INNER JOIN时直接过滤,结果一样且不倾斜
-- 写法二:使用NULL安全等号(Hive/Spark SQL支持)
-- <=> 是NULL安全的等号,NULL<=>NULL = true
-- 但这样NULL会匹配到NULL,业务上通常不对
-- SELECT o.order_id, u.user_name
-- FROM orders o LEFT JOIN users u ON o.user_id <=> u.user_id;
-- 不推荐,容易产生错误的关联结果
NULL倾斜与正常倾斜的区别和处理优先级
| 场景 | 倾斜原因 | 首选方案 | 注意事项 |
|---|---|---|---|
| NULL倾斜 | NULL哈希值固定,全部压到一个分区 | JOIN前过滤NULL+UNION | 确认业务上NULL该如何处理 |
| 热点key倾斜 | 某个key数据量极大 | Broadcast/加盐打散 | 看表大小选方案 |
| 两者都有 | 有NULL且非NULL中有热点key | 先过滤NULL,再处理热点key | 分步处理,不要混在一起 |
生产环境里,NULL倾斜和热点key倾斜经常同时出现。我的经验是先过滤NULL(简单、效果显著),再观察是否还有倾斜,有的话再处理热点key。不要一次搞所有的优化,每次一个,能测量效果。
面试高频追问
追问一:NULL在不同JOIN类型里的行为是什么?
INNER JOIN:NULL不匹配任何值,NULL行被丢弃,不出现在结果里。LEFT JOIN:左表NULL行保留,右表关联字段全是NULL。RIGHT JOIN:右表NULL行保留,左表关联字段全是NULL。FULL OUTER JOIN:两侧的NULL行都保留。这些行为都基于SQL的三值逻辑:NULL ≠ NULL,NULL = NULL是UNKNOWN(非TRUE),所以JOIN条件不成立。在面试里说清楚三值逻辑,能展示你对SQL标准的理解深度。
追问二:Hive里的NULL倾斜有没有自动处理机制?
Hive有一个参数 hive.optimize.skewjoin=true,开启后Hive会检测倾斜的key并进行特殊处理。但这个参数对NULL的处理效果有限——Hive把NULL也当作普通的热点key来处理,会尝试打散,但NULL打散后JOIN语义会变化(类似UUID方案的问题)。所以即使开了这个参数,对NULL倾斜还是建议手动处理,显式过滤NULL。Spark里没有类似的自动参数,AQE的Skew Join也是针对数据量大的分区,不专门针对NULL。
追问三:如果我的JOIN key是复合key(col1 AND col2),NULL倾斜怎么处理?
复合key的情况更复杂,因为NULL可能出现在col1、col2或者两者都是NULL。处理策略:先分析哪个字段的NULL更多、倾斜更严重,针对性处理。如果两个字段都有大量NULL,可以分两步:先按(col1 IS NOT NULL AND col2 IS NOT NULL)过滤出完整的行走JOIN,然后按(col1 IS NULL OR col2 IS NULL)的不同组合分别处理,最后UNION。代码会比较长,但逻辑清晰,性能好。
实战踩坑
这个案例是我亲身踩过的最典型的NULL倾斜。我在某出海硬件品牌做设备激活分析时,orders表(广义的,实际是设备注册记录)里的user_id字段有15%是NULL——这些是设备出厂配置时的预激活记录,当时还没有绑定用户账号。
-- 原始有倾斜的SQL(作业运行时间:40分钟)
SELECT
r.device_id,
r.register_time,
u.user_name,
u.country
FROM device_register r
LEFT JOIN user_account u ON r.user_id = u.user_id;
-- Spark UI显示:有一个Task处理了1500万行(15%的NULL全在这一个Task)
-- 其他Task平均处理10万行,这一个Task是其他的150倍
-- 优化后的SQL(作业运行时间:12分钟)
SELECT
r.device_id,
r.register_time,
u.user_name,
u.country
FROM device_register r
LEFT JOIN user_account u ON r.user_id = u.user_id
WHERE r.user_id IS NOT NULL -- 非NULL行走正常JOIN
UNION ALL
SELECT
device_id,
register_time,
NULL AS user_name,
NULL AS country
FROM device_register
WHERE user_id IS NULL; -- NULL行直接返回,不走JOIN
-- 结果:40分钟 → 12分钟,降低70%执行时间
复盘:这个优化改动只有几行SQL,但效果非常明显。关键洞察是:NULL行的LEFT JOIN结果是固定的(右表字段全NULL),完全不需要走Shuffle和Hash Join,直接返回就行。过滤NULL不只是消除倾斜,也减少了不必要的计算。这是「理解业务逻辑」带来的优化,而不是简单的参数调优。
总结
NULL值倾斜因为NULL的哈希值固定,所有NULL集中到同一个Task。处理方式:JOIN前先用IS NOT NULL过滤,NULL行单独处理后UNION,这是最干净的方案。面试时能说清楚「NULL在JOIN里的三值逻辑」和「为什么NULL不需要参与JOIN可以直接返回」,就展示了对SQL语义的深层理解,而不只是知道「过滤NULL」这个操作步骤。
下一篇聊一个Spark 3.0的杀手锏——AQE(自适应查询执行)如何自动处理数据倾斜?开启之后还需要手动优化吗?
你们的数据里NULL的比例通常有多高?有没有因为NULL倾斜吃过苦头?评论区分享~
关注「Wood聊大数据」| 9年大数据老兵,聊技术、聊职场、聊成长