LeetCode 高频 SQL 50 题里的第 577 题“员工奖金”,是我面试候选人和带新人时最喜欢的开场题之一。题面很短:Employee 表存员工基础信息,Bonus 表存奖金记录,要求查出所有奖金严格低于 1000 的员工,并且没有奖金记录的员工也要算进来。很多人第一眼觉得这题五分钟就能写完,真正跑起来才发现 NULL 才是最大的坑。这篇文章我会完整拆解这道题的三种常见写法、NULL 的底层判空逻辑,以及它对应的真实业务场景,最后再聊聊我在实际工作中踩过的类似问题。
1. 题意拆解:Employee 表和 Bonus 表的关联逻辑
1.1 两张表的结构和关系
先看题目给出的表结构,这也是实际业务里非常经典的主表 + 明细表设计。
Employee 表:
| 列名 | 类型 | 说明 |
|---|---|---|
| empId | int | 员工编号,主键 |
| name | varchar | 员工姓名 |
| supervisor | int | 上级编号 |
| salary | int | 工资 |
Bonus 表:
| 列名 | 类型 | 说明 |
|---|---|---|
| empId | int | 员工编号,与 Employee.empId 关联 |
| bonus | int | 奖金金额 |
示例数据:
Employee 表:
| empId | name | supervisor | salary |
|---|---|---|---|
| 3 | Brad | null | 4000 |
| 1 | John | 3 | 1000 |
| 2 | Dan | 3 | 2000 |
| 4 | Thomas | 3 | 4000 |
Bonus 表:
| empId | bonus |
|---|---|
| 2 | 500 |
| 4 | 2000 |
目标查询结果:
| name | bonus |
|---|---|
| Brad | null |
| John | null |
| Dan | 500 |
Brad 没有奖金记录所以 bonus 为 NULL,John 同样没有奖金记录,Dan 奖金 500 小于 1000 所以被选中。Thomas 奖金 2000 大于等于 1000,不满足条件,不出现。
1.2 这道题真正想考什么
这道题表面上看是入门级的 JOIN 查询,但实际刷过的人都知道,它重点考察两个点:
LEFT JOIN和INNER JOIN的语义差异:题目要求“没有奖金的员工”也要查出来,这意味着不能丢掉左表中未匹配上的行,必须用LEFT JOIN或等价写法。NULL参与比较运算的结果:奖金为NULL的员工,如果直接在WHERE里写b.bonus < 1000,NULL会导致整行被过滤掉,最终结果少人。
在我的经验里,能把这两点下意识想清楚的候选人,后续问关联查询、聚合查询时通常也不会太差。这也是为什么很多 SQL 题库把这一题放在入门和进阶的过渡位置。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 标准解法:LEFT JOIN 配合 NULL 判断的正确姿势
2.1 正确写法
sql复制SELECT
e.name,
b.bonus
FROM Employee e
LEFT JOIN Bonus b ON e.empId = b.empId
WHERE b.bonus < 1000 OR b.bonus IS NULL;
这个写法能通过 LeetCode 的评测,逻辑也很直白。LEFT JOIN 会把 Employee 左表所有员工保留下来,Bonus 表中找不到对应记录的员工,右侧字段自动补 NULL。然后用 WHERE 条件做过滤:要么奖金明确小于 1000,要么奖金就是 NULL。
运行过程可以拆成两步理解:
- 先做关联:
Employee全部员工和Bonus的奖金记录做左连接,得到一张中间表。 - 再过滤:
WHERE对中间表逐行判断,留下满足bonus < 1000或bonus IS NULL的行。
2.2 为什么不直接写 b.bonus < 1000
这是新手最容易踩的坑。假设你写成:
sql复制SELECT
e.name,
b.bonus
FROM Employee e
LEFT JOIN Bonus b ON e.empId = b.empId
WHERE b.bonus < 1000;
结果会变成:
| name | bonus |
|---|---|
| Dan | 500 |
Brad 和 John 直接消失了。原因在于:SQL 里 NULL 不是一个值,而是“未知”。NULL < 1000 的结果不是 TRUE,而是 UNKNOWN。WHERE 子句只会保留那些判断结果为 TRUE 的行,FALSE 和 UNKNOWN 都会被过滤掉。所以 NULL 的行根本没机会留下。
2.3 相对稳妥的另一种写法
如果不想在 WHERE 里同时写两个条件,也可以用 COALESCE 把 NULL 转换成一个明确的值:
sql复制SELECT
e.name,
b.bonus
FROM Employee e
LEFT JOIN Bonus b ON e.empId = b.empId
WHERE COALESCE(b.bonus, 0) < 1000;
COALESCE(b.bonus, 0) 的作用是:如果 b.bonus 是 NULL,就当成 0 处理;否则返回原始值。0 一定小于 1000,所以没有奖金的员工能正常被选中。这个方法在面试里也很常见,它把“处理未知”的语义显式表达了出来,别人看你代码时一眼就能懂。
不过注意一点:COALESCE 会让 bonus 字段上的普通索引失效,因为对列做了函数运算。在大数据量场景下,这个写法可能带来额外的全表扫描成本。这道题数据量小,怎么写都无所谓,但放到生产环境就要评估了。
3. 三值逻辑:SQL 中 NULL 不等于任何值,包括它自己
3.1 TRUE / FALSE / UNKNOWN
很多刚接触 SQL 的人会把 NULL 理解成“空字符串”或者“0”,这是最根本的误解。NULL 在 SQL 标准里表示“未知值”,它的比较结果不是简单的二值逻辑,而是三值逻辑:TRUE、FALSE、UNKNOWN。
举个例子:
NULL < 1000的结果是UNKNOWN。NULL = NULL的结果也是UNKNOWN。NULL IS NULL的结果才是TRUE。
所以判断一个字段是不是 NULL,永远不能用 = 或 !=,必须用 IS NULL / IS NOT NULL。
3.2 从这道题延伸出来的判断规则
判断 NULL 的规则可以总结成一张速查表:
| 表达式 | 结果 |
|---|---|
NULL < 1000 |
UNKNOWN |
NULL = NULL |
UNKNOWN |
NULL <> NULL |
UNKNOWN |
NULL IS NULL |
TRUE |
NULL IS NOT NULL |
FALSE |
NOT (NULL IS NULL) |
FALSE |
再看一个容易混淆的场景:NOT (b.bonus < 1000) 能不能筛选出奖金大于等于 1000 的员工?不能。NOT UNKNOWN 的结果仍然是 UNKNOWN,所以 NULL 行依然会被过滤掉。这也是用布尔逻辑操作 NULL 时最需要注意的地方。
3.3 NULL 在排序、聚合、去重中的不同表现
这道题只涉及比较运算,但实际工作中,NULL 的行为会扩散到各种 SQL 操作里:
- 排序:在 MySQL 中
NULL默认排在最前面;在 SQL Server 中NULL默认排在最后面。不同数据库实现并不统一。 - 聚合:
COUNT(b.bonus)不会统计NULL行,COUNT(*)会统计所有行,SUM、AVG会忽略NULL行。这个差异在报表统计里非常容易踩雷。 - 去重:
DISTINCT会把多个NULL当作同一个值处理,UNIQUE约束在多数数据库里允许存在多个NULL。
这些不是这道题直接要求的内容,但也是高频考点。面试官的常见追问路线就是:先问你 577 题的 NULL 比较怎么写,再问你 COUNT(*) 和 COUNT(bonus) 的区别,最后让你解释为什么 AVG(bonus) 单独算出来的结果和手工计算不一致。本质都是在考察你对 NULL 语义的理解深度。
4. 多种等价的查询写法及跨数据库适配
4.1 子查询写法
如果你不喜欢 LEFT JOIN,也可以用子查询实现相同的效果。比如用 NOT IN:
sql复制SELECT
e.name,
b.bonus
FROM Employee e
LEFT JOIN Bonus b ON e.empId = b.empId
WHERE e.empId NOT IN (
SELECT empId
FROM Bonus
WHERE bonus >= 1000
);
这个思路是:奖金少于 1000 的员工,等价于“不在奖金大于等于 1000 的员工集合里”。注意这里依然用了 LEFT JOIN,是为了把未匹配员工的名字和 NULL 奖金显示出来。
如果不想要 LEFT JOIN,也可以反向查:
sql复制SELECT
e.name,
b.bonus
FROM Employee e
LEFT JOIN Bonus b ON e.empId = b.empId
WHERE NOT EXISTS (
SELECT 1
FROM Bonus b2
WHERE b2.empId = e.empId
AND b2.bonus >= 1000
);
这种写法在逻辑上更接近人类的自然语言:“找出那些不存在一条奖金记录大于等于 1000 的员工。”不过它需要对 Employee 的每一行都做一次相关子查询判断,性能上通常不如一次 LEFT JOIN 来得快。
4.2 不同数据库的 NULL 处理函数差异
标准 SQL 里提供了 COALESCE 函数,大部分主流数据库都支持。但不同数据库还提供了各自的快捷函数:
| 数据库 | 函数 | 示例 |
|---|---|---|
| MySQL | IFNULL |
IFNULL(b.bonus, 0) < 1000 |
| SQL Server | ISNULL |
ISNULL(b.bonus, 0) < 1000 |
| Oracle / 达梦 | NVL |
NVL(b.bonus, 0) < 1000 |
| PostgreSQL | COALESCE |
COALESCE(b.bonus, 0) < 1000 |
| 通用标准 | COALESCE |
COALESCE(b.bonus, 0) < 1000 |
如果你在做跨数据库兼容的代码,尽量使用 COALESCE,因为它是 SQL 标准的一部分。国内不少项目用的是达梦数据库,它兼容 Oracle 的语法,NVL 可用,但团队代码规范通常建议统一用 COALESCE 来降低迁移成本。这个细节在写通用 DAO 层或者做数据库迁移时尤其重要,我在实际项目里就见过因为把 ISNULL 从 SQL Server 带到另一个数据库,导致语法报错的情况。
4.3 关联键唯一性:Bonus 表 empId 必须有唯一约束吗
这道题能直接用 LEFT JOIN,前提是 Bonus 表里每个 empId 最多只对应一条记录。如果 Bonus 表存在多条重复的 empId,左连接后会出现员工记录倍增的问题。实际的奖励系统里,偶尔会存在一个人有两条奖金记录的场景,比如“绩效奖”和“项目奖”分成两张明细,或者一条记录是预发、一条是实发。
遇到这种情况,需要先用 GROUP BY 或窗口函数把奖金汇总成唯一状态,再和主表关联。否则查询结果里同一个员工会出现多行,统计口径就乱了。
5. 一对多关系、重复数据排查与生产环境实践
5.1 一对多关系导致的重复数据
我在真实业务中处理过一个和 577 题非常相似的需求:客户表 + 客户优惠券表,要求查出“优惠券余额不足 10 元”的客户。表面上就是一道 LEFT JOIN 题目,但实际线上优惠券表里一个客户可能有多张券,直接关联会出现一条客户记录对应多张券的情况。
这时候的 WHERE 条件就复杂了,你可能想找的是“客户所有券的余额总和小于 10”,或者“客户存在任意一张券余额小于 10”,这两种语义完全不同。前者需要先聚合再关联,后者可以直接关联并去重。
所以拿到需求时,第一个要确认的问题永远是:关联表在主表维度上是否唯一? 这也是 577 题这个样例数据隐含的前提。LeetCode 的判定数据保证了 Bonus 表 empId 唯一,生产环境并没有这个保证,你得自己确认或者自行处理。
5.2 用检查唯一性的 SQL 快速定位生产问题
我自己排查这种问题时的习惯是,先跑一条分组统计,看看关联字段是否有重复:
sql复制SELECT
empId,
COUNT(*) AS cnt
FROM Bonus
GROUP BY empId
HAVING COUNT(*) > 1;
如果结果集不为空,就说明这张表在 empId 维度上不是唯一的。这时再决定是否需要在关联前做聚合去重。这个排查思路适用于任何主表 + 明细表的关联场景,比直接看表结构更可靠,因为建表文档和实际数据经常不一致。
5.3 面试追问:如何把这道题升级成聚合版本
如果你在面试时顺利答出了基础版本,面试官下一个问题很可能就是“假设一个员工有多条奖金记录,要查所有员工奖金总和小于 1000 的人,怎么写?”这时候基础版就不够用了,需要改写成先聚合再关联:
sql复制WITH bonus_sum AS (
SELECT
empId,
SUM(bonus) AS total_bonus
FROM Bonus
GROUP BY empId
)
SELECT
e.name,
bs.total_bonus
FROM Employee e
LEFT JOIN bonus_sum bs ON e.empId = bs.empId
WHERE COALESCE(bs.total_bonus, 0) < 1000;
注意这里不能直接用 WHERE bs.total_bonus < 1000 OR bs.total_bonus IS NULL,虽然逻辑对,但 COALESCE 的表达更紧凑,并且在聚合结果表上做函数运算,并不会显著影响性能,因为 bonus_sum 已经是一张小表了。
6. 高频相关考点:COALESCE、EXISTS 和范围查询中的 NULL 陷阱
6.1 COALESCE 的多参数能力
COALESCE 其实可以接收多个参数,返回第一个非 NULL 值。比如:
sql复制SELECT COALESCE(NULL, NULL, 500, 1000);
结果是 500。这个特性在复杂的报表计算中非常有用,可以用一个表达式完成多层默认值替换。比如某个查询需要展示奖金,如果奖金表没有记录,就展示工资的 10% 作为预估奖金:
sql复制SELECT
e.name,
COALESCE(b.bonus, e.salary * 0.1) AS final_bonus
FROM Employee e
LEFT JOIN Bonus b ON e.empId = b.empId;
这类需求在实际报表中很常见,COALESCE 的写法比 CASE WHEN 简洁很多,可读性也更好。
6.2 EXISTS 和 LEFT JOIN 的取舍
577 题的场景里,LEFT JOIN 通常是首选,因为它需要把 Bonus 表的字段也展示出来。但如果题目只要求“返回员工名字”,不关心奖金字段,用 EXISTS 可能更高效:
sql复制SELECT e.name
FROM Employee e
WHERE NOT EXISTS (
SELECT 1
FROM Bonus b
WHERE b.empId = e.empId
AND b.bonus >= 1000
);
这样避免了连接后产生中间宽表,而且在 Bonus 表数据量非常大的时候,EXISTS 可以走 empId 上的索引提前终止匹配,性能常常优于 LEFT JOIN 后 WHERE 过滤。
两者的选择标准,我总结成一句口诀:要展示附表字段,用 LEFT JOIN;只需要判断存在性,用 EXISTS。 这个原则虽然不绝对,但在绝大多数场景下都能指导你写出更合理的查询。
6.3 BETWEEN 范围查询中的 NULL 陷阱
热搜词里出现了“sql between and 的用法总结”,这里也顺便提一个和 577 题同类的坑。BETWEEN 实际上是 >= 和 <= 的语法糖,所以它同样继承了三值逻辑。比如:
sql复制WHERE b.bonus BETWEEN 0 AND 1000
当 b.bonus 为 NULL 时,判断结果依然是 UNKNOWN,行会被过滤。所以如果你希望保留 NULL,不能只靠 BETWEEN,依然要额外加 OR b.bonus IS NULL。
7. 我在实际使用过程中的几条经验
最后分享几点个人感受,都是我刷题和工作中反复遇到的:
第一,写 JOIN 查询之前,先确认三件事:关联字段有没有重复、用哪种 JOIN 语义、NULL 是否可能出现。不需要完全确定才动手,但至少心里要有数。
第二,拿到类似 577 题这种“查缺失数据”的需求时,最快的写法是 LEFT JOIN 加上 IS NULL 判断。这个套路可以套用到非常多场景:查未下单用户、查未打卡记录、查未回访客户。
第三,看执行计划永远是验证 SQL 性能的最直接手段。COALESCE、IFNULL 这类函数包裹字段的写法,在表很大时可能让优化器放弃索引。生产环境的查询如果发现走了全表扫描,优先看看是不是筛选字段被函数包裹了。
第四,也不要把所有问题都依赖 NOT IN。当子查询结果集中包含 NULL 时会遇到一个隐蔽问题:NOT IN 整体结果会变成空集,因为 NULL 参与 NOT IN 运算后全部变成 UNKNOWN,导致一条都查不出来。这个坑比 NULL < 1000 更隐蔽,排查起来也更费劲。
SQL 的 NULL 处理从来不是一个孤立的知识点,它会出现在比较、聚合、关联、子查询的每一个角落。把 577 题吃透,等于给这些知识点打了一个比较扎实的地基,后续再遇到相关排查问题,思路会顺畅很多。
