要说SQL数据清洗里最隐蔽的陷阱,很多都得算在做表关联(JOIN)时如何妥善处理空值(NULL)这件事上。一些小细节没注意,结果就可能完全偏离预期,而且事后排查起来相当困难。今天就把几个常见的坑和标准化的应对思路拆开来讲,希望能帮大家少走些弯路,提升数据清洗的效率与准确性。
LEFT JOIN 后 WHERE 误写右表字段导致“查不到缺失数据”
想用 LEFT JOIN 找出左表有、右表没有匹配的记录,结果却一条都查不出来?遇到这种情况,十有八九是 WHERE 条件里写了右表的非空判断,比如 WHERE t2.status = 'active'。
问题的根源在于 SQL 的执行顺序:先执行 JOIN,再进行 WHERE 过滤。左连接之后,右表没有匹配的字段值本身就是 NULL,只要 WHERE 里出现对右表字段的判断,这些 NULL 行就会被直接筛选掉——即便你原本的目标就是找出它们。
要查找“缺失”的记录,标准写法就是 WHERE t2.id IS NULL,并且这个条件之外不能再混入任何右表的字段条件。如果确实需要根据右表状态进行筛选,就必须把条件移到 ON 子句中:LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'paid'。这样一来,右表条件在 JOIN 阶段就已经生效,不会影响 WHERE 对 NULL 行的保留。
- 先运行
SELECT *查看原始的 JOIN 结果,确认右表列是否真的为NULL,然后再添加WHERE条件——这是最稳妥的验证方法。 - 在多层 LEFT JOIN 的情况下,每一层的
IS NULL语义必须厘清。例如第一层IS NULL表示“用户根本没有订单”,第二层则代表“有订单但缺少明细”,两者业务含义完全不同。 - 若逻辑较为复杂,自己拿捏不准,不妨改用
NOT EXISTS:SELECT * FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)。语义更直观,也不容易出错。
JOIN 前不检查重复主键,引发结果行数激增
一个经典案例:users 表里 id = 100 有 3 条重复记录,orders 表里 user_id = 100 有 5 条记录,直接做 JOIN,结果一下产出 15 行。这并非业务上的多对多关系,纯粹是源数据脏了。
数据清洗的首要原则:在 JOIN 之前,必须定位并处理重复主键。先运行 SELECT id, COUNT(*) FROM users GROUP BY id HAVING COUNT(*) > 1,找出哪些 ID 存在重复,再根据业务需求决定如何保留。
- 可以使用
ROW_NUMBER() OVER (PARTITION BY id ORDER BY updated_at DESC) AS rn来生成唯一锚点,JOIN 时再加上AND rn = 1条件,确保只关联一条记录。 - 切忌用临时的
DISTINCT来应付。它仅在最终结果行上去重,并不能消除源数据的歧义,该膨胀的行数一个都不会少。 PARTITION BY的字段必须与后续 JOIN 的条件完全一致,否则预聚合完全失效,等于白做。
用 LEFT JOIN + IS NULL 安全剔除黑名单记录
清洗时常见的一个需求是“剔除所有命中黑名单用户或高风险设备的订单”。与 NOT IN 或 NOT EXISTS 相比,LEFT JOIN ... WHERE b.user_id IS NULL 这种写法更直观、更容易控制——特别是当黑名单表本身包含 NULL 或空值时,NOT IN 很容易出现问题。
但要注意一个性能陷阱:在 ON 条件里使用 OR,例如 ON o.user_id = b.user_id OR o.device_fingerprint = b.device_fingerprint。这种写法会让索引基本失效,查询效率大打折扣。更可靠的做法是拆成两个独立的 LEFT JOIN:
SELECT o.* FROM orders o LEFT JOIN blacklist_rules b1 ON o.user_id = b1.user_id LEFT JOIN blacklist_rules b2 ON o.device_fingerprint = b2.device_fingerprint WHERE b1.user_id IS NULL AND b2.device_fingerprint IS NULL;
- 如果黑名单表的数据量很小(比如几百条),也可以考虑用内存列表配合
NOT IN,但前提是能确认黑名单字段没有 NULL 值。 - 务必提前检查黑名单表里是否有重复的
user_id或者全是NULL的行,否则IS NULL判断会误判,导致该剔除的数据没有被剔除。 - 不要在
ON条件中使用函数,比如ON UPPER(u.email) = r.email_pattern。这种写法会让索引彻底失效,相当于全表扫描。
INNER JOIN 天然过滤 NULL 关联字段,别误以为是“数据遗漏”
左表有 100 行,右表有 50 行,INNER JOIN 跑完只出来 30 行?这种情况大多数时候不是数据丢失,而是关联字段中含有 NULL 值。只要 ON t1.id = t2.ref_id 中任何一方为 NULL,整行匹配结果就是 UNKNOWN(SQL 的三值逻辑),直接被丢弃。
这相当于隐式添加了一个 WHERE t1.id IS NOT NULL AND t2.ref_id IS NOT NULL。如果业务上允许 ref_id 为空,又希望保留左表记录,那就需要换成 LEFT JOIN,然后手动在 WHERE 中加上 t2.ref_id IS NOT NULL 来做过滤。
- 怎么验证是否真的有数据遗漏?对比左表主键的全集,与 JOIN 后左表主键的去重集合,查看差集在哪里。
- 使用
EXPLAIN查看执行计划。如果发现预估行数异常偏低,或者出现与 NULL 关联相关的提示,优先检查关联字段中 NULL 值的分布情况。 - 避免在
ON条件中放入模糊匹配,例如t2.name LIKE '%abc%'。这种写法既慢又无法优化,属于性能杀手。
说到底,数据清洗中最容易被忽略的,其实就是 NULL 值在不同 JOIN 类型下的行为差异,以及它如何悄无声息地改变结果集的基数。一次没注意 IS NULL 放在哪里,或者漏查了判重字段的 NULL 分布,都可能导致清洗结果完全偏离预期,而且很难回溯排查。把这些细节提前理清楚,后面的数据加工工作才会更顺畅。
