1. 为什么做这套 MySQL 练习题(基础-进阶)
先聊个现象。很多人学 MySQL 是那种“看书全会、一写全废”的状态。今天看一遍 SELECT、UPDATE、DELETE,知道语法长什么样,明天真让你从一张表里把“每门课成绩都大于 80 分的学生”捞出来,手就停住了。我自己带过不少刚转行的同事,也当了好几年“面试官对面的人”,有一个感受特别深:SQL 这门技能,最有效的提升方式不是背语法,而是做题。
所以我花了挺长时间整理了一套 MySQL 练习题,从最基础的建表、增删改查,一路做到子查询、窗口函数、存储过程、索引失效分析。这套题的特点在于:不追求偏难怪,而是把日常开发和面试里最高频的场景全部覆盖一遍。今天把这套题的设计思路、核心题目拆解、和实际练习过程中容易踩的坑都写出来,希望能给正在打 SQL 基础的朋友一条相对顺的路。
1.1 练题前先搞清楚:什么样的题才有价值
市面上的 MySQL 练习题其实不少,但很多质量堪忧。我见过最典型的两种:一种是纯语法默写题,比如“请写出 UPDATE 语句的语法格式”,这种题做完没有任何长进;另一种是炫技题,故意用冷门函数和奇淫技巧,出一道在生产环境里永远用不上的 SQL,做完了除了自我感动没有别的效果。
我个人认为,一套有价值的 MySQL 练习题,至少得满足三个条件。
第一,要贴近真实业务。练习题里的场景应该来自实际开发,比如订单统计、用户留存、课程成绩、商品分类。这些场景才能让你在练完题之后,真的能把技能迁移到工作里。第二,要覆盖完整的知识层级。从单表查询到多表连接,从基础聚合到窗口函数,从语法到执行计划,难度要有梯度。第三,要有“为什么”的讲解。光给答案没用,得知道为什么用这个写法、为什么那个写法会慢、为什么同样的结果有不同的实现路径。
这套题就是按这个思路组织的。下面我会把从基础到进阶的题拆开来讲,每一类题我都会给出典型例题和解题思路,还会把一些题目背后的知识点做延展——这些延展往往才是刷题最值钱的部分。
1.2 从基础到进阶的分层逻辑
这套练习题分成了四个大的阶段,每个阶段对应不同的能力目标。
基础阶段:目标是熟练使用增删改查、WHERE 条件过滤、ORDER BY 排序、LIMIT 分页、基础聚合函数。这个阶段解决的是“能不能把数据查出来”的问题。
进阶一阶段:目标是掌握多表连接、GROUP BY 分组、HAVING 过滤、子查询、CASE WHEN 逻辑分支。这个阶段解决的是“能不能在复杂业务里把数据查对”的问题。
进阶二阶段:目标是掌握窗口函数、公用表表达式(CTE)、变量、存储过程、触发器。这个阶段解决的是“能不能用更优雅的 SQL 解决复杂需求”的问题。
性能与原理阶段:目标是理解索引、执行计划、事务隔离级别、锁机制,能够分析和优化慢查询。这个阶段解决的是“能不能让 SQL 跑得快、跑得稳”的问题。
四个阶段环环相扣。很多人一上来就整窗口函数和存储过程,基础阶段的 GROUP BY 却总是想不明白,最后写出来的 SQL 逻辑漏洞百出。我的建议是:严格按照层级来,每一层先做题验证自己真的掌握了,再往上走。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三道必练的基础题:刷透增删改查
基础阶段虽然简单,但真不能跳过。我见过太多工作了两年的人,写 UPDATE 不加 WHERE、DELETE 之前不 SELECT 检查,全是基础不扎实埋的雷。这一节我就拿三道代表性的基础题来拆解,展示正确的解题和思考过程。
2.1 建表与数据准备:学生-课程-成绩三张表
为了后面所有题目都有统一的练习环境,我设计了一套经典的三张表结构,这也是“学生课程成绩信息实体表”最常见的建模方式:
sql复制CREATE TABLE student (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '学生ID',
name VARCHAR(50) NOT NULL COMMENT '姓名',
gender CHAR(1) DEFAULT 'M' COMMENT '性别',
birth_date DATE COMMENT '出生日期',
class_name VARCHAR(50) COMMENT '班级'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE course (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '课程ID',
course_name VARCHAR(100) NOT NULL COMMENT '课程名称',
credit DECIMAL(3,1) COMMENT '学分'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE score (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '成绩ID',
student_id INT NOT NULL COMMENT '学生ID',
course_id INT NOT NULL COMMENT '课程ID',
score DECIMAL(5,2) COMMENT '成绩',
exam_date DATE COMMENT '考试日期',
UNIQUE KEY uk_stu_course (student_id, course_id),
CONSTRAINT fk_student FOREIGN KEY (student_id) REFERENCES student(id),
CONSTRAINT fk_course FOREIGN KEY (course_id) REFERENCES course(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这套表结构有几个细节值得注意:成绩表的联合唯一索引 uk_stu_course 确保同一学生对同一门课不会重复录入成绩;外键约束在练习阶段可以保留,用来强化对表关系的理解,但真正生产环境里,很多团队会刻意不用外键,把一致性交给应用层处理;字符集统一用 utf8mb4,避免中文等字符乱码。
建表完成之后,建议自己造一批有区分度的数据,别全用“小明、小红、张三”这种,成绩也尽量拉开梯度。因为后面做排名、分组、窗口函数时,数据分布越真实,写出来的 SQL 验证效果越好。
2.2 基础题解法拆解:从 SELECT 到 UPDATE 的完整链路
第一道题:查询所有 1995 年以后出生的男学生,按出生日期从早到晚排序,只要前 5 个。
sql复制SELECT id, name, gender, birth_date
FROM student
WHERE gender = 'M' AND birth_date >= '1995-01-01'
ORDER BY birth_date ASC
LIMIT 5;
这道题考察的是三个基础子句的执行顺序问题。很多人以为 SQL 是从 SELECT 开始读的,其实不是。SQL 的执行逻辑顺序是:FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT。所以 WHERE 里不能用 SELECT 中定义的别名做条件过滤,但 ORDER BY 里可以用别名排序,这就是执行顺序决定的。
第二道题:把所有“计算机基础”课程的学分改成 3.5。
sql复制UPDATE course
SET credit = 3.5
WHERE course_name = '计算机基础';
这道题本身就是给新手的一记警钟。UPDATE 语句里 WHERE 是生命线,没有 WHERE 就是全表更新。我在生产环境里处理过好几次数据事故,都是因为同事写 UPDATE 时忘了 WHERE,或者 WHERE 条件写错导致更新了不该更新的行。所以我的习惯是:在执行 UPDATE 或 DELETE 之前,先写一条同样的 SELECT 看一下匹配的行数,确认无误再执行变更操作。
第三道题:删除没有参加过任何考试的学生记录。
sql复制DELETE FROM student
WHERE id NOT IN (
SELECT DISTINCT student_id FROM score
);
这道题有一个很常见的坑:如果 score 表中 student_id 存在 NULL 值,NOT IN 的结果可能会出乎意料,因为 SQL 中 NULL 参与比较时结果是 UNKNOWN,最终导致整个查询匹配不到任何行。更稳妥的写法是使用 NOT EXISTS:
sql复制DELETE FROM student s
WHERE NOT EXISTS (
SELECT 1 FROM score sc WHERE sc.student_id = s.id
);
这道题很好地展示了“逻辑上等价,但实际执行结果不同”的场景。刷基础题不是背答案,而是要把这些边界情况都摸透。
2.3 基础阶段的常见错误:连错都不知道错在哪
我在帮人看 SQL 时,经常发现基础阶段有几个特别典型的错误。
第一个是把 = 和 <=> 搞混。MySQL 里的 <=> 是安全等于,它可以处理 NULL 值的比较,而普通 = 遇到 NULL 只会返回 UNKNOWN。比如要查成绩为空的学生,WHERE score = NULL 是什么都查不出来的,必须写 WHERE score IS NULL。
第二个是字符串比较的隐式转换问题。比如 WHERE phone = 13800138000,如果 phone 字段是 VARCHAR 类型,MySQL 会尝试把字段转成数字再比较,导致索引失效,甚至匹配出错误结果。基础练习时就该养成类型匹配的好习惯。
第三个是分页越深越慢没人管。LIMIT 10000, 20 看着没什么,但 MySQL 实际上要扫完前面 10020 行才能返回 20 行。基础题里虽然不涉及性能优化,但从一开始写 SQL 就要有“数据量大了会不会炸”的意识。后面我会专门讲索引和执行的优化。
3. 进阶核心:多表连接、分组聚合与子查询
过了基础阶段,真正拉开差距的是进阶部分的题目。这一节我挑了三类最核心的进阶题来拆解:多表连接、分组聚合、子查询。这三板斧是日常业务开发里出现频率最高的 SQL 技能,也是面试题的重灾区。
3.1 多表连接的三种形态:INNER JOIN、LEFT JOIN、RIGHT JOIN
先来一道典型题目:查询所有学生的姓名、课程名称、成绩,要求返回所有选了课的学生信息,没选课的学生不需要展示。
sql复制SELECT s.name AS student_name, c.course_name, sc.score
FROM score sc
INNER JOIN student s ON sc.student_id = s.id
INNER JOIN course c ON sc.course_id = c.id;
这道题用 INNER JOIN,因为要同时满足两个表的关联条件,只返回匹配上的行。如果改成“展示所有学生及其选课情况,没选课的学生课程列显示为 NULL”,就要换成 LEFT JOIN,从学生表出发,左表数据全保留:
sql复制SELECT s.name AS student_name, c.course_name, sc.score
FROM student s
LEFT JOIN score sc ON sc.student_id = s.id
LEFT JOIN course c ON sc.course_id = c.id;
这里核心是搞清楚“以哪张表为主表”。LEFT JOIN 左表全保留,RIGHT JOIN 右表全保留。虽然 MySQL 也支持 RIGHT JOIN,但我个人习惯一律写 LEFT JOIN,需要哪张表为主就把哪张表放左边,逻辑更统一,其他人看代码也更好理解。
连接条件的书写也容易出问题。ON 后面跟的是两表关联的条件,WHERE 后面是结果集的过滤条件。把关联条件误写到 WHERE 里,对于 INNER JOIN 来说结果可能一样,但 LEFT JOIN 就会出大问题,左表会被过滤掉本该保留的行。
3.2 分组聚合与 HAVING 细节:WHERE 管不了聚合结果
进阶阶段必考的一道题:查询平均成绩大于 80 分的学生 ID 和平均成绩。
sql复制SELECT student_id, AVG(score) AS avg_score
FROM score
GROUP BY student_id
HAVING AVG(score) > 80;
这道题的关键考点是:WHERE 是在 GROUP BY 之前执行的,所以它不能过滤聚合结果。过滤聚合结果必须用 HAVING。很多新手会顺手写成 WHERE AVG(score) > 80,然后报错或得到错误结果,就是因为没有理解执行顺序。
另一个常见的坑是关于 ONLY_FULL_GROUP_BY 模式。MySQL 5.7 之后默认开启这个 SQL 模式,它要求 SELECT 中出现的非聚合列必须出现在 GROUP BY 中。比如下面这条 SQL 在某些配置下会直接报错:
sql复制SELECT student_id, course_id, AVG(score)
FROM score
GROUP BY student_id;
因为 course_id 没有出现在 GROUP BY 中,而它又不是聚合函数参数。在 MySQL 里,如果关闭了 ONLY_FULL_GROUP_BY,这条 SQL 能跑,但它返回的 course_id 是该分组内的任意值,逻辑上不可控。练习时我建议保持默认模式,强制自己写出严谨的分组 SQL。
3.3 子查询与 EXISTS:用还是不用,这是个问题
进阶阶段有一类典型需求:查询所有“没有选修任何课程”的学生。前面我已经展示过 NOT EXISTS 的写法,这里再深入聊一下子查询的两种风格。
sql复制-- 风格一:IN 子查询
SELECT name FROM student
WHERE id NOT IN (
SELECT student_id FROM score WHERE student_id IS NOT NULL
);
-- 风格二:EXISTS 子查询
SELECT name FROM student s
WHERE NOT EXISTS (
SELECT 1 FROM score sc WHERE sc.student_id = s.id
);
两种写法在数据量不大的时候性能差异不大,但 IN 子查询有时间接性,EXISTS 是相关性子查询,对外层每一行都会执行一次子查询。到底哪个快,取决于表的规模和索引情况。练习时我建议把两种写法都写一遍,养成多方案对比的习惯,这对后面理解执行计划非常有帮助。
子查询还有一个容易踩的坑是“子查询结果集过大”。比如 WHERE id IN (SELECT id FROM student WHERE ...),如果子查询的结果有几万行,IN 列表会非常长,可能导致性能问题。这时候用 JOIN 改写往往更高效。写成 JOIN 的形式:
sql复制SELECT s.name
FROM student s
JOIN (
SELECT DISTINCT student_id FROM score
) tmp ON s.id = tmp.student_id;
这也是刷题的一个重要收获:同一道题可以用 JOIN、IN、EXISTS 三种写法实现,但不同写法在不同场景下的表现可能差异极大。
4. 窗口函数与排名场景:面试最强分水岭
如果你去面试,面试官问“用 SQL 查每个班级排名前 3 的学生”,而你还在用临时表 + 自连接慢慢算,那大概率要凉。MySQL 8.0 引入了窗口函数,这件事彻底改变了复杂排名的写法。这一节我把窗口函数单独拿出来讲,因为它是从业余到专业的明显标志性知识点之一。
4.1 窗口函数入门:ROW_NUMBER、RANK、DENSE_RANK
窗口函数的核心特征是可以让计算基于当前行所属的“窗口”进行,同时每一行仍然保留自己的详细信息,不会像 GROUP BY 那样折叠行数。这非常关键,因为很多需求要求“既能看到明细,又能看到汇总或排名”。
先看一个经典的“薪水排名”需求:查询每个部门内员工的薪水排名。
sql复制SELECT
dept_id,
emp_name,
salary,
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn
FROM employee;
这里 PARTITION BY dept_id 表示按部门分区,ORDER BY salary DESC 表示在分区内按薪水降序排列,ROW_NUMBER() 生成连续的行号。三个排序函数各有区别:ROW_NUMBER 是连续编号,哪怕薪水相同也强制分出先后;RANK 是跳跃排名,比如两人并列第一,第三名直接是 3;DENSE_RANK 是连续排名,并列第一后第二名是 2。这个细节极其常考,必须记清楚。
4.2 经典排名场景实战:用窗口函数一行搞定
再回到那道经典题:查询每门课程成绩排名前 3 的学生。
sql复制SELECT course_id, student_id, score, rk
FROM (
SELECT
course_id,
student_id,
score,
DENSE_RANK() OVER (PARTITION BY course_id ORDER BY score DESC) AS rk
FROM score
) t
WHERE rk <= 3;
子查询里先用 DENSE_RANK 按课程分区、按成绩降序生成排名,外层再用 WHERE 过滤前 3 名。这个模式几乎可以套用到所有“分组 TopN”的需求上,比如查每个品类销量前 5 的商品、查每个用户最近 3 笔订单……我强烈建议把这段 SQL 练到能默写的程度。
窗口函数里还可以用聚合函数,比如累计求和:
sql复制SELECT
student_id,
exam_date,
score,
SUM(score) OVER (PARTITION BY student_id ORDER BY exam_date) AS cumulative_score
FROM score;
这个 SUM() OVER (...) 的写法实现了“按日期累计”的效果,不用自连接,不用临时表,在报表统计分析里特别常用。练题的时候一定要亲手跑一遍这些 SQL,看输出结果和预期是否一致,只有亲手试过才能理解窗口的边界是怎么划分的。
5. 存储过程、索引与事务:从会用走向优化
能写对 SQL 只算第一阶段,能写出生产级、高性能、可靠的 SQL 才是进阶的真正目标。这一节我聊三个方向:存储过程的实际价值、索引失效的典型场景、以及事务与锁的基本概念。这也是我练习后期集中投入最多的部分。
5.1 存储过程与触发器:把逻辑交给数据库的代价与回报
存储过程在互联网公司里用得不算多,很多团队倾向于把业务逻辑放在应用层,但存储过程在特定场景下依然有意义。比如,批量处理数据、定时任务、复杂的报表计算,或者在多个操作需要保证原子性时,存储过程可以把一组 SQL 打包成一个整体。
用一个简单的存储过程示例:为指定学生添加一条成绩记录,如果该学生该课程已有成绩,则更新,否则插入。
sql复制DELIMITER //
CREATE PROCEDURE upsert_score(
IN p_student_id INT,
IN p_course_id INT,
IN p_score DECIMAL(5,2)
)
BEGIN
IF EXISTS (
SELECT 1 FROM score WHERE student_id = p_student_id AND course_id = p_course_id
) THEN
UPDATE score SET score = p_score WHERE student_id = p_student_id AND course_id = p_course_id;
ELSE
INSERT INTO score (student_id, course_id, score) VALUES (p_student_id, p_course_id, p_score);
END IF;
END //
DELIMITER ;
这里有个关键细节:创建存储过程时改变了分隔符 DELIMITER //,因为默认的分隔符是分号,而存储过程中有多条语句都以分号结尾,如果不改变分隔符,MySQL 会在第一条分号处就认为语句结束了,导致语法错误。这个坑几乎每个初学存储过程的人都会踩,我就见过有人调了半天,最后才发现是分隔符的问题。
触发器和存储过程类似,但对性能的影响通常更大,而且触发器是隐式触发,排查问题的时候不容易快速定位到是谁改了数据。我给新手一句劝告:练习触发器没问题,但生产环境慎用触发器。
5.2 索引失效的典型场景:会写 SQL 更要会查 SQL
索引是 MySQL 性能优化的核心,也是练习题从“基础-进阶”过渡时最值得花时间的一块。
经常考的索引失效场景包括:对索引列使用函数、对索引列做隐式类型转换、使用前置模糊查询、用 OR 连接条件时其中一列没有索引等。拿一个最常见的例子来说:
sql复制-- 假设 name 列上有索引
-- 这条语句会导致索引失效,因为对 name 用了函数
SELECT * FROM student WHERE LOWER(name) = 'tom';
-- 这条语句同样可能失效,因为 name 是字符串,却和数字比较
SELECT * FROM student WHERE name = 123;
我自己排查慢查询时养成了一个习惯:任何 SQL 在压测或者上线前,都先用 EXPLAIN 看一下执行计划。EXPLAIN 输出里的 type 字段,从 const、ref、range 到 ALL,代表访问类型从好到差。如果看到 ALL,意味着全表扫描,就要警惕了。
sql复制EXPLAIN SELECT s.name, sc.score
FROM student s
LEFT JOIN score sc ON s.id = sc.student_id
WHERE s.gender = 'M';
通过 EXPLAIN 能清楚看到每张表的访问方式、有没有走索引、扫描了多少行,这比瞎猜“为什么这么慢”要高效得多。刷题阶段就把 EXPLAIN 当常规工具用起来,后面处理线上问题会从容很多。
5.3 事务隔离级别与锁:从练习场景延伸到生产场景
先说一个我实际遇到过的事故。当时一个同事在线上执行一条 UPDATE,因为没走索引,锁定了全表,导致整个业务在高峰期被锁了十几秒。从那以后,我特别重视事务和锁相关的知识。
MySQL 默认使用 InnoDB 引擎,支持事务。四大隔离级别里,RR(可重复读)是 MySQL 的默认级别,它通过 MVCC 解决了部分幻读问题,但在某些场景下仍然会有间隙锁。练习时可以试试:开启两个事务,分别在两个会话里执行 SELECT、UPDATE,观察彼此的阻塞情况。这种动手实验比死记概念有用得多。
一个基础的锁示例:
sql复制-- 会话 A
BEGIN;
SELECT * FROM score WHERE student_id = 1 FOR UPDATE;
-- 在未提交之前,其他会话对同一行的更新操作会被阻塞
-- 会话 B
BEGIN;
UPDATE score SET score = 90 WHERE student_id = 1;
-- 这条更新会一直等待,直到会话 A 提交或回滚
通过这个练习,你能很直观地理解行锁和事务的关系。面试时被问“数据库怎么防止并发修改同一行数据”,答出来这个概念立刻加分。
6. 刷题避坑实录与排查技巧
最后这一节,我把自己练题外加帮别人看题过程中遇到的高频问题和排查经验整理成了一份速查,基本上覆盖了从入门到进阶最常见的坑。
6.1 高频报错与解决速查表
| 报错或异常现象 | 常见原因 | 排查方向 |
|---|---|---|
ERROR 1064 (42000) 语法错误 |
SQL 语句里关键字拼错、引号不配对、分隔符问题 | 先查关键字拼写,再检查字符串引号,存储过程检查 DELIMITER |
Unknown column 'xxx' in 'where clause' |
字段名写错,或字段不在当前表中 | 用 DESC 表名 查看表结构确认 |
Expression #1 of SELECT list is not in GROUP BY |
非聚合列没有出现在 GROUP BY 中 | 要么把该列加进 GROUP BY,要么用聚合函数包起来 |
Lock wait timeout exceeded |
事务未提交导致的锁等待超时 | 用 SHOW PROCESSLIST 查看正在执行的事务,定位到具体会话 |
| 结果集出现 NULL 但预期不是 NULL | 表连接时选择了不正确的连接类型,或数据本身就是 NULL | 先检查源数据,再确认是 INNER JOIN 还是 LEFT JOIN |
| 排序结果不对 | 中文排序、字符集排序规则问题,或排序字段类型不一致 | 确认字符集排序规则,确认字段类型一致,必要时显式指定 COLLATE |
这张表是压缩版,实际线上排查还要配合日志、监控和 EXPLAIN。但从练题阶段开始养成“看到报错先想原因、再查方向”的习惯,会省下大量时间。
6.2 我自己刷题踩过的坑与心得
练题过程中踩过几个印象很深的坑,写出来给大家避避雷。
第一个坑是“只看不练”。刚开始我刷题时,喜欢看题解,觉得“看懂了”就等于“会了”。结果一关上答案,自己从头写一遍时,总是会在 ORDER BY、GROUP BY 这些地方卡住。后来我把所有练习题的答案都屏蔽掉,自己先写,错了再对照答案,这个习惯让我的 SQL 水平真正提升了一个台阶。刷题千万不要高估自己的记忆力,SQL 的熟练度完全靠手,不靠眼睛。
第二个坑是忽略数据量对 SQL 写法的影响。做练习时数据量小,随便怎么写都秒回,于是形成了不好的习惯。后来处理百万级数据时才发现,同样是查一个结果,一个走了索引一个没走索引,速度可以差 50 倍以上。以后刷题时我就会特意问自己:如果在千万级数据上跑,这条 SQL 还能不能扛得住?这个问题带着问久了,写出来的 SQL 自然就更有生产意识。
第三个坑是不重视 NULL 值处理。NULL 在 SQL 中就是“薛定谔的猫”,你说它存在吧它没有值,你说它不存在吧它又占着位置。聚合函数遇到 NULL 的行为、比较运算遇到 NULL 的结果、唯一索引里多个 NULL 能不能共存……这些问题都要自己亲手验证一遍,才能在真实场景中不踩坑。
第四个坑是忽略命令行的使用。很多人从 Navicat 开始学 MySQL,按钮点击习惯了,连最基本的命令行登录都不熟练。但生产环境、服务器排查、脚本自动化都离不开命令行。练习时我特意要求自己:先命令行搞定,再图形化工具辅助。到现在我依然认为这是一个极其正确的决定。
最后,这套 MySQL 练习题(基础-进阶)目前我仍然在持续扩充,每当我从面试题或实际项目中看到好的 SQL 场景,就会添加进去。刷题不是目的,通过刷题真的理解数据库的思维方式,才是最大的收获。我的感受是:SQL 并不是一门“背诵语言”,它更像一套解决问题的逻辑框架。你练得越多,脑子里积累的“解题模式”就越多,遇到新需求的反应速度就越快。如果你也想系统提升 MySQL 的能力,建议就从这套题开始,一点点补齐自己的短板。
