吃透经典50道SQL练习题:从多表查询到执行计划优化的进阶指南
2026/9/18 0:19:03 网站建设 项目流程

做SQL练习这件事,我一直有个观点:与其漫无目的地刷一百道碎片题,不如踏踏实实把一套经典题吃透。经典50道SQL练习题就是这样一套值得反复练手的题库,它表面上是50道查询题,实际上把SQL开发中绝大多数核心场景都串了一遍——单表查询、多表连接、子查询、聚合统计、去重、空值处理、窗口函数、行列转换,甚至慢查询优化。无论你是准备面试的在校生,还是想查漏补缺的后端开发,这套题都可以当一面镜子,照清楚你对SQL的真实掌握程度。

我最早接触这套题是五六年前准备校招的时候,当时靠着硬背答案混过了面试,后来真正上了生产环境才发现自己只会背不会想。近几年我每年都会抽时间把这套题重做一遍,每次都能发现新的写法,也踩了不少坑。这篇文章不打算把50道题的答案逐条抄出来,而是想把这套题背后的设计逻辑、解题思路、易错点,以及我实际练习中的排查经验完整梳理一遍,希望能帮你在练题时少走弯路。

1. 内容整体设计与思路拆解

1.1 核心知识点图谱:一套题覆盖SQL面试的90%考点

经典50道SQL练习题之所以被称为“经典”,是因为它用极少的表结构覆盖了极为密集的知识点。以最常见的版本为例,它通常围绕四张表展开:学生表(Student)、课程表(Course)、成绩表(SC)、教师表(Teacher)。表结构简单,但题目设计却很讲究,从最简单的“查询所有学生信息”一路升级到“查询有两门以上不及格课程的学生”这类需要多表嵌套统计的问题。

我按实际考试频率把知识点分成了几个梯队,第一梯队是面试必考的基础能力:单表条件查询(WHERE)、排序(ORDER BY)、分组统计(GROUP BY + HAVING)、去重(DISTINCT)、聚合函数(COUNT、SUM、AVG、MAX、MIN)。第二梯队是拉开差距的核心能力:多表连接(INNER JOIN、LEFT JOIN)、子查询(标量子查询、派生表、EXISTS/NOT EXISTS)。第三梯队是近几年热度越来越高的进阶能力:窗口函数(ROW_NUMBER、RANK、LAG/LEAD)、行列转换(CASE WHEN + 聚合)、分页查询。这套题把这几个梯队全部包含在内,所以与其去找各种零散的练习题,不如先把它吃透。

1.2 为什么“学生-课程-成绩”四表是最好的练习场景

很多初学者会问,练习SQL为什么要用学生选课,而不是用一个更贴近业务的订单表或者用户表?我自己实际练下来发现,学生选课这个场景的设计非常巧妙。

首先,它天然包含了一对多关系(一个学生有多条成绩记录)、多对多关系(学生和课程通过成绩表关联),这种关系模型最能锻炼JOIN的思维。其次,成绩表SC同时携带了SId(学生ID)和CId(课程ID)两个外键,是典型的事实表,业务里的订单明细、流水记录和它结构上完全同构。第三,这个场景的“中文描述”容易让人产生理解偏差,比如“查询每门课程的平均成绩,结果按平均成绩降序排列”和“查询平均成绩大于60分的学生”看似差不多,写法却完全不同,这种“中文理解转SQL表达”的能力恰好是实际工作中最常用的能力。

1.3 环境选择:MySQL和SQL Server的方言差异先搞清楚

从相关搜索词里能看到,很多人在用SQL Server环境练这套题,也有人用MySQL,这就有个必须提前说明的问题:经典50道的标准答案大多基于SQL Server 2008左右的语法,能跑通不代表在MySQL里也能直接跑通,两个数据库至少有四处明显的方言差异。

排序上,MySQL用LIMIT做分页,SQL Server用OFFSET/FETCH,老版本甚至没有OFFSET只能用TOP。字符串处理方面,MySQL的IFNULL对应SQL Server的ISNULL,字符串拼接的写法也不一样。日期函数差异更大,SQL Server的GETDATE、DATEADD在MySQL里对应NOW、DATE_ADD。窗口函数方面,MySQL 8.0之后才支持窗口函数,如果你用的是5.7,练题时不要选带ROW_NUMBER的解法。我建议初学者优先选MySQL 8.0或SQL Server 2019+,因为这两个版本窗口函数都支持得比较完整,一套练习下来不需要为了方言兼容绕路。

2. 核心细节解析与实操要点

2.1 建表和造数据:把地基打好,后面才能调得动

无论你选什么数据库,第一步都是把四张表和测试数据建出来。我贴一份我常用的MySQL建表脚本,结构上尽量贴合经典题目的原始设定。注意字符集我统一用了utf8mb4,避免中文姓名出现乱码。

CREATE DATABASE IF NOT EXISTS sql_practice DEFAULT CHARSET utf8mb4; USE sql_practice; CREATE TABLE Student ( SId VARCHAR(10) PRIMARY KEY, Sname VARCHAR(50) NOT NULL, Sage DATETIME, Ssex VARCHAR(10) ); CREATE TABLE Teacher ( TId VARCHAR(10) PRIMARY KEY, Tname VARCHAR(50) NOT NULL ); CREATE TABLE Course ( CId VARCHAR(10) PRIMARY KEY, Cname VARCHAR(50) NOT NULL, TId VARCHAR(10), FOREIGN KEY (TId) REFERENCES Teacher(TId) ); CREATE TABLE SC ( SId VARCHAR(10), CId VARCHAR(10), score DECIMAL(5,1), PRIMARY KEY (SId, CId), FOREIGN KEY (SId) REFERENCES Student(SId), FOREIGN KEY (CId) REFERENCES Course(CId) );

插入数据时有个细节:Sage字段我用了DATETIME类型,这样可以顺便练习日期函数相关的题目。造数据时建议稍微加一些“噪音”,比如某个学生没有成绩记录、某门课程没有学生选修、成绩字段出现NULL,这些边界情况恰恰是后面很多易错题的题眼。我在实践中发现,很多人练这套题练到后面ABS卡壳,不是不会写SQL,而是数据造得太“干净”,没有触发边界条件。

2.2 聚合统计题:COUNT、SUM、AVG里的隐性坑

50道题里带统计的题目占了将近三分之一,这也是最容易失分的地方。先说COUNT的两种用法的区别。COUNT()统计行数,包括NULL行;COUNT(列名)统计该列非NULL值的数量,NULL会被忽略。在关联查询里,如果你用COUNT(SC.SId)想统计报名人数,一旦某个学生有NULL成绩,这个计数就可能和预期不一致。这里推荐一个经验法则:做“有几门课”“有多少人”这类统计时,优先COUNT(主键)或COUNT(DISTINCT 列),不要无脑COUNT()。

AVG同样有坑。AVG(score)只对非NULL成绩求均值,如果题目要求“所有学生的平均分,没有成绩的按0分算”,直接用AVG就是错的,要先用IFNULL或者CASE把NULL转成0。我见过很多开发在线上报表里踩这个坑,最后查出来的均值和手工算的对不上,问题就出在NULL上。SUM和NULL相加的结果还是NULL,这在计算总分时需要特别留意。

2.3 连接查询:INNER JOIN、LEFT JOIN和NOT IN怎么选

多表连接是这套题的核心重头戏。我从练习中总结了一个判断标准:题目要求“必须有对应关系”的记录时用INNER JOIN,要求“以某张表为主,附属表可有可无”时用LEFT JOIN,要求“找出某张表中不存在关联记录”的数据时,可以用LEFT JOIN + IS NULL,也可以用NOT IN/ NOT EXISTS。

举例说,“查询没有选修任何课程的学生”这道题,很多人第一反应是NOT IN。这题单独用没毛病,但如果子查询的结果集里包含NULL,NOT IN会整体失效,因为SQL里NOT IN遇到NULL会返回UNKNOWN,整条查询查不到任何数据。这是我实际踩过的坑:成绩表里如果存在SId为NULL的脏数据,NOT IN版本直接返回空结果,而LEFT JOIN + IS NULL的写法不受影响。现在我的默认习惯是:找“不存在”的记录优先用LEFT JOIN + IS NULL,或者NOT EXISTS,这两者都比NOT IN更稳。

3. 实操过程与核心环节实现

3.1 三轮练习法:别急着看答案,先逼自己手写

对于这套题,我不推荐直接照答案抄一遍就完事,建议按三轮来练。

第一轮:不查资料,不百度,不看答案,每道题先手写一遍。写不出来或者写错都没关系,关键是让自己“卡住”,带着问题去比对答案,记忆会深很多。第二轮:把每道题尽量写出两种以上解法。比如“查询每门课程成绩最高的学生”,既可以先用GROUP BY拿到最高分再JOIN成绩表,也可以用窗口函数ROW_NUMBER直接排秩。两种解法都写一遍,你才算真正理解了这个场景的数据流向。第三轮才是对答案,并且重点去看参考答案里哪些写法比你的简洁、为什么简洁。

我个人的体会是,第二轮是最痛苦的也是提升最快的。刷题不是目的,把“一个场景能对应多种SQL表达”练成肌肉记忆,面试时碰到没见过的题才不会慌。

3.2 三类高频题的解法拆解:从思路到代码

这套题里有一批“高频中的高频”,我挑三类典型题把解题思路完整拆开来。

第一类:课程成绩对比。最常见的问法是“查询两门课成绩都大于某分数的学生”,或者是“查询01课程比02课程成绩高的所有学生”。核心套路是把成绩表SC按课程拆成两个副本,然后通过学生ID连接。拆表之后的连接条件一定是学生ID相等,再在ON或WHERE里写课程成绩比较。这里有坑:如果用WHERE做筛选,连接条件判断完再过滤,会影响保留的行数;如果只想保留符合条件的学生,放在WHERE没问题,但如果后面还要展示其他信息,放ON里会更安全。

第二类:平均分和排名。比如“查询平均成绩大于60分的学生”。先GROUP BY学生ID,再用HAVING AVG(score) > 60。很多新手会写WHERE AVG(score) > 60,这是语法错误,因为WHERE在分组前执行,不能引用聚合函数。HAVING就是为分组后过滤而生的,这个关系如果能彻底搞清楚,后面做复杂报表会顺很多。

第三类:行列转换。比如把成绩表变成“一行一个学生,每门课的成绩作为一列”。解法是用CASE WHEN或IF把每个课程变成一列,配合MAX(或SUM)做聚合。如果你正在用MySQL 8.0,也可以试一下用存储过程拼动态SQL去完成字段不确定的行列转换,不过那属于进阶玩法,基础阶段先把CASE WHEN版本吃透。

3.3 同一个需求,两种写法,性能差在哪里

练题不能只满足于“跑通”,还要有性能意识。我拿一个常见场景举例:查询“没学过张三老师课的学生”。这个需求有两种主流解法,一种用NOT IN子查询,一种用LEFT JOIN + IS NULL。在小数据集上两者结果相同,但用EXPLAIN一看执行计划,差距就出来了。

-- 写法A:NOT IN SELECT * FROM Student WHERE SId NOT IN ( SELECT SId FROM SC WHERE CId IN ( SELECT CId FROM Course WHERE TId = ( SELECT TId FROM Teacher WHERE Tname = '张三' ) ) ); -- 写法B:LEFT JOIN SELECT s.* FROM Student s LEFT JOIN SC sc ON s.SId = sc.SId LEFT JOIN Course c ON sc.CId = c.CId LEFT JOIN Teacher t ON c.TId = t.TId AND t.Tname = '张三' WHERE t.TId IS NULL;

在数据量达到几十万行时,嵌套子查询优化不好会导致驱动表被反复扫描,而JOIN版本如果索引合理,整体执行时间可能少一个数量级。这也是为什么我在前面强调要多写几种解法,因为解法之间不只是写法差异,背后是优化器对不同执行路径的选择。练题阶段养成看执行计划的习惯,对以后处理慢SQL优化非常有帮助。

3.4 从50题到面试:把练习变成本能

练完这50道题,你可以做一个小测试来验证自己是否真的掌握了:不看任何资料,尝试在一小时内完成其中20道随机题,并同时写出其中5道题的两种解法。如果能做到,说明你的SQL基础已经过关了。

面试中很多看似复杂的问题,本质都是这套题的变形。比如“找出连续3天登录的用户”可以拆成自连接或窗口函数解法,而窗口函数解法用到的LAG/LEAD在经典题里就有类似的排序场景。我建议练完之后,主动把题目里的学生课程场景映射到业务表上,比如把“成绩表”想象成“订单表”,把“平均分”想象成“客单价”,这样你带走的不只是50个答案,而是一套解决“查询统计类需求”的通用思路。

4. 常见问题与排查技巧实录

4.1 空值问题:漏统计、查不到、算不出的元凶

空值相关的坑,我练题时踩得最多,这里集中列出来。第一,COUNT(列)和COUNT(*)结果不一致,原因是有NULL。第二,AVG和SUM遇到NULL时不参与计算或直接返回NULL,需要IFNULL先处理。第三,NOT IN的子查询结果里只要有一个NULL,结果集就是空的,需要改成NOT EXISTS或LEFT JOIN IS NULL。第四,字符串字段和NULL比较不会命中,用“= NULL”查不到任何数据,必须用IS NULL。

我排查空值问题有个固定流程:先把问题字段单独SELECT出来,看一眼NULL分布,再回查聚合或连接的条件。90%的统计类“Bug”排查到最后,都是空值在捣乱,不是SQL语法错了。

4.2 去重和分组:DISTINCT到底该放在哪

去重看起来简单,实际上很容易放错位置。DISTINCT放在SELECT后面,表示对整行去重,也就是多个列都相同才算重复,这个和“只要SId相同就去重”是两回事。如果你脑子里想的是“按SId去重”,SQL里应写GROUP BY SId,而不是SELECT DISTINCT。

经典题里“查询所有课程成绩都大于等于60分的学生”,如果直接用DISTINCT SId,无法反映“所有课程”这个条件,正确的做法是先GROUP BY SId,再用HAVING MIN(score) >= 60或者用COUNT比较总课程数和及格课程数。记住一个原则:只要题目里出现“每个”“按某字段”这类词,优先考虑GROUP BY;只有“结果里不要重复行”时才用DISTINCT。

4.3 行列转换:CASE WHEN写好了,反过来也要会

行列转换是很多人的老大难。从行转列来看,核心是确定“哪些字段变成列”和“哪列作为分组依据”。以“查询每门课程的成绩,按学生一行展示”为例,CId的值变成了列名,SId是分组依据,score是填充值。写法是围绕CASE WHEN和聚合函数,但这里有个容易忽略的细节:CASE WHEN返回成绩后,必须要套MAX或SUM聚合,因为GROUP BY要求学生每人只能出一行,而CASE WHEN的结果只是一堆散值,聚合函数负责从中挑出非NULL的那个。

反过来列转行也不少见,可以用UNION ALL把多列拆成多行。面试中这种场景经常出现,练题时如果只是被动看答案,很难在短时间内想通“为什么要有聚合函数”。我的建议是,用手动算一遍的方式模拟:把毕业生成绩表先写出来,再逐行套CASE WHEN看数据怎么变,比空想高效得多。

4.4 执行计划和索引:练题时就要养成的排查习惯

练这套题,不需要等到线上出了问题才学性能优化。你可以在每道题跑完后,用EXPLAIN命令看执行计划,重点观察有没有出现Using temporary(临时表)、Using filesort(文件排序)和全表扫描。如果发现某道题的多表关联在十几万行数据下走全表扫描,可以尝试给外键建索引再对比。

EXPLAIN SELECT s.Sname, c.Cname, sc.score FROM SC sc JOIN Student s ON sc.SId = s.SId JOIN Course c ON sc.CId = c.CId WHERE sc.score > 80;

我实际练习中一个很大的体会是:经典50道题在测试数据量下,所有写法几乎都是瞬间返回,性能和正确性看不出来区别,所以很多人忽略执行计划。但一旦你把数据量放大到几十万、上百万,索引带来的差距就非常明显。建议练题时顺手给SC表的SId、CId、score分别建索引,再看执行计划,你会发现数据库优化器的选择是完全不同的。

4.5 延伸提醒:练习环境务必干净,线上SQL安全不能忘

最后说一个和安全相关的延伸提醒。这套题有些版本会把子查询、UNION、动态SQL等技巧揉在一起,确实能玩出很多花样。但无论怎么练,都要牢记SQL注入的风险时刻存在。练习时我们是在自己搭建的本地数据库折腾,不会有问题;但一旦进入生产系统,任何用户输入拼进SQL字符串的行为都应该被禁止,优先级最高的做法是使用参数化查询或预编译语句,而不是手动拼接字符串。学这套题的同时,把“输入即不可信”这条职业底线一起记住,才算真正练到位了。

写在最后的小建议

如果你正准备开始练这套题,我建议你先自己建好环境和表,再从第一题按顺序往下写,不要跳题。每一道题都当作一次小型的“需求评审”来对待:先读懂中文描述,心里默念一句“它到底要按什么字段分组,需要保留哪些行”,再动手写SQL。遇到卡壳不要马上看答案,多想十分钟,卡住的那个点就是你目前最薄弱的知识带。

刷完一遍只是入场券,过一两周再回来重刷第二遍,你会发现原来的很多写法可以改得更简洁,甚至能把多个题目合并成同一个数据集的多种解法。经典50道题能流传这么久,价值不在于题目本身,而在于它逼你养成了把中文需求准确翻译成SQL逻辑的习惯。这个习惯,等你真正上生产线后,会是最值钱的能力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询