ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MySQL经典50题:拆解SQL查询核心能力,搞定面试与实战

MySQL经典50题:拆解SQL查询核心能力,搞定面试与实战 在中文互联网的 SQL 学习资料里MySQL 经典50题是一套流传了很久的题集。它没有官方版本号却几乎成了后端开发、数据分析岗位面试前默认要刷一遍的清单。网上能找到十几个版本表名和字段名略有出入但核心始终是学生、教师、课程、成绩这四张表。我见过不少人把这套题当作“背诵题库”把每个答案存进收藏夹结果面试时稍微换一个条件就卡壳。我的看法是这套题真正的价值不在于押中原题而在于把 SQL 查询里最核心的几类手段——分组聚合、多表关联、子查询、集合比较——练成肌肉记忆。这篇内容我不打算把 50 道题逐个贴答案而是按自己的理解把整套题重新拆解一遍讲清楚每类题背后的思考路径再挑几道有代表性的题目做完整推理最后把实操中容易踩的坑和面试表达方式一起聊透。1. 先搞清楚这 50 题到底在模拟什么业务场景1.1 四张表构成的最小教务系统经典 50 题使用的是一个非常精简的教务管理模型通常由四张表构成Student学生表学号、姓名、出生日期、性别Course课程表课程号、课程名、教师编号Teacher教师表教师编号、教师姓名SC成绩表学号、课程号、分数这个模型看起来简单但它把关系型数据库里最重要的三类关系都覆盖了。学生和课程之间是多对多关系这种关系不能直接存必须通过 SC 成绩表作为中间表来关联教师和课程之间是一对多关系一个教师可以带多门课学生和 SC 之间是一对多关系一个学生可以有多条成绩记录。你在真实业务里看到的订单-商品-用户、文章-标签-分类、部门-员工-项目本质上都是这三种关系的排列组合。所以不要觉得“教务系统太老套”它其实是关系模型的最小完整样本。下面是一套比较通行且可复现的建表语句我用的是 MySQL 5.7 及以上版本都兼容的写法CREATE TABLE Student ( Sid VARCHAR(10) PRIMARY KEY, Sname VARCHAR(20), Sage DATETIME, Ssex VARCHAR(10) ); CREATE TABLE Teacher ( Tid VARCHAR(10) PRIMARY KEY, Tname VARCHAR(20) ); CREATE TABLE Course ( Cid VARCHAR(10) PRIMARY KEY, Cname VARCHAR(20), Tid VARCHAR(10) ); CREATE TABLE SC ( Sid VARCHAR(10), Cid VARCHAR(10), score DECIMAL(18,1), PRIMARY KEY (Sid, Cid) );数据填充部分我习惯使用一份固定样本网上的经典数据基本都能对上INSERT INTO Student VALUES (01, 赵雷, 1990-01-01, 男), (02, 钱电, 1990-12-21, 男), (03, 孙风, 1990-05-20, 男), (04, 李云, 1990-08-06, 男), (05, 周梅, 1991-12-01, 女), (06, 吴兰, 1992-03-01, 女), (07, 郑竹, 1989-07-01, 女), (08, 王菊, 1990-01-20, 女); INSERT INTO Teacher VALUES (01, 张三), (02, 李四), (03, 王五); INSERT INTO Course VALUES (01, 语文, 02), (02, 数学, 01), (03, 英语, 03); INSERT INTO SC VALUES (01, 01, 80), (01, 02, 90), (01, 03, 99), (02, 01, 70), (02, 02, 60), (02, 03, 80), (03, 01, 80), (03, 02, 80), (03, 03, 80), (04, 01, 50), (04, 02, 30), (04, 03, 20), (05, 01, 76), (05, 02, 87), (06, 01, 31), (06, 03, 34), (07, 02, 89), (07, 03, 98);这套数据里刻意留了几个特殊设计学生 08 没有选任何课课程 03 只有六个学生选学生 04 三门课都不及格。这样的边界数据在刷题时能帮你验证 JOIN、聚合、存在性判断是否正确比全是“标准答案”的数据更能暴露问题。1.2 表结构里三个容易被忽略的细节很多人直接复制建表语句就开始刷题但你越往后做越会发现三个字段设计的细节直接影响题目难度和写法第一Sid 和 Cid 用的是 VARCHAR 而不是 INT。经典 50 题里“查询 01 课程比 02 课程成绩高的学生”这类题目会频繁出现字符串和数值的混合比较。在 MySQL 中VARCHAR 和 INT 比较时会触发隐式转换某些版本和配置下可能导致索引失效或结果异常。你在自己练习时最好保持字段类型和网上题目一致这样遇到报错或预期之外的结果时才会被迫去查类型转换规则这本身就是学习过程。第二Sage 字段是 DATETIME 类型。这导致很多人在写“查询 1990 年出生的学生”这类条件时会写出WHERE Sage 1990这种错误写法。正确的处理方式是使用日期函数或范围比较比如WHERE YEAR(Sage) 1990或WHERE Sage BETWEEN 1990-01-01 AND 1990-12-31。这个坑在真实业务里也一样存储日期时间永远比存字符串规范但查询时你必须知道怎么按年、按月、按天去圈定范围。第三SC 表的主键是 (Sid, Cid) 联合主键。这个设计保证了同一个学生同一门课程只能有一条成绩记录逻辑上非常合理。但做题时你会发现某些题目需要在同一个 SC 表上做多次关联比如“查询 01 课程和 02 课程都选了的学生”本质上是在同一张明细表上做两次自关联联合主键的存在让你能放心地用多条件 JOIN 而不必担心重复数据。2. 把 50 题拆成四条能力线比按顺序硬刷更有用网上流传的经典 50 题题目顺序并不完全遵循由易到难。如果从第 1 题做到第 50 题你会发现难度像过山车前面几个简单的单表查询做到中间突然冒出几个需要自连接的复杂题目。我不建议按题号顺序硬刷更高效的方式是把所有题目按能力维度分成四类一类一类地击破。2.1 分组聚合WHERE 和 HAVING 的分工边界第一类能力线是分组聚合这也是经典 50 题里占比最高的题型。核心就一句话WHERE 在分组前过滤行HAVING 在分组后过滤组。这个区别人人都背过但真正用的时候很容易搞混。举个例子题目“查询平均成绩大于 60 分的学生的学号和平均成绩”写出错误写法的人不在少数SELECT Sid, AVG(score) FROM SC WHERE AVG(score) 60 GROUP BY Sid;这条 SQL 会直接报错因为在 MySQL 的执行逻辑里WHERE 是逐行过滤的它只能访问当前行不能访问聚合函数的结果。聚合后的过滤必须交给 HAVING。正确的写法是SELECT Sid, AVG(score) AS avg_score FROM SC GROUP BY Sid HAVING AVG(score) 60;再进阶一点看“查询两门以上不及格课程的同学的学号及其平均成绩”这道题。这里的“两门以上不及格课程”是对课程数量的统计不是对成绩明细的过滤所以应该先找出所有不及格记录再按学号分组统计课程数最后用 HAVING 过滤组SELECT Sid, AVG(score) AS avg_score FROM SC WHERE score 60 GROUP BY Sid HAVING COUNT(Cid) 2;这道题的完整推理链路是先确认数据粒度为每个学生每门课一条记录然后确定筛选条件在分组前还是分组后。不及格是一个行级条件应该用 WHERE选课数是一个组级条件应该用 HAVING。刷这类型题目时我要求自己每次写 GROUP BY 都先停下来问一句我要过滤的对象是原始行还是分组后的组。2.2 多表连接INNER JOIN 与 LEFT JOIN 的选择依据第二类能力线是多表连接。经典 50 题里一切涉及“查询所有学生”“查询所有课程”的题目都在考察你有没有理解外连接和主从表的概念。以“查询所有学生的选课情况”为例如果你用 INNER JOINSELECT Student.Sid, Student.Sname, SC.Cid, SC.score FROM Student INNER JOIN SC ON Student.Sid SC.Sid;你会发现学生 08 “王菊”整行消失了因为她在 SC 表里没有任何成绩记录。但题目的“所有学生”明确要求把没选课的学生也显示出来这时必须以学生表为主表左外连接成绩表SELECT Student.Sid, Student.Sname, SC.Cid, SC.score FROM Student LEFT JOIN SC ON Student.Sid SC.Sid;这里有一个非常实用的小技巧判断到底用 INNER JOIN 还是 LEFT JOIN不要看题目有没有“所有”两个字就机械判断而是先确定“主表是谁”。如果题目要求以 A 表为主体展示数据即使它在关联表里没有匹配记录也要保留那 A 表就必须放在 FROM 之后作为驱动表配合 LEFT JOIN。反之如果题目只关心“存在关系”的数据比如“查询所有选了课的学生成绩”那用 INNER JOIN 就够了。经典 50 题里还有一个变体是要求查询“没学过张三老师课的同学”。这种题涉及三张表关联先找张三老师的教师编号再找张三老师教的课程再找选了这些课程的学生最后从学生表里排除掉这些人。核心难点在于“排除”这个动作这就要进入第三类能力线。2.3 子查询从 NOT IN 到 NOT EXISTS 的思维升级第三类能力线是子查询与存在性判断。最典型的一类题是“查询没有学全所有课程的学生”。很多初学者第一次看到这道题会想要用 NOT IN先查出所有学全了的学生再从学生表里排除。逻辑上没错但写起来容易踩 NULL 的坑。更稳妥的写法是从“集合包含”的角度切入一个学生学全了所有课程等价于“不存在一门课程这个学生没有选”。所以反过来说没学全的学生就是“存在一门课这个学生没选”。对应的 SQL 是SELECT Sid, Sname FROM Student s WHERE EXISTS ( SELECT 1 FROM Course c WHERE NOT EXISTS ( SELECT 1 FROM SC sc WHERE sc.Sid s.Sid AND sc.Cid c.Cid ) );这是经典 50 题里最考验逻辑的一道题。外层 EXISTS 表示“存在这样一门课”内层 NOT EXISTS 表示“学生没有选这门课”。整个查询读出来就是找一个学生使得存在一门课程这个学生没有选它。这不就是没学全吗这类题的思维方式是从“满足条件的集合”变成了“是否存在反例”。在真实业务里“找出没买过某类商品的用户”“找出没有完成任何订单的客户”都是同一个套路。所以我不建议你把子查询当作语法技巧来背而是要把它理解成一种逻辑工具当题目里出现“没有”“从不”这类否定词时优先思考能不能用 NOT EXISTS 来表达。2.4 窗口函数老题新做的加分写法第四类能力线是窗口函数。经典 50 题问世时MySQL 还没有窗口函数所以网上大多数旧版答案是靠自连接实现的。如果你是 MySQL 8.0 及以上版本完全可以换一种更简洁的思路。最典型的是“查询各科成绩前三名”这类排名问题。旧版答案通常需要把 SC 表自连接然后用 COUNT(DISTINCT) 统计比自己分数高的人数人数小于 3 就说明自己排在前三。这种写法性能差且逻辑绕。如果直接用窗口函数SELECT Cid, Sid, score, rk FROM ( SELECT Cid, Sid, score, ROW_NUMBER() OVER (PARTITION BY Cid ORDER BY score DESC) AS rk FROM SC ) AS t WHERE rk 3;一下就清晰多了。窗口函数把“分组内排序编号”这个动作原生化了你只需要掌握 ROW_NUMBER、RANK、DENSE_RANK 三者的区别ROW_NUMBER 遇到并列会随机排先后RANK 并列时会跳过下一个名次DENSE_RANK 并列时不跳名次。比如成绩是 99、98、98、97ROW_NUMBER 的结果是 1、2、3、4RANK 的结果是 1、2、2、4DENSE_RANK 的结果是 1、2、2、3。实际面试中面试官经常会追问这三者的区别你必须能流畅地说出来。不过要注意窗口函数写起来简单不代表你可以完全不理解自连接的写法。有些老系统、低版本数据库不支持窗口函数而且面试时如果只给出窗口函数的秒解反而显得你对底层逻辑理解不够。最佳策略是两种都会面试时先讲窗口函数版本再补一句“如果环境不支持窗口函数也可以用自连接实现”瞬间加分。3. 从 50 题里精选五道我把完整推理过程写给你网上的题解大多是“答案 简单解释”这对提升帮助有限。我更想把推理过程完整还原一遍让你看到我在动手写 SQL 之前脑子里发生了什么。下面五道题是我认为最值得反复咀嚼的分别对应了不同的思维模型。3.1 平均成绩大于 60 分——HAVING 过滤组的经典题目查询平均成绩大于 60 分的学生的学号和平均成绩。我拿到这道题的第一步是确认数据粒度。SC 表里每个学生每门课一行所以“学生的平均成绩”必须按学号分组对 score 求平均。第二步是确认过滤时机。大于 60 是对“平均成绩”这个聚合结果做过滤不是对原始成绩做过滤所以必须放在 HAVING。最终SELECT Sid, AVG(score) AS avg_score FROM SC GROUP BY Sid HAVING AVG(score) 60;有些版本会把平均分保留一位小数SELECT Sid, ROUND(AVG(score), 1) AS avg_score FROM SC GROUP BY Sid HAVING ROUND(AVG(score), 1) 60;稍微延伸一下“查询平均成绩大于等于 85 的所有学生”这道题和上面的逻辑一模一样只要把 60 改成 85。但有一道变体题值得注意“查询每门课程的平均成绩并且平均成绩大于 80 分”这时候分组维度从学号变成了课程号同样用 HAVING但你要意识到题目要求的是每个课程一组。3.2 没学全所有课程的学生——双重否定怎么理解题目查询没有学全所有课程的学生学号和姓名。这道题我前面已经给过最稳的 EXISTS 写法但这里我想讲清楚为什么“双重否定”会导致初学的人懵。从字面上看“没学全”是一个否定表述把它转换成代码有两种路径路径一先求“学全了”的学生再从全体学生中排除。这个思路直观但“学全了”本身也是一个聚合判断得先按学号分组统计选课数再与总课程数比较SELECT Sid, Sname FROM Student WHERE Sid NOT IN ( SELECT Sid FROM SC GROUP BY Sid HAVING COUNT(DISTINCT Cid) (SELECT COUNT(*) FROM Course) );这段逻辑没问题但如果某个学生的 Sid 在子查询中因某种原因为 NULLNOT IN 就会出问题。路径二就是前面写的双重 NOT EXISTS它不依赖具体的选课数字而是用“反例不存在”来表达“包含关系”语义更稳。从我个人的刷题体会来看遇到“所有”“全部”这类字眼时用 EXISTS/NOT EXISTS 去反证比用 COUNT 比较更符合 SQL 的声明式思维。3.3 每门课程的前两名——自连接与窗口函数两种解法题目查询每门课程成绩最好的前两名学生。我先说窗口函数的简洁版SELECT Cid, Sid, score FROM ( SELECT Cid, Sid, score, ROW_NUMBER() OVER (PARTITION BY Cid ORDER BY score DESC) AS rk FROM SC ) t WHERE rk 2;而如果不用窗口函数经典的自连接写法是把 SC 表自己跟自己关联找“比当前记录分数更高的同课程记录”统计它的数量如果数量小于 2就说明当前记录排在前二SELECT a.Cid, a.Sid, a.score FROM SC a LEFT JOIN SC b ON a.Cid b.Cid AND b.score a.score GROUP BY a.Cid, a.Sid, a.score HAVING COUNT(b.Sid) 2 ORDER BY a.Cid, a.score DESC;这个自连接版本的思路是对每一行 a统计同一门课程里有多少行 b 的分数比 a 高。如果比自己高的记录少于两条那自己至少是第二名。分数相同时LEFT JOIN 和 COUNT 的配合会出现并列情况你需要结合题目要求决定用 COUNT(DISTINCT b.Sid) 还是直接 COUNT(b.Sid)这正好对应 RANK 和 ROW_NUMBER 的区别。两种写法能同时掌握笔试面试都不虚。3.4 查询和“01”号同学课程完全相同的其他同学——集合比较题目查询和 “01” 号同学学习的课程完全相同的其他同学。这是一道把关系代数里“集合相等”变成 SQL 的经典题。核心是两个条件其他同学选的课程数要和 01 号同学一样多。其他同学选的每门课01 号同学都选过。把这两个条件同时满足就能保证“完全相同”。SQL 写法SELECT Sid FROM SC WHERE Sid 01 GROUP BY Sid HAVING GROUP_CONCAT(Cid ORDER BY Cid) ( SELECT GROUP_CONCAT(Cid ORDER BY Cid) FROM SC WHERE Sid 01 );这个解法用 GROUP_CONCAT 把每个学生选的课程号拼成字符串再比较对有索引的表不一定最快但思路直观。还有一种更关系化的写法是“NOT EXISTS 双重否定”不存在一门课程01 号选了而其他同学没选同时课程数还要相等。两种都可以关键是你要理解“完全相等”用 SQL 表达时要么拼串比较要么对称做差集。3.5 每门课被选修的学生数——COUNT 的细节题目查询每门课程被选修的学生数。粗看是一个单表分组查询SELECT Cid, COUNT(Sid) FROM SC GROUP BY Cid;很多人交卷就结束了。但如果你想做得更完善应该把课程名列出来再展示每门课的选课人数SELECT Course.Cid, Course.Cname, COUNT(SC.Sid) AS stu_count FROM Course LEFT JOIN SC ON Course.Cid SC.Cid GROUP BY Course.Cid, Course.Cname;这里如果某门课没有学生选COUNT(SC.Sid) 会返回 0而不会丢掉这门课程的展示。这也是 LEFT JOIN 加 COUNT 的一个典型易错点你要确认 COUNT 的字段来自被驱动表否则即使没有匹配行COUNT(1) 也会把主表本身算进去。这道题不算难但它提醒我任何时候写 GROUP BY都要先想清楚分组的维度是不是题目要的粒度。4. 刷题过程中常见的坑我把报错和翻车现场也记录下来刷题不是一路顺畅的我至少踩过十几个坑。这里挑四个最有代表性的每个都能让你少浪费起码半小时。4.1 ONLY_FULL_GROUP_BYMySQL 5.7 之后最典型的报错MySQL 5.7 开始默认启用了sql_mode中的ONLY_FULL_GROUP_BY。这个模式的要求是SELECT 后面出现的非聚合列必须出现在 GROUP BY 中。比如SELECT Sname, AVG(score) FROM Student JOIN SC ON Student.Sid SC.Sid GROUP BY Student.Sid;在 5.6 里可以跑但在 5.7 可能直接报错因为 Sname 没有出现在 GROUP BY 中。虽然逻辑上 Sname 由 Sid 唯一确定但 MySQL 的优化器没有那么智能。解决办法有两个要么把 Sname 也加进 GROUP BY要么用 ANY_VALUE(Sname) 包一层。刷题的时候我建议你保持开启 ONLY_FULL_GROUP_BY因为这种约束能逼你写出更规范的 SQL。如果你不确定当前环境是否开启可以执行SELECT sql_mode;4.2 NOT IN 遇 NULL结果为空的原因与解法这是最经典的一个坑。题目“查询没学过张三老师课的同学”如果你用 NOT IN 且子查询结果集里含 NULL返回结果可能为空。原因很简单SQL 中NOT IN等价于 ALL而x NULL的结果是 UNKNOWN不是 TRUE所以整行被过滤掉了。最典型的情况是子查询里有 teacher 表和 course 表关联某些课程记录的 Tid 是 NULL导致子查询的结果里出现 NULLNOT IN 就全盘翻车。解决方法是要么在子查询里加WHERE Tid IS NOT NULL要么直接用 NOT EXISTS。这让我养成了一个习惯凡是需要排除集合的题目优先用 NOT EXISTS因为它只关心“是否存在”不参与 NULL 的等值判断。4.3 类型转换与字符集为什么查询结果对不上经典 50 题的数据量很小类型转换问题通常不会造成执行错误但会造成结果集和预期不一致。比如学生的 Sid 是 VARCHAR成绩表的 Sid 也是 VARCHAR但你在写关联条件时如果其中一个表的类型是 INTMySQL 会把字符串转成数字再比较这本身没问题问题在于“01”会被转成 1万一库里有“1”和“01”两种历史数据关联时就可能连出重复记录。字符集的问题也是老生常谈。如果你建表时没统一 utf8mb4插入中文姓名时可能出现乱码或长度超限。虽然 50 题的数据量小一般不会暴露字符集问题但在真实业务里表之间 JOIN 失败经常是因为两边的字符集和排序规则不一致。刷题时顺便把每张表的字符集统一成 utf8mb4能省很多后续麻烦。4.4 MySQL 版本差异5.7 和 8.0 的解法差距经典 50 题的老版本题解大多假设 MySQL 5.x不支持窗口函数和 CTE。而 MySQL 8.0 不仅支持窗口函数还支持公共表表达式WITH 语句这让很多题目的写法变得更加自然。比如“每门课成绩前三名”的题目在 8.0 中可以写WITH t AS ( SELECT Cid, Sid, score, ROW_NUMBER() OVER (PARTITION BY Cid ORDER BY score DESC) AS rk FROM SC ) SELECT Cid, Sid, score FROM t WHERE rk 3;如果面试环境明确是 5.7你写了窗口函数很可能直接报语法错误。所以我的建议是刷题前先确认你的 MySQL 版本如果本机是 8.0可以用窗口函数更高效地解一部分题但必须同时知道 5.7 的自连接或变量写法这样无论对方问什么你都有备选方案。工具本身不是越新越好关键是你得能适配环境。5. 从刷题到面试如何把“会做”升级成“能讲”很多人把经典 50 题刷了三遍笔试能写对但一到面试就被问倒。问题不在于他对 SQL 不熟而在于他只在“做题”没有在“讲题”。5.1 面试官想听的不是答案而是分析链路面试官问你“查询平均成绩大于 60 分的学生”他大概率不关心你能不能背出答案他想看你拿到问题后的第一反应是什么。一个加分的表达方式是这样的“这道题的数据粒度是学生和课程要求的是每个学生的平均分所以第一步是对 SC 表按学号分组平均分大于 60 是对分组结果的过滤所以用 HAVING不能放在 WHERE 里如果需要显示学生姓名再把 Student 表关联进来。”你注意看这个表述先讲数据粒度再讲分组维度再讲过滤时机最后讲关联。这条链路本身就是分析 SQL 题目的通用框架。面试官听完会觉得你不是在背答案而是真的理解查询逻辑。5.2 用 EXPLAIN 验证解法而不是凭感觉刷题练到一定阶段我建议你每写完一条 SQL都顺手执行一下 EXPLAIN看看执行计划里的 type 是 ALL、index 还是 ref有没有走索引有没有出现全表扫描。经典 50 题数据量小性能差异看不出来但 EXPLAIN 的意义在于让你建立“SQL 写法会改变执行路径”的直觉。比如同样是查一个学生的成绩子查询和 JOIN 的执行计划可能完全不同。面试时如果面试官问“你这条 SQL 性能怎么样”你能说出“我用 EXPLAIN 看过走了主键查询扫描行数很小”这就是一个很实在的加分点。5.3 一道经典 50 题的面试变体怎么应对面试官通常不会直接拿原题而是会改一个条件。比如原题是“查询没有学全所有课程的学生”他可能会改成“查询选了语文和数学两门课的学生”。这时候你脑子里要快速拆解核心是“同时满足两个选课条件”最稳妥的写法是按学生分组统计满足条件的课程数等于 2SELECT Sid FROM SC WHERE Cid IN (01, 02) GROUP BY Sid HAVING COUNT(DISTINCT Cid) 2;变体题考察的是你能否把原题里的思维模式迁移到新条件上。原题考的是“集合包含”变体题考的是“集合求交”两者思路相近但细节不同。我刷题时不追求记住每道题的答案而是把每道题归到一个模式框架里比如“否定类题目用 NOT EXISTS”“包含关系用双重否定”“排名问题用窗口函数或自连接”“同时满足多个条件用分组计数”。脑子里有了这些模式换什么条件都不怕。我个人刷完三遍之后最大的体会是经典 50 题不是用来“刷完”的而是用来“反复咀嚼”的。第一遍你可能重在把语法跑通第二遍开始关注不同写法的性能差异和语义差异第三遍你会发现自己能一眼看出每道题在考哪个能力点。真正让你提升的不是那些 SQL 答案而是每一次写错之后排查原因的过程以及对自己思维盲区的修正。这套题值得你每隔几个月回来重做一次每次都会有新的收获。
返回列表