上周有个刚转行的朋友问我:“我把网上的SQL题刷了两百多道,可一碰到需求稍微变一下的查询还是卡壳,问题出在哪?”我看了看他刷的题,答案其实很简单——他刷的是“题”,不是“方法”。
这让我想起自己做MySQL教学和面试辅导时一直在用的那套“SQL 100道基础练习题”。它不追求偏题怪题,而是把SQL日常开发里最常见的场景拆成一个个可递进的练习:从单表过滤开始,一步步走到聚合分组、多表连接、子查询、窗口函数,最后落到DML、索引和事务。每一道题背后都有一个明确的考察点,刷完不是让你“见过”,而是让你以后拿到任何需求都能在脑子里先画出一条SQL的骨架。
这篇文章把完整题目分类、表结构设计、部分典型题的精讲答案,以及我自己在批改练习时看到的高频错误全部整理出来。适合三类人:刚学完SQL语法但不知道怎么实战的初学者;准备数据分析师或后端开发面试、想在短时间内系统过一遍SQL重点的求职者;以及基础不牢、想回头补课的在职开发者。
1. 这套题不是让你背答案的:题目的设计逻辑
市面上的SQL题很多,但大多存在两个问题:一是题目之间没有递进关系,东一榔头西一棒槌;二是过度堆砌复杂场景,初学者连表结构都看不懂就直接被劝退。我最初整理这100道题时,设定的原则只有三条:覆盖日常工作高频语法、难度循序渐进、每道题都必须能讲清楚“为什么这样写”。
1.1 为什么要从三张表开始
很多教程喜欢用几十张表的大业务模型来出题,但我个人强烈不建议新手这么做。SQL的核心思维是“对集合进行操作”,这个思维在简单的表结构上更容易建立起来。这套题只用了三张表:学生表、课程表、成绩表,三者之间有外键关联,既有单表操作,又能做多表连接,还能支持子查询和窗口函数的大部分练习场景。等你把这三张表玩透了,换任何复杂的业务表结构,本质上都是同样的套路。
另一个现实原因是,我在面试候选人和帮同事review代码时发现,不少人写了大半年业务SQL,看到“LEFT JOIN之后数据变多”“GROUP BY之后查不到非分组字段”这样的问题还是懵的。这些基础问题的根源,恰恰是对单表过滤和连接逻辑的理解不够扎实。100道题里我特意把连接和子查询的部分占比拉高,因为这两块才是实战中真正拉开差距的地方。
1.2 100道题怎么分配
整套题我分成了六个模块,每个模块有明确的训练目标,不是平均用力:
| 模块 | 题目数量 | 训练目标 |
|---|---|---|
| 基础查询与过滤 | 10题 | 掌握SELECT核心子句、WHERE条件、LIKE/IN/BETWEEN/NULL判断 |
| 聚合、分组与排序 | 20题 | 理解分组聚合逻辑,能区分WHERE与HAVING |
| 多表连接 | 25题 | 掌握INNER JOIN/LEFT JOIN用法、自连接与连接条件 |
| 子查询与派生表 | 20题 | 学会把复杂查询拆解成独立子问题 |
| 窗口函数 | 15题 | 掌握排名、累计、分组内计算等进阶语法 |
| DML、视图、索引与事务 | 10题 | 不只会查,还要能安全地增删改和建索引 |
后面每个模块我都会有题目清单和精讲。你可以按顺序刷,也可以根据面试目标跳着刷,但我不建议打乱模块内部的顺序,因为每道题考察的语法点都是在前面的基础上叠加的。
2. 动手前先把三张表和测试数据建好
练SQL的前提是你手上有一套干净的、自己完全掌控的数据。我见过有人直接用生产库练习,结果一条UPDATE把线上数据改了,这个风险无论如何都要避免。我建议你在自己的电脑上装一个MySQL 8.0,用下面的脚本建库建表。
2.1 环境准备
装MySQL很简单,直接到官网下载MySQL Community Server,安装时记住root密码。如果你追求效率,也可以用Docker:
docker run --name mysql100 -e MYSQL_ROOT_PASSWORD=yourpassword -p 3306:3306 -d mysql:8.0客户端方面,命令行效率太低,我平常用Navicat或MySQL Workbench。Workbench免费但有时候界面卡顿,Navicat需要破解,经济条件允许的话建议支持正版。其实有一个更轻量的选择:VS Code装一个MySQL插件,日常练习完全够用,重点是能用快捷键执行选中SQL,比在终端一行行敲舒服得多。
2.2 建表语句
这100道题全部围绕学生选课成绩这个场景展开,三张表的结构如下:
CREATE DATABASE IF NOT EXISTS sql100 DEFAULT CHARSET utf8mb4; USE sql100; DROP TABLE IF EXISTS scores; DROP TABLE IF EXISTS students; DROP TABLE IF EXISTS courses; CREATE TABLE students ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '学生ID', name VARCHAR(50) NOT NULL COMMENT '姓名', gender CHAR(1) DEFAULT 'M' COMMENT '性别 M/F', class VARCHAR(20) COMMENT '班级', age INT COMMENT '年龄', enroll_date DATE COMMENT '入学日期' ) ENGINE=InnoDB COMMENT='学生表'; CREATE TABLE courses ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '课程ID', course_name VARCHAR(100) NOT NULL COMMENT '课程名称', teacher VARCHAR(50) COMMENT '授课老师', credit DECIMAL(3,1) COMMENT '学分' ) ENGINE=InnoDB COMMENT='课程表'; CREATE TABLE scores ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, score DECIMAL(5,2) COMMENT '成绩', exam_date DATE COMMENT '考试日期', CONSTRAINT fk_scores_student FOREIGN KEY (student_id) REFERENCES students(id), CONSTRAINT fk_scores_course FOREIGN KEY (course_id) REFERENCES courses(id) ) ENGINE=InnoDB COMMENT='成绩表';这里有几个细节想提醒你。第一,所有字符集统一用utf8mb4,避免中文乱码;第二,成绩字段用DECIMAL(5,2)而不是FLOAT,因为浮点数在比较时会有精度问题,这在后面做范围查询时会让结果变得莫名其妙;第三,外键约束在建表时就加上,练习子查询时你才能理解为什么有些“无关联”的数据会被LEFT JOIN带出来。
2.3 插入测试数据的几个小技巧
建完表要插数据。为了让练习效果更好,我建议你至少插入20名学生、6门课程、60条成绩记录,并且要刻意制造一些“陷阱数据”:比如某个学生没有任何选课记录、某门课程没有学生选、某些成绩为NULL、两个学生同姓名等。这些脏数据,后面很多题目就是专门针对它们出的。
INSERT INTO courses (course_name, teacher, credit) VALUES ('语文', '王老师', 3.0), ('数学', '李老师', 4.0), ('英语', '张老师', 2.0), ('物理', '李老师', 4.0), ('化学', '赵老师', 3.0), ('生物', '孙老师', 2.5); INSERT INTO students (name, gender, class, age, enroll_date) VALUES ('张伟', 'M', '一班', 20, '2022-09-01'), ('王芳', 'F', '一班', 19, '2022-09-01'), ('李娜', 'F', '二班', 21, '2021-09-01'), ('刘洋', 'M', '二班', 20, '2021-09-01'), ('陈静', 'F', '三班', 22, '2020-09-01'), ('杨磊', 'M', '三班', 21, '2020-09-01'), ('赵敏', 'F', '一班', 20, '2022-09-01'), ('孙浩', 'M', '二班', 22, '2021-09-01');我特意让“张伟”和“赵敏”没有出现在scores表里,后面题目会用到。成绩记录你可以自己造,原则是保证每个学生至少选了1门课、最多选5门课,并且把班级区分开,方便做分组聚合练习。
3. 模块一:基础查询与过滤,先把SELECT的手感练出来
很多人觉得SELECT太简单,但面试里最容易失分的恰恰是条件边界。什么叫“年龄大于20岁”?如果age字段允许为NULL,那么age > 20这个条件会把NULL的记录过滤掉,而很多人根本没意识到NULL的存在。这批基础题就是为了把这些细节磨透。
| 题号 | 题目 | 考察点 |
|---|---|---|
| 1 | 查询学生表中所有学生的全部信息 | SELECT基本语法 |
| 2 | 查询所有学生的姓名和班级 | 投影列 |
| 3 | 查询年龄大于20岁的学生姓名和年龄 | WHERE数值比较 |
| 4 | 查询一班且性别为F的学生 | AND多条件 |
| 5 | 查询姓名以“张”开头的学生 | LIKE模糊匹配 |
| 6 | 查询年龄在18到22岁之间的学生 | BETWEEN边界 |
| 7 | 查询班级为一班或二班的学生 | IN列表 |
| 8 | 查询没有填写年龄的学生 | IS NULL |
| 9 | 查询学生表一共有多少条记录 | COUNT(*) |
| 10 | 查询一共有哪些班级 | DISTINCT去重 |
精讲两道比较容易出错的。
第5题:查询姓名以“张”开头的学生。
SELECT * FROM students WHERE name LIKE '张%';考察点是LIKE通配符。%匹配任意多个字符,_匹配单个字符。实际业务中经常遇到用户输入一个姓来搜索,这里的索引利用率其实是很多人忽略的:如果name字段有前缀索引或普通索引,'张%'这种写法是可以走索引的,但'%张'不行。你写SQL时心里要有个弦,%放在最前面的模糊查询,数据量一上来就容易慢。
第8题:查询没有填写年龄的学生。
SELECT * FROM students WHERE age IS NULL;这个必须用IS NULL,不能用= NULL。在SQL里NULL不是一个值,而是“未知”的标记,任何与NULL做比较的结果都是NULL,也就是说age = NULL永远不会为TRUE。我在面试中问过很多候选人,至少有三分之一会写成age = NULL或age = '',这两个都拿不到正确结果。如果真的想查空字符串,才用age = '',但NULL与空字符串是两个完全不同的概念,造数据时也要注意区分。
第2题的变形——别名:
SELECT name AS 姓名, class AS 班级 FROM students;AS可以省略,但建议保留,可读性更好。注意中文别名在命令行客户端不需要加引号,在部分框架里可能报错,保险起见可以写成'姓名'。
4. 模块二:聚合分组与排序,别一上来就写窗口函数
进入第11题到第30题,这是整套练习题的第一次卡人点。很多初学者能在SELECT和WHERE里游刃有余,一遇到GROUP BY就蒙。核心原因是没有建立“分组后每一行代表一个组”这个认知。聚合函数的作用对象不是某一条记录,而是一个分组,分组里可能包含多条记录,但最终只输出一行。
| 题号 | 题目 | 考察点 |
|---|---|---|
| 11 | 查询每个班级有多少名学生 | GROUP BY + COUNT |
| 12 | 查询每个班级学生的平均年龄 | GROUP BY + AVG |
| 13 | 查询男女生各有多少人 | 分组多字段 |
| 14 | 查询每门课程的平均分、最高分、最低分 | 多个聚合函数 |
| 15 | 查询每个学生选了多少门课 | COUNT + GROUP BY |
| 16 | 查询平均分大于80分的课程 | HAVING |
| 17 | 查询选课人数超过5人的课程ID | HAVING + COUNT |
| 18 | 查询每个班级中年龄最大的学生年龄 | MAX + GROUP BY |
| 19 | 查询学生总数按班级降序排列 | ORDER BY + 别名 |
| 20 | 查询每门课程的平均分,并按照平均分从高到低排序 | 聚合 + 排序 |
| 21 | 查询成绩总分最高的学生ID | SUM + GROUP BY |
| 22 | 查询年龄在20岁以上的学生各班人数 | WHERE + GROUP BY |
| 23 | 查询每个班级男女生各多少人 | GROUP BY多列 |
| 24 | 查询班级人数不足3人的班级 | HAVING |
| 25 | 查询每门课的选课人数,没有学生选的课也要显示 | 稍后用JOIN解决 |
| 26 | 查询各班级学生的年龄总和 | SUM |
| 27 | 查询最小年龄、最大年龄和平均年龄 | 聚合无分组 |
| 28 | 查询姓名重复的学生及重复次数 | GROUP BY + HAVING |
| 29 | 查询每个班级最早入学日期 | MIN |
| 30 | 按年龄排序,年龄相同按入学日期排序 | 多字段排序 |
第16题:查询平均分大于80分的课程。
SELECT course_id, AVG(score) AS avg_score FROM scores GROUP BY course_id HAVING AVG(score) > 80;这道题十个人有八个人会写错,错误版本是:
-- 错误示例 SELECT course_id, AVG(score) FROM scores WHERE AVG(score) > 80 GROUP BY course_id;错在WHERE不能使用聚合函数。WHERE是在分组之前对原始行进行过滤的,而聚合函数的结果在分组之后才产生。HAVING的作用就是过滤分组。一句话总结:WHERE过滤行,HAVING过滤组。另外,HAVING中尽量直接写聚合函数,不要依赖SELECT中的别名,有些数据库不支持在HAVING里引用别名。
第28题:查询姓名重复的学生及重复次数。
SELECT name, COUNT(*) AS cnt FROM students GROUP BY name HAVING COUNT(*) > 1;这是面试常考的“查重复记录”原型题,它能变形出很多类型:查手机号重复、查订单号重复、查同一用户同一天的重复登录等。核心就是GROUP BY后COUNT大于1。
5. 模块三:多表连接,SQL面试最常见的分水岭
从第31题到第55题是整套题里含金量最高的部分。多表连接不是背语法,而是要理解“连接到底在干什么”。我教新人的时候常用一个比喻:JOIN就是把两张表按某种匹配规则横向拼接起来,匹配上的行合在一起成为新表的一行;匹配不上的行,如果是LEFT JOIN,左边表的行会保留,右边表的字段填NULL。
| 题号 | 题目 | 考察点 |
|---|---|---|
| 31 | 查询学生姓名和其选课的课程名称 | INNER JOIN三表 |
| 32 | 查询所有学生的选课情况,没选课的学生也要显示 | LEFT JOIN |
| 33 | 查询所有课程的学生选课情况,没被选的课程也要显示 | RIGHT JOIN或LEFT JOIN换表 |
| 34 | 查询没有选任何课程的学生 | LEFT JOIN + IS NULL |
| 35 | 查询没有被任何学生选择的课程 | LEFT JOIN + IS NULL |
| 36 | 查询每个学生的选课数量 | JOIN + GROUP BY |
| 37 | 查询每门课程的选课学生人数 | 同上 |
| 38 | 查询选修了“语文”课的学生的姓名 | JOIN + WHERE |
| 39 | 查询“李老师”教的所有课程以及选课人数 | JOIN + 聚合 |
| 40 | 查询每个班级中每门课程的平均分 | JOIN + GROUP BY多列 |
| 41 | 查询学生姓名、课程名、成绩,并按成绩降序排列 | JOIN + ORDER BY |
| 42 | 查询成绩大于90分的学生姓名和课程名 | JOIN + WHERE |
| 43 | 查询每位同学的平均分,并显示姓名 | JOIN + GROUP BY |
| 44 | 查询同班同名的学生 | 自连接 |
| 45 | 查询年龄比同班平均年龄小的学生 | 衍生表+自连接 |
| 46 | 查询至少选了两门课的学生姓名 | JOIN + HAVING |
| 47 | 查询每个学生各科成绩都大于60分的记录 | 聚合+比较 |
| 48 | 查询所有学生中与“王芳”同班的学生 | 自连接 |
| 49 | 查询每门课最高分对应的学生姓名 | 连接 + 子查询 |
| 50 | 查询每个学生总分的排名 | 先SUM后JOIN再排序 |
| 51 | 查询没学过“李老师”课程的学生 | NOT IN + JOIN |
| 52 | 查询选课门数超过3门的学生姓名 | HAVING |
| 53 | 查询各科平均分都在80分以上的学生ID | HAVING + MIN |
| 54 | 查询总成绩排名前3的学生姓名 | 派生表 |
| 55 | 查询所有课程成绩都大于等于90分的学生 | NOT EXISTS |
第34题:查询没有选任何课程的学生。
SELECT s.id, s.name FROM students s LEFT JOIN scores sc ON s.id = sc.student_id WHERE sc.id IS NULL;这道题是“LEFT JOIN + 右表IS NULL”的经典用法,比用NOT IN子查询在数据量大时性能更稳定。需要注意的是ON后面的连接条件到底该带什么、WHERE中过滤右表字段时会不会把左表记录也滤掉。如果你写了WHERE sc.student_id IS NULL,结果一样;但如果你不小心在WHERE里加了一个AND sc.score > 0,那NULL记录全部被过滤掉,结果就变成0行了。这种错误我见过不止一次。
第44题:查询同班同名的学生。
SELECT a.name, a.class FROM students a JOIN students b ON a.name = b.name AND a.class = b.class AND a.id <> b.id;自连接的要点是给同一张表起两个不同的别名,关键条件要写a.id <> b.id,否则每对重复姓名会和自己匹配一次。同理,查询“比同班同学年龄大”就是ON a.class = b.class AND a.age > b.age,这时候没有id <>条件也会自动排除自己,因为自己的年龄不可能大于自己。
**第49题:查询每门课最高分对应的学生姓名。**这道题很多人用GROUP BY + MAX先查出最高分,再JOIN成绩表。但要注意,如果同一门课有两个学生同分且都是最高分,分组+JOIN会得到两行,这其实是正确的。如果题目改成“每门课最高分的学生”,语义上可以允许多个最高分。想取其中一个,就需要窗口函数或ORDER BY + LIMIT,这是后话。
6. 模块四:子查询与派生表,把复杂问题拆碎
第56题到第75题是子查询专项。子查询的核心价值在于把“一步到位”写不出来的查询,拆成“先查一个结果集,再基于结果集查询”。写子查询不丢人,可读性永远比“用极其复杂的方法强行避免子查询”更重要。真正需要优化的时候,再考虑改成JOIN或EXISTS。
| 题号 | 题目 | 考察点 |
|---|---|---|
| 56 | 查询与“张伟”同班级的学生 | IN子查询 |
| 57 | 查询年龄大于全校平均年龄的学生 | 标量子查询 |
| 58 | 查询选了“语文”课的学生姓名 | IN子查询 |
| 59 | 查询没有选“数学”课的学生姓名 | NOT IN |
| 60 | 查询成绩表中存在成绩记录的学生ID | EXISTS |
| 61 | 查询不存在成绩记录的学生姓名 | NOT EXISTS |
| 62 | 查询每门课成绩最高的学生和成绩 | 相关子查询 |
| 63 | 查询比所在班级平均年龄小的学生 | 相关子查询 |
| 64 | 查询选修课程数超过平均选课门数的学生 | 子查询+HAVING |
| 65 | 查询平均分最高的学生姓名 | 派生表 |
| 66 | 查询每个班级年龄最小的学生 | 相关子查询 |
| 67 | 查询总分超过班级平均总分的学生 | 两次聚合的嵌套 |
| 68 | 查询每门课的选课人数并显示课程名 | 派生表JOIN |
| 69 | 查询所有学生中年龄最大的学生信息 | 标量嵌套 |
| 70 | 查询成绩表中比某门课程平均分高的记录 | 多个子查询 |
| 71 | 查询每个学生分数最高的课程 | 相关子查询 |
| 72 | 查询成绩排名第二的学生的姓名和成绩 | ORDER BY + LIMIT |
| 73 | 查询选了所有课程的学生 | 双重NOT EXISTS |
| 74 | 查询学生姓名及各科成绩中最高分 | 关联子查询 |
| 75 | 查询同一年级中比同班任一同学年龄都大的学生 | ALL子查询 |
第57题:查询年龄大于全校平均年龄的学生。
SELECT name, age FROM students WHERE age > (SELECT AVG(age) FROM students);这是一个标量子查询,返回结果是单个值。注意AVG(age)如果students表是空表会返回NULL,那么WHERE条件就恒为NULL,查不出任何行。实际开发中,统计报表脚本一定要处理这种空表场景,否则结果会让你排查很久。
第65题:查询平均分最高的学生姓名。
SELECT s.name, t.avg_score FROM students s JOIN ( SELECT student_id, AVG(score) AS avg_score FROM scores GROUP BY student_id ORDER BY avg_score DESC LIMIT 1 ) t ON s.id = t.student_id;派生表就是FROM后面的子查询,它必须有一个别名。MySQL在8.0之前对派生表的优化很差,8.0之后有自动物化和合并优化,性能改善明显。如果你还在用5.7,遇到派生表慢的情况,可以先STRAIGHT_JOIN或者改成临时表。
第73题:查询选了所有课程的学生。
SELECT s.id, s.name FROM students s WHERE NOT EXISTS ( SELECT c.id FROM courses c WHERE NOT EXISTS ( SELECT 1 FROM scores sc WHERE sc.student_id = s.id AND sc.course_id = c.id ) );这题是双重否定:选出那些“不存在任何一门课他没有选”的学生。很多初学者看到答案直接懵了,但你只要掌握一个方法论:把SQL从外层往内层读,每个NOT EXISTS都翻译成“不存在……的记录”,语境就清晰了。这也是数据库面试里出镜率极高的一道题,值得反复研究。
7. 模块五:窗口函数与进阶语法,面试加分项
从第76题到第90题是窗口函数专项。MySQL 8.0之后开始支持窗口函数,这算是目前面试中非常热门的考点。窗口函数和GROUP BY最大的区别是:GROUP BY会折叠多行为一行,而窗口函数不折叠行,它在每一行上都保留原有的行,同时增加一个计算列。
| 题号 | 题目 | 考察点 |
|---|---|---|
| 76 | 按成绩从高到低给所有成绩记录排名 | ROW_NUMBER |
| 77 | 查询每门课成绩的前三名 | RANK + PARTITION BY |
| 78 | 查询每个学生总分在中位数以上的记录 | PERCENT_RANK |
| 79 | 查询每门课最高分与最低分的分差 | MAX/MIN OVER |
| 80 | 查询每个班级学生按年龄排序的名次 | DENSE_RANK |
| 81 | 查询每位同学在各科考试中的累计总分 | SUM OVER |
| 82 | 查询每位同学成绩相比上一次考试的变化 | LAG |
| 83 | 查询每位同学成绩相比下一次考试的变化 | LEAD |
| 84 | 查询每个班级年龄最大的学生 | ROW_NUMBER + PARTITION |
| 85 | 查询每门课平均分排名 | AVG OVER |
| 86 | 查询每个学生的选课数量在所有学生中的排名 | COUNT OVER ORDER BY |
| 87 | 查询每个学生成绩最好的课程名称 | 窗口函数 + JOIN |
| 88 | 查询每个班级按总分排名的前三名 | 两层窗口 |
| 89 | 查询成绩表中每行与其所在课程平均分的差值 | AVG OVER |
| 90 | 查询每个学生最近一次考试成绩 | ROW_NUMBER + PARTITION + ORDER BY |
第77题:查询每门课成绩的前三名。
SELECT course_id, student_id, score FROM ( SELECT course_id, student_id, score, ROW_NUMBER() OVER (PARTITION BY course_id ORDER BY score DESC) AS rn FROM scores ) t WHERE rn <= 3;这里用ROW_NUMBER而不是DENSE_RANK,两者的区别是:ROW_NUMBER对并列成绩也会给出不同的序号(1、2、3、4),而DENSE_RANK会为相同成绩分配相同名次,并且下一名是紧接着的数字(1、1、2)。如果你希望“成绩相同算并列”,就用DENSE_RANK。我建议你先把窗口函数中的三个排序函数都练一遍,然后回答一下它们之间的差异,这道面试送分题别丢。
第82题:查询每位同学成绩相比上一次考试的变化。
SELECT student_id, exam_date, score, LAG(score) OVER (PARTITION BY student_id ORDER BY exam_date) AS prev_score, score - LAG(score) OVER (PARTITION BY student_id ORDER BY exam_date) AS diff FROM scores;LAG取同一分组内前一行,LEAD取后一行。PARTITION BY指定了分组范围,ORDER BY指定了组内的排序规则,这两者缺一不可。很多人在学习窗口函数时最大的误区是忘记写PARTITION BY,结果导致全表变成一个窗口,算出来的值完全不是想要的分组内结果。
窗口函数的执行顺序要记住一个口诀:窗口函数是在WHERE和GROUP BY之后执行的,所以你不能在WHERE里对窗口函数的结果进行过滤,这就是为什么第77题需要先查窗口函数再包一层子查询。如果你在MySQL 8.0里写了WHERE rn <= 3,会直接报错,因为WHERE子句根本看不到窗口函数生成的列。
8. 模块六:DML、视图、索引与事务,不只会查还要会改
第91题到第100题虽然只有10道,但它们保证了这套练习的完整性。很多学习SQL的人只会SELECT,一到要写UPDATE和DELETE就畏手畏脚,或者乱写一气导致生产事故。我个人认为,用可控的练习数据把DML、视图、索引和事务都过一遍,是非常必要的。
| 题号 | 题目 | 考察点 |
|---|---|---|
| 91 | 向students表插入一条新学生记录 | INSERT基础 |
| 92 | 将“张伟”的班级改为一班 | UPDATE单表 |
| 93 | 删除年龄大于25且没有成绩记录的学生 | DELETE + JOIN |
| 94 | 创建一个视图,显示学生姓名、课程名、成绩 | CREATE VIEW |
| 95 | 从视图中查询平均分最高的学生 | 视图复用 |
| 96 | 给scores表的course_id字段创建索引 | CREATE INDEX |
| 97 | 用EXPLAIN查看一条查询语句的执行计划 | EXPLAIN |
| 98 | 开启一个事务,插入一条成绩记录并回滚 | BEGIN/ROLLBACK |
| 99 | 开启一个事务,插入一条记录并提交 | COMMIT |
| 100 | 查询当前数据库中的所有表和视图 | SHOW TABLES |
第93题:删除年龄大于25且没有成绩记录的学生。
DELETE s FROM students s LEFT JOIN scores sc ON s.id = sc.student_id WHERE s.age > 25 AND sc.id IS NULL;MySQL支持在DELETE中直接JOIN,这是很多人不知道的语法。执行前可以先改写成SELECT看看会命中哪些行,这是一个好习惯:
SELECT s.* FROM students s LEFT JOIN scores sc ON s.id = sc.student_id WHERE s.age > 25 AND sc.id IS NULL;第97题:用EXPLAIN查看一条查询语句的执行计划。
EXPLAIN SELECT s.name, c.course_name, sc.score FROM scores sc JOIN students s ON sc.student_id = s.id JOIN courses c ON sc.course_id = c.id WHERE sc.score > 90;看EXPLAIN主要关注type列和rows列,理论上type至少要到ref,如果出现ALL全表扫描且数据量大,就要考虑加索引。还要注意Extra列里有没有Using filesort和Using temporary,这两个都是SQL优化的重点信号。做这道题的时候可以试一下:把scores表的course_id索引先删掉再EXPLAIN,你会发现rows列的预估扫描行数明显上升,这样就直观理解索引的作用了。
第98题和第99题:事务回滚与提交。
START TRANSACTION; INSERT INTO students (name, gender, class, age) VALUES ('测试学生', 'F', '一班', 18); ROLLBACK; START TRANSACTION; INSERT INTO students (name, gender, class, age) VALUES ('保留学生', 'M', '二班', 19); COMMIT;事务我建议用InnoDB练习,MyISAM不支持事务。执行ROLLBACK或COMMIT之后,再SELECT一下,你能直观看到哪些操作被保留、哪些被撤销。别看这两题简单,对新手理解“原子性”和“持久性”非常有帮助。实际工作中,如果你在存储过程或者脚本里做了多步数据修改,一定记得包在事务里,并且在异常分支做ROLLBACK,这是避免脏数据的最后一道防线。
9. 刷完这100道题之后,我实测容易翻车的五个点
这套题我在带新人、帮朋友突击面试时反复用了很多轮,最后总结几个绝大多数人都会踩的坑,提前写给你,省得你走弯路。
**第一,函数依赖问题。**MySQL 5.7和8.0中,SELECT后面出现的非聚合字段如果不是GROUP BY字段,默认是允许的(5.7只在严格模式下才报错),但查出来的值来自分组中的哪一行是不确定的。第16题如果写成SELECT course_id, course_name, AVG(score)且GROUP BY只写了course_id,course_name可能随便拿一行,逻辑上是错的。写SQL时自己心里要有数,别依赖这个“默认行为”。
**第二,JOIN后面的ON和WHERE的边界。**第34题已经演示过:ON负责连接条件,WHERE负责结果集过滤。如果你想在LEFT JOIN后把右边为NULL的行保留,过滤条件必须写在ON里而不是WHERE里。同理,在内连接中ON和WHERE的最终结果等价,但语义不同,别混着用。
**第三,COUNT(1)还是COUNT(*)。**这两者在MySQL中性能几乎没有差别,但COUNT(某字段)会忽略字段值为NULL的行,而COUNT(*)不会。第9题和第28题用COUNT(*),第15题用COUNT(course_id)都能用,但如果你统计的是“有多少人选了课”,而课程ID恰好有NULL,两个结果就不一样了。做题时先问自己:到底想数行数,还是数该字段非NULL的个数。
**第四,LIMIT与分页的性能坑。**第72题查第二名用ORDER BY score DESC LIMIT 1, 1,数据量小没问题,但数据量大时LIMIT偏移量越深越慢。比如LIMIT 100000, 10,数据库要先扫过前10万行再取数据。实际业务中可以用“上一页最后一条记录的ID”来做条件过滤,替代深分页。这也是慢SQL优化的常见场景。
**第五,慢SQL优化别只盯着索引看。**热搜词里“慢sql优化 explain主要看哪些信息”热度很高,我在批改练习时也提醒大家:explain里的type列出现ALL、Extra里出现Using temporary或Using filesort,都要警惕。但优化顺序永远是:先看SQL逻辑是否扫了非必要的数据,再看索引是否生效,最后才考虑改表结构和缓存。很多新人事先给所有字段都加索引,反而导致写操作变慢,这是典型的过度优化。
最后给你一个刷题建议
这套题我自己的使用习惯是:每10题一组,刷完一组就停下来,不看答案重新在空的数据库里自己造一套不同的测试数据,再把这10道题重新写一遍。这样做的目的,是强迫自己理解每一道题背后的语法逻辑和适用场景,而不是把答案背下来。
如果你是在准备面试,我特别建议把第28题、第34题、第49题、第65题、第77题这几道题吃透,因为它们是“查重复、查缺失、查最值、查分组TopN、查排名”五大面试原型的代表,面试官出题基本都是从这些原型变形出来的。把这五类题目背后的写法练到不用想就能写出来,比你盲目刷几百道零散题有用得多。
等100道题全部过完,你可以试着把题目里的“一、二、三班”换成你自己的业务订单表、用户表、商品表,重新套一遍这套练习逻辑。做到那一步,SQL对你来说就不再是背语法,而是真正的工具了。