一、复杂查询语法:搞定多表与子查询的核心技巧
实际开发中,我们很少只查询单张表,多表关联、子查询是高频需求,这部分也是SQL进阶的基础,掌握这些,能解决80%的业务查询场景。
1.1 多表连接:不同场景选对连接方式,效率翻倍
多表连接的核心是"通过关联字段,将多张表的数据整合为一张虚拟表",常用的连接方式有4种:INNER JOIN、LEFT JOIN、RIGHT JOIN、FULL JOIN,很多人容易混淆它们的区别,这里用"学生表+成绩表"的示例,一次性讲清楚。
先准备测试表和测试数据(后续所有示例均基于这两张表):
sql
-- 学生表(student)
CREATE TABLE student (
id INT PRIMARY KEY AUTO_INCREMENT,
student_no VARCHAR(20) NOT NULL COMMENT '学号',
name VARCHAR(50) NOT NULL COMMENT '姓名',
grade VARCHAR(10) NOT NULL COMMENT '年级',
class VARCHAR(20) NOT NULL COMMENT '班级'
);
-- 成绩表(score)
CREATE TABLE score (
id INT PRIMARY KEY AUTO_INCREMENT,
student_no VARCHAR(20) NOT NULL COMMENT '学号(关联student表)',
subject VARCHAR(30) NOT NULL COMMENT '科目',
score INT NOT NULL COMMENT '分数',
exam_date DATE NOT NULL COMMENT '考试日期'
);
-- 插入测试数据
INSERT INTO student (student_no, name, grade, class)
VALUES
('2024001', '张三', '高一', '1班'),
('2024002', '李四', '高一', '1班'),
('2024003', '王五', '高一', '2班'),
('2024004', '赵六', '高二', '3班');
INSERT INTO score (student_no, subject, score, exam_date)
VALUES
('2024001', '数学', 92, '2024-06-10'),
('2024001', '语文', 88, '2024-06-10'),
('2024002', '数学', 75, '2024-06-10'),
('2024003', '语文', 90, '2024-06-10'),
('2024005', '数学', 80, '2024-06-10'); -- 该学号无对应学生信息
1.1.1 INNER JOIN(内连接):只取两张表的交集
核心逻辑:只返回"两张表中关联字段匹配成功"的数据,不匹配的会被过滤掉。
示例:查询有成绩记录的学生姓名、班级及对应科目和分数(只显示既有学生信息、又有成绩的记录):
sql
SELECT s.name, s.class, sc.subject, sc.score
FROM student s
INNER JOIN score sc ON s.student_no = sc.student_no;
-- 执行结果:不会出现赵六(无成绩)和学号2024005(无学生信息)的记录
-- 姓名 班级 科目 分数
-- 张三 1班 数学 92
-- 张三 1班 语文 88
-- 李四 1班 数学 75
-- 王五 2班 语文 90
1.1.2 LEFT JOIN(左连接):左表全保留,右表匹配补充
核心逻辑:保留"左表(LEFT JOIN左边的表)"的所有数据,右表中匹配成功的显示对应数据,匹配失败的显示NULL。
示例:查询所有学生的姓名、班级,以及他们的成绩(无成绩的学生也显示,成绩字段为NULL):
sql
SELECT s.name, s.class, sc.subject, sc.score
FROM student s
LEFT JOIN score sc ON s.student_no = sc.student_no;
-- 执行结果:赵六(2024004)无成绩,subject和score显示NULL
-- 姓名 班级 科目 分数
-- 张三 1班 数学 92
-- 张三 1班 语文 88
-- 李四 1班 数学 75
-- 王五 2班 语文 90
-- 赵六 3班 NULL NULL
1.1.3 RIGHT JOIN(右连接):右表全保留,左表匹配补充
核心逻辑:和LEFT JOIN相反,保留"右表(RIGHT JOIN右边的表)"的所有数据,左表匹配成功显示对应数据,匹配失败显示NULL。
示例:查询所有成绩记录,以及对应学生的姓名、班级(无对应学生的成绩也显示):
sql
SELECT s.name, s.class, sc.subject, sc.score
FROM student s
RIGHT JOIN score sc ON s.student_no = sc.student_no;
-- 执行结果:学号2024005无学生信息,name和class显示NULL
-- 姓名 班级 科目 分数
-- 张三 1班 数学 92
-- 张三 1班 语文 88
-- 李四 1班 数学 75
-- 王五 2班 语文 90
-- NULL NULL 数学 80
1.1.4 FULL JOIN(全连接):保留两张表的所有数据
核心逻辑:保留左表和右表的所有数据,匹配成功的显示对应数据,匹配失败的一侧显示NULL。 注意:MySQL本身不直接支持FULL JOIN,但可以通过"LEFT JOIN + UNION ALL + RIGHT JOIN"模拟实现。
示例:查询所有学生和所有成绩记录,无论是否匹配:
sql
-- 模拟FULL JOIN
SELECT s.name, s.class, sc.subject, sc.score
FROM student s
LEFT JOIN score sc ON s.student_no = sc.student_no
UNION ALL
SELECT s.name, s.class, sc.subject, sc.score
FROM student s
RIGHT JOIN score sc ON s.student_no = sc.student_no
WHERE s.id IS NULL; -- 过滤掉重复的匹配数据
-- 执行结果:包含所有学生和所有成绩,无匹配的字段显示NULL
-- 姓名 班级 科目 分数
-- 张三 1班 数学 92
-- 张三 1班 语文 88
-- 李四 1班 数学 75
-- 王五 2班 语文 90
-- 赵六 3班 NULL NULL
-- NULL NULL 数学 80
1.1.5 自连接与不等值连接
除了上述4种常规连接,还有两种特殊连接场景,在复杂业务中会用到:
- 自连接:一张表自己和自己连接,本质是"将一张表当作两张表使用",常用于查询"同一表中具有关联关系的数据"(如员工表中查询员工及其上级)。
示例(新增员工表测试数据):
sql
-- 员工表(含上级ID)
CREATE TABLE emp (
id INT PRIMARY KEY AUTO_INCREMENT,
emp_name VARCHAR(50) NOT NULL,
dept VARCHAR(30) NOT NULL,
manager_id INT COMMENT '上级ID(关联自身id)'
);
INSERT INTO emp (emp_name, dept, manager_id)
VALUES
('张三', '研发部', NULL), -- 无上级(部门经理)
('李四', '研发部', 1), -- 上级是张三
('王五', '研发部', 1), -- 上级是张三
('赵六', '测试部', NULL); -- 无上级(部门经理)
-- 自连接查询:查询每个员工的姓名及其上级姓名
SELECT e.emp_name AS 员工姓名, m.emp_name AS 上级姓名
FROM emp e
LEFT JOIN emp m ON e.manager_id = m.id;
-- 执行结果
-- 员工姓名 上级姓名
-- 张三 NULL
-- 李四 张三
-- 王五 张三
-- 赵六 NULL
- 不等值连接:连接条件不是"=",而是">、<、>=、<=、BETWEEN AND"等不等值条件,常用于"范围匹配"场景。
示例(新增分数等级表):
sql
-- 分数等级表
CREATE TABLE score_level (
level VARCHAR(10) PRIMARY KEY,
min_score INT NOT NULL,
max_score INT NOT NULL
);
INSERT INTO score_level (level, min_score, max_score)
VALUES ('优秀', 90, 100), ('良好', 80, 89), ('及格', 60, 79), ('不及格', 0, 59);
-- 不等值连接:查询每个学生的成绩及对应等级
SELECT sc.student_no, sc.subject, sc.score, sl.level
FROM score sc
LEFT JOIN score_level sl
ON sc.score BETWEEN sl.min_score AND sl.max_score;
-- 执行结果
-- student_no subject score level
-- 2024001 数学 92 优秀
-- 2024001 语文 88 良好
-- 2024002 数学 75 及格
-- 2024003 语文 90 优秀
-- 2024005 数学 80 良好
1.2 子查询:嵌套查询的正确用法与避坑技巧
子查询(又称嵌套查询):将一个查询语句的结果,作为另一个查询语句的条件或数据源,分为"标量子查询、列子查询、行子查询、表子查询、关联子查询"5种,核心是"先执行内层查询,再执行外层查询"。
重点:子查询虽然灵活,但嵌套过深会影响性能,实际开发中需合理使用,优先考虑用JOIN替代复杂子查询。
1.2.1 标量子查询:返回单个值(一行一列)
核心特点:内层查询结果只有"一个值",常用于WHERE条件中,搭配=、>、<等比较运算符。
示例1:查询数学成绩大于平均分的学生学号、科目、分数:
sql
-- 先查询数学平均分(内层子查询,返回单个值),再查询大于该平均分的记录
SELECT student_no, subject, score
FROM score
WHERE subject = '数学'
AND score > (SELECT AVG(score) FROM score WHERE subject = '数学');
-- 执行逻辑:先执行内层查询,得到数学平均分((92+75+80)/3 ≈ 82.33),再筛选出分数>82.33的记录
-- 执行结果
-- student_no subject score
-- 2024001 数学 92
示例2:查询成绩最高的学生姓名、科目、分数(假设最高成绩唯一):
sql
SELECT s.name, sc.subject, sc.score
FROM student s
JOIN score sc ON s.student_no = sc.student_no
WHERE sc.score = (SELECT MAX(score) FROM score);
-- 执行结果:成绩最高为92(张三,数学)
-- name subject score
-- 张三 数学 92
1.2.2 列子查询:返回一列多行
核心特点:内层查询结果是"一列多个值",常用于WHERE条件中,搭配IN、NOT IN、ANY、ALL等运算符。
示例1:查询高一学生的所有成绩记录(先查高一学生的学号,再查对应成绩):
sql
SELECT * FROM score
WHERE student_no IN (SELECT student_no FROM student WHERE grade = '高一');
-- 内层查询返回高一学生的学号(2024001、2024002、2024003),外层查询筛选出这些学号的成绩
-- 执行结果:包含张三、李四、王五的成绩,不包含赵六(高二)和2024005(无年级信息)
-- id student_no subject score exam_date
-- 1 2024001 数学 92 2024-06-10
-- 2 2024001 语文 88 2024-06-10
-- 3 2024002 数学 75 2024-06-10
-- 4 2024003 语文 90 2024-06-10
示例2:查询分数大于"高一所有学生数学成绩"的记录(用ALL):
sql
SELECT * FROM score
WHERE score > ALL (SELECT score FROM score WHERE student_no IN (SELECT student_no FROM student WHERE grade = '高一') AND subject = '数学');
-- 内层查询返回高一学生的数学成绩(92、75),ALL表示"大于所有值",即>92
-- 执行结果:无符合条件的记录(最高分为92)
1.2.3 行子查询:返回一行多列
核心特点:内层查询结果是"一行多个值",常用于WHERE条件中,搭配=、IN等运算符,需保证内层和外层的列数、数据类型一致。
示例:查询和"张三(2024001)数学成绩"相同的所有记录(学号、科目、分数都匹配):
sql
SELECT * FROM score
WHERE (student_no, subject, score) = (SELECT student_no, subject, score FROM score WHERE student_no = '2024001' AND subject = '数学');
-- 内层查询返回一行三列(2024001、数学、92),外层查询匹配相同的记录
-- 执行结果
-- id student_no subject score exam_date
-- 1 2024001 数学 92 2024-06-10
1.2.4 表子查询:返回多行多列(当作临时表)
核心特点:内层查询结果是"多行多列",相当于一张临时表,常用于FROM子句中,给临时表起别名后使用。
示例:查询每个学生的平均成绩,筛选出平均成绩大于85分的学生(先查每个学生的平均成绩,再筛选):
sql
-- 表子查询:将每个学生的平均成绩作为临时表
SELECT t.student_no, s.name, t.avg_score
FROM (SELECT student_no, AVG(score) AS avg_score FROM score GROUP BY student_no) t
JOIN student s ON t.student_no = s.student_no
WHERE t.avg_score > 85;
-- 执行逻辑:内层子查询生成临时表t(包含student_no和avg_score),再和student表连接,筛选平均成绩>85的记录
-- 执行结果
-- student_no name avg_score
-- 2024001 张三 90.0000
-- 2024003 王五 90.0000
1.2.5 关联子查询:内层查询依赖外层查询(核心难点)
核心特点:和普通子查询不同,关联子查询的内层查询会用到外层查询的字段,执行顺序是"外层查询每执行一行,内层查询就执行一次",灵活性高,但性能相对较差(避免大量数据场景使用)。
示例1:查询每个学生的最高成绩及对应科目(按学生分组,每个学生取最高成绩的记录):
sql
SELECT s.name, sc.subject, sc.score
FROM student s
JOIN score sc ON s.student_no = sc.student_no
WHERE sc.score = (SELECT MAX(score) FROM score WHERE student_no = sc.student_no);
-- 执行逻辑:外层查询每取一条score记录,内层查询就根据该记录的student_no,查询该学生的最高成绩,判断当前score是否等于最高成绩
-- 执行结果
-- name subject score
-- 张三 数学 92
-- 李四 数学 75
-- 王五 语文 90
-- (2024005无学生信息,不显示)
示例2:查询每个科目中,分数大于该科目平均分的记录:
sql
SELECT subject, student_no, score
FROM score sc1
WHERE score > (SELECT AVG(score) FROM score sc2 WHERE sc2.subject = sc1.subject);
-- 执行逻辑:外层查询每取一条sc1记录,内层查询就根据该记录的subject,查询该科目的平均分,判断当前score是否大于平均分
-- 执行结果(数学平均分≈82.33,语文平均分≈89)
-- subject student_no score
-- 数学 2024001 92
-- 语文 2024003 90
1.3 EXISTS / IN 优化与选择
EXISTS和IN都是用于"判断是否存在满足条件的记录",但用法和性能有差异,很多人不知道该选哪个,这里结合场景给出明确结论。
1.3.1 语法区别
sql
-- IN:判断字段是否在子查询返回的列表中
SELECT * FROM 表名 WHERE 字段 IN (子查询);
-- EXISTS:判断子查询是否返回数据(只要有一条,就返回true)
SELECT * FROM 表名 WHERE EXISTS (子查询);
1.3.2 性能差异与选择原则
核心结论:外层表小,用IN;内层表小,用EXISTS(原因:IN是先执行内层子查询,将结果存入临时表,再和外层表匹配;EXISTS是先执行外层表,再执行内层子查询,匹配到就终止,无需全部执行)。
示例1:查询有成绩记录的学生(外层表student小,用IN):
sql
SELECT * FROM student WHERE student_no IN (SELECT DISTINCT student_no FROM score);
-- 等价于用EXISTS:
SELECT * FROM student s WHERE EXISTS (SELECT 1 FROM score sc WHERE sc.student_no = s.student_no);
-- 两种方式结果一致,但student表数据量小时,IN效率更高
示例2:查询有学生信息的成绩记录(内层表student小,用EXISTS):
sql
SELECT * FROM score sc WHERE EXISTS (SELECT 1 FROM student s WHERE s.student_no = sc.student_no);
-- 等价于用IN:
SELECT * FROM score WHERE student_no IN (SELECT student_no FROM student);
-- 两种方式结果一致,但student表数据量小时,EXISTS效率更高
注意:NOT IN 和 NOT EXISTS 差异更大------NOT IN 会受到NULL值影响(如果子查询返回的结果包含NULL,NOT IN会返回空集),而NOT EXISTS 不受NULL值影响,优先用NOT EXISTS替代NOT IN。
反例(NOT IN 踩坑):
sql
-- 子查询返回的student_no包含NULL(假设新增一条student_no为NULL的成绩)
INSERT INTO score (student_no, subject, score, exam_date) VALUES (NULL, '英语', 85, '2024-06-10');
-- 用NOT IN 查询无学生信息的成绩记录,会返回空集(因为NULL无法比较)
SELECT * FROM score WHERE student_no NOT IN (SELECT student_no FROM student);
-- 用NOT EXISTS 查询,正常返回结果
SELECT * FROM score sc WHERE NOT EXISTS (SELECT 1 FROM student s WHERE s.student_no = sc.student_no);
1.4 UNION / UNION ALL 区别与性能
UNION和UNION ALL 用于"合并两个或多个查询结果集",要求两个查询的列数、数据类型一致,核心区别在于"是否去重"。
1.4.1 语法与示例
sql
-- UNION:合并结果集,去重(会扫描所有结果,删除重复记录,性能较差)
SELECT student_no, subject, score FROM score WHERE subject = '数学'
UNION
SELECT student_no, subject, score FROM score WHERE score > 90;
-- UNION ALL:合并结果集,不去重(直接合并,不处理重复,性能较好)
SELECT student_no, subject, score FROM score WHERE subject = '数学'
UNION ALL
SELECT student_no, subject, score FROM score WHERE score > 90;
执行结果对比:
-
UNION 结果:合并后去重,张三的数学92分只出现一次;
-
UNION ALL 结果:合并后不去重,张三的数学92分出现两次(既满足subject=数学,又满足score>90)。
1.4.2 选择原则
-
如果确定两个结果集没有重复记录,优先用UNION ALL(性能更优,避免不必要的去重操作);
-
如果可能有重复记录,且需要去重,再用UNION;
-
注意:UNION 和 UNION ALL 都要求两个查询的"列数、数据类型、列顺序"完全一致,否则会报错。
二、分组与聚合高级用法:摆脱GROUP BY的常见坑
分组(GROUP BY)和聚合函数(COUNT、SUM、AVG等)是统计类查询的核心,很多人在使用时会遇到"ONLY_FULL_GROUP_BY"报错、HAVING与WHERE混淆等问题,这里逐一拆解,结合示例避坑。
2.1 GROUP BY 原理与常见坑(ONLY_FULL_GROUP_BY)
核心原理:GROUP BY 用于"按指定字段分组",分组后,SELECT 后面的字段只能是"分组字段"或"聚合函数",否则会出现逻辑混乱(MySQL 5.7+ 开启了ONLY_FULL_GROUP_BY模式,会直接报错)。
常见坑:SELECT 字段中包含非分组、非聚合的字段,导致报错。
示例(正确 vs 错误):
sql
-- 错误示例:SELECT 包含非分组、非聚合字段(name),ONLY_FULL_GROUP_BY模式下报错
SELECT student_no, name, AVG(score) AS avg_score
FROM student s
JOIN score sc ON s.student_no = sc.student_no
GROUP BY student_no;
-- 报错原因:name不是分组字段,也不是聚合函数,分组后无法确定返回哪个name(虽然一个student_no对应一个name,但MySQL不允许)
-- 正确示例1:SELECT 只包含分组字段和聚合函数
SELECT student_no, AVG(score) AS avg_score
FROM score
GROUP BY student_no;
-- 正确示例2:如果需要显示name,将name也加入分组(因为student_no是主键,name和student_no一一对应,分组后不影响)
SELECT s.student_no, s.name, AVG(sc.score) AS avg_score
FROM student s
JOIN score sc ON s.student_no = sc.student_no
GROUP BY s.student_no, s.name;
补充:如果确实需要在SELECT中显示非分组字段,可通过"聚合函数(如MAX、MIN)"包裹(适用于非分组字段和分组字段一一对应的场景):
sql
SELECT student_no, MAX(name) AS name, AVG(score) AS avg_score
FROM student s
JOIN score sc ON s.student_no = sc.student_no
GROUP BY student_no;
2.2 聚合函数:COUNT/SUM/AVG/MAX/MIN 进阶
基础用法大家都熟悉,这里重点讲进阶用法和避坑点,尤其是COUNT和AVG的细节。
2.2.1 COUNT:统计行数的3种用法与区别
sql
-- 1. COUNT(*):统计所有行数,包括NULL值(最常用,效率最高)
SELECT COUNT(*) FROM score; -- 统计所有成绩记录数(含student_no为NULL的记录)
-- 2. COUNT(字段名):统计该字段非NULL的行数
SELECT COUNT(student_no) FROM score; -- 统计student_no非NULL的成绩记录数(不含student_no为NULL的记录)
-- 3. COUNT(DISTINCT 字段名):统计该字段非NULL且去重后的行数
SELECT COUNT(DISTINCT student_no) FROM score; -- 统计有成绩的不同学生数
避坑点:COUNT(1) 和 COUNT(*) 效率基本一致(MySQL优化后),无需刻意替换;COUNT(字段名) 效率低于COUNT(*),因为需要判断字段是否为NULL。
2.2.2 SUM/AVG:处理NULL值与异常数据
-
SUM:自动忽略NULL值,若所有值都是NULL,返回NULL(可搭配IFNULL处理);
-
AVG:自动忽略NULL值,计算的是"非NULL值的平均值"(不是所有行的平均值)。
sql
-- 示例:给score表新增一条score为NULL的记录
INSERT INTO score (student_no, subject, score, exam_date) VALUES ('2024001', '英语', NULL, '2024-06-10');
-- SUM:忽略NULL值,返回92+88+75+90+80 = 425
SELECT SUM(score) FROM score;
-- AVG:忽略NULL值,计算5个非NULL值的平均(425/5=85),不是6条记录的平均
SELECT AVG(score) FROM score;
-- 处理NULL值:将NULL视为0计算平均
SELECT AVG(IFNULL(score, 0)) FROM score; -- (425 + 0)/6 ≈ 70.83
2.2.3 MAX/MIN:忽略NULL值,支持非数值类型
MAX和MIN不仅支持数值类型,还支持字符串、日期类型,自动忽略NULL值。
sql
-- 数值类型:查询最高、最低分数
SELECT MAX(score) AS max_score, MIN(score) AS min_score FROM score;
-- 字符串类型:查询姓名排序最前、最后的学生(按字典序)
SELECT MAX(name) AS max_name, MIN(name) AS min_name FROM student;
-- 日期类型:查询最早、最晚的考试日期
SELECT MAX(exam_date) AS latest_date, MIN(exam_date) AS earliest_date FROM score;
2.3 HAVING 与 WHERE 区别(核心重点)
很多人混淆HAVING和WHERE,核心区别只有一个:WHERE 过滤行,HAVING 过滤分组,具体差异如下:
| 对比项 | WHERE | HAVING |
|---|---|---|
| 作用对象 | 单个行数据 | 分组后的结果集 |
| 使用时机 | 分组(GROUP BY)之前 | 分组(GROUP BY)之后 |
| 支持的条件 | 普通字段、表达式,不支持聚合函数 | 聚合函数、分组字段,支持普通字段(不推荐) |
示例(对比使用):
sql
-- 需求1:查询数学科目中,分数大于80分的学生,按学号分组,统计平均成绩(先过滤行,再分组)
SELECT student_no, AVG(score) AS avg_score
FROM score
WHERE subject = '数学' AND score > 80 -- WHERE:过滤数学科目、分数>80的行
GROUP BY student_no;
-- 需求2:查询每个学生的平均成绩,筛选出平均成绩大于85分的学生(先分组,再过滤分组)
SELECT student_no, AVG(score) AS avg_score
FROM score
GROUP BY student_no
HAVING avg_score > 85; -- HAVING:过滤平均成绩>85的分组
-- 错误示例:WHERE中使用聚合函数(会报错)
SELECT student_no, AVG(score) AS avg_score
FROM score
WHERE AVG(score) > 85 -- 报错:Invalid use of group function
GROUP BY student_no;
2.4 分组后排序、分页
实际开发中,分组后常需要对分组结果排序(如按平均成绩降序)、分页(如只显示前2个分组),核心是"GROUP BY 之后,用ORDER BY 排序,LIMIT 分页"。
sql
-- 示例:查询每个学生的平均成绩,按平均成绩降序排序,分页显示前2条
SELECT s.name, s.class, AVG(sc.score) AS avg_score
FROM student s
JOIN score sc ON s.student_no = sc.student_no
GROUP BY s.student_no, s.name, s.class
ORDER BY avg_score DESC -- 分组后排序
LIMIT 0, 2; -- 分页(从第0条开始,取2条)
-- 执行结果:显示平均成绩最高的2个学生
-- name class avg_score
-- 张三 1班 90.0000
-- 王五 2班 90.0000
2.5 去重统计:COUNT(DISTINCT xx)
用于"统计某字段不重复的行数",是业务中高频需求(如统计有多少个不同的学生参加了考试、有多少个不同的科目)。
sql
-- 示例1:统计有多少个不同的学生参加了考试(去重)
SELECT COUNT(DISTINCT student_no) AS student_count FROM score;
-- 示例2:统计有多少个不同的科目(去重)
SELECT COUNT(DISTINCT subject) AS subject_count FROM score;
-- 示例3:统计每个班级有多少个不同的学生参加了考试
SELECT s.class, COUNT(DISTINCT s.student_no) AS student_count
FROM student s
JOIN score sc ON s.student_no = sc.student_no
GROUP BY s.class;
避坑点:COUNT(DISTINCT 字段1, 字段2) 表示"两个字段同时去重",只有两个字段都相同才视为重复。
sql
-- 统计不同的"学生+科目"组合数(一个学生选多个科目,视为不同组合)
SELECT COUNT(DISTINCT student_no, subject) AS combo_count FROM score;
三、窗口函数:进阶必备,面试高频(重点难点)
窗口函数是MySQL 8.0+ 新增的核心功能,也是进阶阶段的重点,能轻松解决"TopN、连续登录、环比同比、排名"等复杂业务场景,替代复杂的子查询和自连接,代码更简洁、可读性更高。
核心定义:窗口函数 = 聚合函数 / 排序函数 + OVER(窗口子句),其中"窗口子句"用于定义"分析的范围"(如按哪个字段分组、排序)。
窗口子句常用语法:OVER(PARTITION BY 分组字段 ORDER BY 排序字段 [ROWS/RANGE 范围])
说明:PARTITION BY 相当于"分组但不聚合"(分组后每个组内的每行都保留),ORDER BY 用于组内排序,ROWS/RANGE 用于指定窗口范围(默认无需手动指定)。
3.1 排序类窗口函数:ROW_NUMBER() / RANK() / DENSE_RANK()
三者都是用于"组内排序、排名",核心区别在于"处理并列排名的方式",用示例一次性讲清楚(基于score表,按科目分组,对分数排序)。
sql
-- 示例:按科目分组,对每个科目的分数进行排名,对比三种函数
SELECT
subject,
student_no,
score,
ROW_NUMBER() OVER(PARTITION BY subject ORDER BY score DESC) AS row_num,
RANK() OVER(PARTITION BY subject ORDER BY score DESC) AS rnk,
DENSE_RANK() OVER(PARTITION BY subject ORDER BY score DESC) AS dense_rnk
FROM score;
-- 执行结果(以数学科目为例,分数92、80、75):
-- subject student_no score row_num rnk dense_rnk
-- 数学 2024001 92 1 1 1
-- 数学 2024005 80 2 2 2
-- 数学 2024002 75 3 3 3
-- 若新增一条数学分数92的记录(student_no=2024006),结果变化:
-- subject student_no score row_num rnk dense_rnk
-- 数学 2024001 92 1 1 1
-- 数学 2024006 92 2 1 1
-- 数学 2024005 80 3 3 2
-- 数学 2024002 75 4 4 3
三者区别总结:
-
ROW_NUMBER():不处理并列,即使分数相同,排名也不重复(1、2、3、4);
-
RANK():处理并列,并列的排名相同,后续排名跳过(1、1、3、4);
-
DENSE_RANK():处理并列,并列的排名相同,后续排名不跳过(1、1、2、3)。
实战场景:TopN问题(每个科目取分数前2名)
sql
-- 方法1:用ROW_NUMBER()(分数相同,只取前2个,不管并列)
SELECT * FROM (
SELECT
subject,
student_no,
score,
ROW_NUMBER() OVER(PARTITION BY subject ORDER BY score DESC) AS row_num
FROM score
) t
WHERE t.row_num <= 2;
-- 方法2:用RANK()(分数相同,并列排名,可能超过2条)
SELECT * FROM (
SELECT
subject,
student_no,
score,
RANK() OVER(PARTITION BY subject ORDER BY score DESC) AS rnk
FROM score
) t
WHERE t.rnk <= 2;
3.2 分布类窗口函数:NTILE() / PERCENT_RANK()
用于"将数据分组、计算百分比排名",适用于"分档次、占比分析"场景。
3.2.1 NTILE(n):将组内数据分为n个档次
核心:将每个分组内的行,平均分为n个部分,返回每个行所在的档次(若无法平均分,前面的档次会多1行)。
sql
-- 示例:按科目分组,将每个科目的分数分为3个档次(优秀、良好、及格)
SELECT
subject,
student_no,
score,
NTILE(3) OVER(PARTITION BY subject ORDER BY score DESC) AS level
FROM score;
-- 执行结果(数学科目4条记录,分为3档:第1档1行,第2档1行,第3档2行):
-- subject student_no score level
-- 数学 2024001 92 1
-- 数学 2024006 92 2
-- 数学 2024005 80 3
-- 数学 2024002 75 3
3.2.2 PERCENT_RANK():计算百分比排名
核心:返回当前行在组内的百分比排名,公式:(当前排名 - 1) / (组内总行数 - 1),结果范围0~1(0表示最低,1表示最高)。
sql
-- 示例:按科目分组,计算每个学生分数的百分比排名
SELECT
subject,
student_no,
score,
RANK() OVER(PARTITION BY subject ORDER BY score DESC) AS rnk,
PERCENT_RANK() OVER(PARTITION BY subject ORDER BY score DESC) AS pct_rank
FROM score;
-- 执行结果(数学科目4条记录,组内总行数=4):
-- subject student_no score rnk pct_rank
-- 数学 2024001 92 1 0.0000 -- (1-1)/(4-1)=0
-- 数学 2024006 92 1 0.0000 -- (1-1)/(4-1)=0
-- 数学 2024005 80 3 0.6667 -- (3-1)/(4-1)≈0.6667
-- 数学 2024002 75 4 1.0000 -- (4-1)/(4-1)=1
3.3 偏移类窗口函数:LAG() / LEAD()
用于"获取当前行的前n行(LAG)或后n行(LEAD)的数据",无需自连接,就能实现"连续数据对比"(如环比、连续登录判断)。
语法:LAG(字段名, n, 默认值) / LEAD(字段名, n, 默认值),n表示"偏移n行",默认值表示"无数据时返回的值"(默认NULL)。
实战场景1:查询每个学生的当前科目分数,以及上一个科目、下一个科目的分数
sql
SELECT
student_no,
subject,
score,
LAG(score, 1, 0) OVER(PARTITION BY student_no ORDER BY subject) AS prev_score, -- 上一个科目分数
LEAD(score, 1, 0) OVER(PARTITION BY student_no ORDER BY subject) AS next_score -- 下一个科目分数
FROM score
WHERE student_no = '2024001'; -- 只看张三的记录
-- 执行结果:
-- student_no subject score prev_score next_score
-- 2024001 数学 92 0 88
-- 2024001 语文 88 92 0
-- 2024001 英语 NULL 88 0
实战场景2:判断学生是否连续两天参加考试(假设exam_date为考试日期)
sql
-- 示例:查询每个学生的考试日期,判断是否连续
SELECT
student_no,
exam_date,
LAG(exam_date, 1) OVER(PARTITION BY student_no ORDER BY exam_date) AS prev_date,
DATEDIFF(exam_date, LAG(exam_date, 1) OVER(PARTITION BY student_no ORDER BY exam_date)) AS date_diff,
CASE WHEN DATEDIFF(exam_date, LAG(exam_date, 1) OVER(PARTITION BY student_no ORDER BY exam_date)) = 1 THEN '连续' ELSE '不连续' END AS is_continuous
FROM score;
-- 执行逻辑:用LAG获取上一次考试日期,计算两次日期差,若差为1则连续
-- 执行结果(假设张三两天连续考试):
-- student_no exam_date prev_date date_diff is_continuous
-- 2024001 2024-06-10 NULL NULL 不连续
-- 2024001 2024-06-11 2024-06-10 1 连续
3.4 聚合窗口:SUM() OVER() / AVG() OVER()
将聚合函数(SUM、AVG等)与窗口函数结合,用于"计算累计值、移动平均值",无需分组,保留每行数据,同时显示聚合结果。
实战场景1:计算每个科目的分数累计和
sql
SELECT
subject,
student_no,
score,
SUM(score) OVER(PARTITION BY subject ORDER BY score DESC) AS cumulative_sum
FROM score;
-- 执行结果(数学科目):
-- subject student_no score cumulative_sum
-- 数学 2024001 92 92
-- 数学 2024006 92 184 -- 92+92
-- 数学 2024005 80 264 -- 184+80
-- 数学 2024002 75 339 -- 264+75
实战场景2:计算每个学生的分数移动平均值(前1行+当前行+后1行的平均)
sql
SELECT
student_no,
subject,
score,
AVG(score) OVER(PARTITION BY student_no ORDER BY subject ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) AS moving_avg
FROM score
WHERE student_no = '2024001';
-- 说明:ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING 表示"窗口范围为前1行、当前行、后1行"
-- 执行结果:
-- student_no subject score moving_avg
-- 2024001 数学 92 90.0000 -- (92+88)/2(无前1行,只取当前和后1行)
-- 2024001 语文 88 88.0000 -- (92+88+NULL)/2(NULL自动忽略,取前1行和当前行)
-- 2024001 英语 NULL 88.0000 -- (88+NULL)/2(无后1行,只取前1行)
3.4.1 聚合窗口与普通聚合的区别
很多人会混淆"聚合窗口函数"和"普通聚合函数",核心区别在于:
-
普通聚合函数(SUM、AVG等)+ GROUP BY:会将分组后的数据合并为一行,只返回聚合结果;
-
聚合窗口函数 + OVER():不会合并行,保留每行原始数据,同时在每行后追加聚合结果(按窗口范围计算)。
sql
-- 普通聚合(GROUP BY):按学生分组,只返回每个学生的平均成绩(一行一个学生)
SELECT student_no, AVG(score) AS avg_score
FROM score
WHERE student_no = '2024001'
GROUP BY student_no;
-- 聚合窗口:保留每行成绩,同时追加该学生的平均成绩(一行一个科目)
SELECT
student_no,
subject,
score,
AVG(score) OVER(PARTITION BY student_no) AS avg_score
FROM score
WHERE student_no = '2024001';
3.5 窗口函数的注意事项(避坑重点)
窗口函数虽好用,但使用时容易踩坑,以下4个注意事项必须牢记:
- 窗口函数只能用于 SELECT 和 ORDER BY 子句,不能用于 WHERE、GROUP BY、HAVING 子句(因为窗口函数是在这些子句执行之后才执行的);
sql
-- 错误示例:WHERE中使用窗口函数(会报错)
SELECT student_no, subject, score, ROW_NUMBER() OVER(PARTITION BY subject ORDER BY score DESC) AS row_num
FROM score
WHERE row_num <= 2;
-- 正确示例:先子查询获取窗口函数结果,再在WHERE中过滤
SELECT * FROM (
SELECT student_no, subject, score, ROW_NUMBER() OVER(PARTITION BY subject ORDER BY score DESC) AS row_num
FROM score
) t
WHERE t.row_num <= 2;
-
窗口函数的执行顺序:FROM → WHERE → GROUP BY → HAVING → SELECT(窗口函数)→ ORDER BY → LIMIT;
-
PARTITION BY 可省略,省略后表示"整个结果集作为一个窗口"(不分组,对所有数据进行分析);
sql
-- 省略PARTITION BY,对所有成绩进行排名
SELECT
student_no,
subject,
score,
ROW_NUMBER() OVER(ORDER BY score DESC) AS global_row_num
FROM score;
- MySQL 8.0+ 才支持窗口函数,若使用5.7及以下版本,需用子查询、自连接替代(如TopN问题用子查询+LIMIT)。
四、CTE(公共表表达式):简化复杂查询,提升可读性
CTE(Common Table Expression,公共表表达式)是MySQL 8.0+ 新增的功能,用于"定义临时结果集",可以替代复杂的子查询嵌套,让SQL代码更简洁、可读性更高,尤其适合多次复用同一子查询结果的场景。
核心语法:WITH 临时表名 AS (子查询),然后在后续查询中使用该临时表。
4.1 基本用法:单CTE
示例:用CTE查询每个学生的平均成绩,筛选出平均成绩大于85分的学生(替代表子查询):
sql
-- 定义CTE(临时表t,存储每个学生的平均成绩)
WITH t AS (
SELECT student_no, AVG(score) AS avg_score
FROM score
GROUP BY student_no
)
-- 使用CTE,和student表连接筛选
SELECT t.student_no, s.name, t.avg_score
FROM t
JOIN student s ON t.student_no = s.student_no
WHERE t.avg_score > 85;
-- 等价于之前的表子查询,但代码更简洁,可读性更高
4.2 进阶用法:多CTE、嵌套CTE
-
多CTE:用逗号分隔多个CTE,可相互引用(前一个CTE可被后一个CTE使用);
-
嵌套CTE:在CTE内部再定义CTE,适用于更复杂的查询场景。
sql
-- 多CTE示例:查询每个科目分数前2名的学生信息(结合CTE和窗口函数)
WITH
-- 第一个CTE:给每个科目的分数排名
score_rank AS (
SELECT
subject,
student_no,
score,
ROW_NUMBER() OVER(PARTITION BY subject ORDER BY score DESC) AS row_num
FROM score
),
-- 第二个CTE:筛选出每个科目前2名的记录
top2_score AS (
SELECT * FROM score_rank WHERE row_num <= 2
)
-- 使用第二个CTE,关联student表获取学生信息
SELECT t.subject, t.student_no, s.name, s.class, t.score
FROM top2_score t
LEFT JOIN student s ON t.student_no = s.student_no;
-- 嵌套CTE示例:在CTE内部嵌套CTE
WITH parent_cte AS (
-- 内部嵌套CTE,查询高一学生的学号
WITH child_cte AS (
SELECT student_no FROM student WHERE grade = '高一'
)
-- 父CTE使用子CTE的结果,查询高一学生的成绩
SELECT sc.student_no, sc.subject, sc.score
FROM score sc
JOIN child_cte c ON sc.student_no = c.student_no
)
-- 使用父CTE,统计高一学生各科目平均分
SELECT subject, AVG(score) AS avg_score
FROM parent_cte
GROUP BY subject;
4.3 CTE与子查询、临时表的区别
| 对比项 | CTE | 子查询 | 临时表 |
|---|---|---|---|
| 可读性 | 高,代码结构化,可复用 | 低,嵌套过深易混乱 | 中等,需手动创建和删除 |
| 复用性 | 高,可在同一查询中多次引用 | 低,每次使用需重复编写 | 高,可在整个会话中复用 |
| 性能 | 和子查询基本一致(MySQL优化后) | 嵌套过深会影响性能 | 性能较好,但创建和删除有开销 |
选择原则:简单查询用子查询,复杂查询、需要复用临时结果集时,优先用CTE(简洁易维护);需要跨会话复用时,用临时表。
