四个方面,九道题
JOIN:把两种答案区分开的那些问题
2 道题「LEFT JOIN 保留左表未匹配行」人人会背。分数在后半句:那些行的右表列全是 NULL,而这一个事实就解释了两个经典 bug——在 WHERE 里对右表列加条件,会把 LEFT JOIN 悄悄变回 INNER JOIN;COUNT(右表列) 会把它们静默跳过。把 NULL 这个后果说出来,两个追问就一起答掉了。
通常怎么问:「LEFT JOIN 和 INNER JOIN 有什么区别?」·「匹配不上的行会怎样?」
第 1 题
表 users 有 100 行,表 orders 只有其中 30 个用户的订单。两个查询各返回多少行?
-- A
SELECT * FROM users u INNER JOIN orders o ON o.user_id = u.id;
-- B
SELECT * FROM users u LEFT JOIN orders o ON o.user_id = u.id;
- AA 返回 30 行,B 返回 100 行——一定如此
- BA 每条匹配订单一行;B 是这一批再加上 70 个未匹配用户各一行(右表列全 NULL)
- C两个都恰好返回 100 行
- DA 返回 100 行,B 返回 30 行
▶看答案与解析
正确答案:B. A 每条匹配订单一行;B 是这一批再加上 70 个未匹配用户各一行(右表列全 NULL)
🐱 选项 A 那个「30」正是陷阱:一个用户有 5 条订单就产生 5 行,所以 INNER JOIN 返回的是每条匹配订单一行,不是每个用户一行。B 在此基础上再加 70 行、其 orders 列全为 NULL。这种「行数被乘开」的效应,正是 join 之后 COUNT(*) 经常看着不对的原因——面试官通常是在看你会不会自己发现,而不必等他点破。
第 2 题
为什么加上这个 WHERE 之后,LEFT JOIN 就变了味?
SELECT u.id, o.total
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.total > 100;
- A不会变;LEFT JOIN 永远保留左表全部行
- B未匹配行的 o.total 是 NULL,而
NULL > 100 不为真,于是被过滤掉——结果等同于 INNER JOIN - CWHERE 先于 join 执行,所以先过滤了 orders 表
- D这在标准 SQL 里是语法错误
▶看答案与解析
正确答案:B. 未匹配行的 o.total 是 NULL,而 NULL > 100 不为真,于是被过滤掉——结果等同于 INNER JOIN
🐱 join 确实保留了那些未匹配行——然后 WHERE 又把它们扔掉了,因为它们的 o.total 是 NULL,而任何与 NULL 的比较结果都是 UNKNOWN,WHERE 按「非真」处理。想过滤又不丢行,就把条件写进 ON(LEFT JOIN orders o ON o.user_id = u.id AND o.total > 100),或者补 OR o.total IS NULL。真正被考的能力,是知道条件该放在哪里。
NULL:那个不是值的值
2 道题NULL 表示「未知」,既不是空也不是零——你调试过的所有反直觉 SQL 结果,几乎都由此而来。与 NULL 比较返回 UNKNOWN(所以要用 IS NULL),聚合函数会跳过 NULL(所以 COUNT(列) 和 COUNT(*) 不一样),而 NOT IN 一旦对上含 NULL 的列表会一行都不返回。面试官爱考 NULL,因为它的行为完全学得会、又完全猜不出。
通常怎么问:「COUNT(列) 到底数了什么?」·「为什么 WHERE x = NULL 不管用?」
第 3 题
orders 表有 10 行,其中 3 行的 coupon_code 是 NULL。三个表达式各返回什么?
SELECT COUNT(*), COUNT(coupon_code), COUNT(DISTINCT coupon_code)
FROM orders;
- A10, 10, 10
- B10、7、以及去重后的非 NULL 优惠码个数
- C7, 7, 7
- D10, 3, 3
▶看答案与解析
正确答案:B. 10、7、以及去重后的非 NULL 优惠码个数
🐱 COUNT(*) 数的是行、什么都不跳:10。COUNT(列) 只数非 NULL 值:7。COUNT(DISTINCT 列) 同样忽略 NULL,再去重。这是报表查询里「总数对不上」最常见的单一来源——也正是当你想问「有多少行」时,COUNT(*) 才是安全默认写法的原因。
第 4 题
明明有大量用户的 id 既不是 1 也不是 2,为什么这个查询返回零行?
SELECT * FROM users
WHERE id NOT IN (SELECT manager_id FROM staff);
-- staff.manager_id 里含有 1、2 和 NULL
- ANOT IN 不能用于子查询
- B
id NOT IN (1, 2, NULL) 对每一行都求值为 UNKNOWN,于是没有任何行通过 WHERE - C子查询返回了零行
- DNOT IN 需要索引才能工作
▶看答案与解析
正确答案:B. id NOT IN (1, 2, NULL) 对每一行都求值为 UNKNOWN,于是没有任何行通过 WHERE
🐱 x NOT IN (1, 2, NULL) 展开成 x <> 1 AND x <> 2 AND x <> NULL,最后那个比较是 UNKNOWN——于是整个表达式永远不可能为 TRUE,结果静默地成了空集。改用 NOT EXISTS(它能正确处理 NULL),或在子查询里加 WHERE manager_id IS NOT NULL。这题之所以是面试官心头好,恰恰因为这个查询看上去明显是对的。
GROUP BY、HAVING,以及过滤条件该放哪
1 道题这两个问题的答案是同一个:逻辑执行顺序。FROM 与 JOIN 先造出行,WHERE 过滤单行,GROUP BY 把行折叠成组,HAVING 过滤组,然后才是 SELECT,最后 ORDER BY。按原始列过滤用 WHERE;按聚合结果过滤用 HAVING——而且条件要尽量往前放,因为先过滤再分组意味着要分组的行更少。
通常怎么问:「WHERE 和 HAVING 的区别?」·「为什么不能选一个不在 GROUP BY 里的列?」
第 5 题
找出 2026 年下单超过 3 次的客户。哪个查询是对的?
-- A
SELECT user_id, COUNT(*) c FROM orders
WHERE COUNT(*) > 3 AND year = 2026
GROUP BY user_id;
-- B
SELECT user_id, COUNT(*) c FROM orders
WHERE year = 2026
GROUP BY user_id
HAVING COUNT(*) > 3;
- AA——过滤条件永远该写在 WHERE 里
- BB——WHERE 在分组之前过滤行,聚合条件必须写进 HAVING
- C两者完全等价
- D都不对,必须用子查询
▶看答案与解析
正确答案:B. B——WHERE 在分组之前过滤行,聚合条件必须写进 HAVING
🐱 A 直接就错:WHERE 在行被分组之前执行,此时 COUNT(*) 还不存在——多数引擎会直接报错。B 是对的,而且顺带是高效写法:先筛到 2026 年,要分组的行就少了。可以直接说出口的规则:原始列条件进 WHERE,聚合条件进 HAVING。
窗口函数:不折叠行也能排名
2 道题「每组前 N 名」是最常被问的 SQL 难题,而窗口函数正是面试官期待的答案:与 GROUP BY 不同,它在一组行上计算却不折叠行,所以每一列都还在。三个排名函数的差别只在怎么处理并列——ROW_NUMBER 从不并列(1,2,3,4),RANK 并列后跳号(1,1,3),DENSE_RANK 并列且不跳号(1,1,2)。「取前三名且含并列」该选哪个,考的就是这个。
通常怎么问:「找第二高的薪水」·「每组前 N 名」·「ROW_NUMBER、RANK、DENSE_RANK 有什么区别?」
第 6 题
三名员工的薪水分别是 100、100、90。三个排名函数按此顺序各返回什么?
- A三个函数都返回 1, 1, 2
- BROW_NUMBER: 1,2,3 · RANK: 1,1,3 · DENSE_RANK: 1,1,2
- CROW_NUMBER: 1,1,2 · RANK: 1,2,3 · DENSE_RANK: 1,1,3
- D它们是同一个函数的别名
▶看答案与解析
正确答案:B. ROW_NUMBER: 1,2,3 · RANK: 1,1,3 · DENSE_RANK: 1,1,2
🐱 ROW_NUMBER 不管并列、一律给唯一序号,所以两个 100 会任意地拿到 1 和 2。RANK 给两个 1,然后跳到 3。DENSE_RANK 给两个 1,然后接着是 2。实际后果:要「薪水前三名且包含并列」用 DENSE_RANK;要「恰好三行」用 ROW_NUMBER——选错就是行被静默丢掉或多出来的原因。
第 7 题
取每个客户最近的一笔订单,并保留订单的全部列。哪种做法合适?
SELECT * FROM (
SELECT o.*,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) rn
FROM orders o
) t WHERE rn = 1;
- A写错了——正确做法是 GROUP BY user_id 配 MAX(created_at)
- B正确:PARTITION BY 让编号按客户重新开始,rn = 1 就是每位客户最新的那一行,且所有列都在
- C它总共只返回一行,而不是每位客户一行
- D窗口函数的结果不能用来过滤
▶看答案与解析
正确答案:B. 正确:PARTITION BY 让编号按客户重新开始,rn = 1 就是每位客户最新的那一行,且所有列都在
🐱 PARTITION BY 是窗口版的 GROUP BY,区别是行不会消失:编号在每个 user_id 内重新开始,于是 rn = 1 取到每位客户最新的订单、且整行列都在。选项 A 的 GROUP BY 写法只能给出最大时间戳,拿不到那一行的其余字段——要取回来就得自连接,而这份笨拙正是窗口函数要消除的东西。注意过滤必须放在外层查询:窗口函数在 WHERE 之后才计算。