《MySQL内核拆解:从SQL到InnoDB》(四) : 联合查询 & 视图

文章目录

MySQL内核拆解:从SQL到InnoDB》(四) : 联合查询 & 视图

数据按范式拆到多张表之后,任何一条完整的业务信息都得跨表取,于是"怎么把多张表拼起来"成了 SQL 里最见功力的部分。本文从笛卡尔积讲起,依次梳理内连接、外连接、自连接、子查询、合并查询与插入查询结果,每一步都给出可复现的案例。

1、为什么要联合查询

数据设计时为了满足范式,同一份业务信息被拆到了多张表里。要拿到"学生的基本信息 + 他所在的班级",就必须同时查 student 和 class 两张表。

这里的"联合"指的是多个表的组合,不是简单的两次查询拼接。

1.1 MySQL 内部怎么算的

理解执行过程,才能理解为什么会写出慢查询:

  1. 先取笛卡尔积:参与查询的所有表两两组合,结果放进临时表。
  2. 再过滤无效数据:按两张表的关联关系,把没有对应关系的行剔除。

所以 8 个学生 × 3 个班级 = 24 行的临时表,过滤后才剩 8 行有效记录。参与的表越多、表里数据量越大,中间那张临时表就越膨胀------这就是"别在一条 SQL 里连七八张表"的根本原因。

2、案例数据

后面所有示例都基于这四张表:

sql 复制代码
-- 课程表:6 门课
INSERT INTO course (name) VALUES
('Java'), ('C++'), ('MySQL'), ('操作系统'), ('计算机网络'), ('数据结构');

-- 班级表:3 个班
INSERT INTO class (name) VALUES ('Java001班'), ('C++001班'), ('前端001班');

-- 学生表:8 人,1 班 4 人、2 班 4 人,3 班没有学生
INSERT INTO student (name, sno, age, gender, enroll_date, class_id) VALUES
('唐三藏', '100001', 18, 1, '1986-09-01', 1),
('孙悟空', '100002', 18, 1, '1986-09-01', 1),
('猪悟能', '100003', 18, 1, '1986-09-01', 1),
('沙悟净', '100004', 18, 1, '1986-09-01', 1),
('宋江',   '200001', 18, 1, '2000-09-01', 2),
('武松',   '200002', 18, 1, '2000-09-01', 2),
('李逹',   '200003', 18, 1, '2000-09-01', 2),
('不想毕业', '200004', 18, 1, '2000-09-01', 2);

-- 成绩表:20 条,"不想毕业"没有成绩记录
INSERT INTO score (score, student_id, course_id) VALUES
(70.5, 1, 1), (98.5, 1, 3), (33, 1, 5), (98, 1, 6),
(60, 2, 1), (59.5, 2, 5),
(33, 3, 1), (68, 3, 3), (99, 3, 5),
(67, 4, 1), (23, 4, 3), (56, 4, 5), (72, 4, 6),
(81, 5, 1), (37, 5, 5),
(56, 6, 2), (43, 6, 4), (79, 6, 6),
(80, 7, 2), (92, 7, 6);

数据里有三个刻意留下的"坑",后面会反复用到:3 号班级没有学生 、8 号学生没有成绩 、多张表都有 name 和 id 列。

3、联合查询的五个步骤

以"查询学生姓名为孙悟空的详细信息,含班级信息"为例,一条多表查询是逐步搭出来的。

第一步:确定参与的表

sql 复制代码
SELECT * FROM student, class;      -- 8 × 3 = 24 行

FROM 后把所有表用逗号隔开,此时拿到的就是笛卡尔积,每行学生都被配上了每个班级。

第二步:加连接条件

sql 复制代码
SELECT * FROM student, class WHERE student.class_id = class.id;   -- 8 行

student.class_id 指向 class.id,加上这个等式之后,24 行里只剩 8 行是有效的。

第三步:加查询条件

sql 复制代码
SELECT * FROM student, class
WHERE student.class_id = class.id AND name = '宋江';
-- ERROR 1052 (23000): Column 'name' in where clause is ambiguous

报错了 。两张表都有 name 列,MySQL 不知道指的是哪个,必须写成 表名.列名:

sql 复制代码
SELECT * FROM student, class
WHERE student.class_id = class.id AND student.name = '宋江';   -- 1 行

第四步:精减查询字段

sql 复制代码
SELECT student.id, student.name, student.sno, student.age,
       student.gender, student.enroll_date, class.name
FROM student, class
WHERE student.class_id = class.id AND student.name = '宋江';

SELECT * 会把两张表的全部列都带出来,包含重复的 id 和 name,实际业务里只需要其中几列。明确列出字段还能减少传输量、避免列名冲突。

第五步:为表起别名

sql 复制代码
SELECT s.id, s.name, s.sno, s.age, s.gender, s.enroll_date, c.name
FROM student s, class c
WHERE s.class_id = c.id AND s.name = '宋江';

表名写成 s、c 之后,整条 SQL 短了一大截。别名在一条语句内有效,自连接时更是必不可少。

4、内连接

内连接就是上面那套写法的正式形式,两种语法完全等价:

sql 复制代码
-- 写法一:隐式内连接(逗号 + where)
SELECT 字段 FROM 表1 别名1, 表2 别名2 WHERE 连接条件 AND 其他条件;

-- 写法二:显式内连接(join ... on)
SELECT 字段 FROM 表1 别名1 [INNER] JOIN 表2 别名2 ON 连接条件 WHERE 其他条件;

INNER 可以省略,JOIN 默认就是内连接。

4.1 查某个学生的成绩

sql 复制代码
SELECT s.name, sc.score
FROM student s JOIN score sc ON sc.student_id = s.id
WHERE s.name = '唐三藏';

结果 4 行:70.5 / 98.5 / 33 / 98。

4.2 查所有同学的总成绩

sql 复制代码
SELECT s.name, SUM(sc.score)
FROM student s, score sc
WHERE sc.student_id = s.id
GROUP BY s.id;

结果 7 行,唐三藏 300、孙悟空 119.5、猪悟能 200......

这里 GROUP BY s.id 只起到"按学号把同一个人的多门成绩并成一行"的作用,重点看结果为什么是 7 行而不是 8 行 ------"不想毕业"没有成绩记录,内连接直接把他丢了。

4.3 查所有同学每门课的成绩

sql 复制代码
SELECT s.id AS id, s.name AS 姓名, s.sno AS 学号,
       s.gender AS 性别, c.name AS 班级, sc.score AS 分数
FROM student s, course c, score sc
WHERE s.id = sc.student_id
  AND c.id = sc.course_id
ORDER BY s.id;

20 行结果。同样没有"不想毕业"------这条查询只返回"有成绩的记录",不是"所有学生"。要保留无成绩的学生,就得用外连接。

5、外连接

外连接解决的就是"另一张表里没有匹配记录"的问题:

类型 保留哪边 无匹配时
左外连接 LEFT JOIN 左表全部 右表字段填 NULL
右外连接 RIGHT JOIN 右表全部 左表字段填 NULL
全外连接 FULL JOIN 两边全部 两侧都可能为 NULL

MySQL 不支持全外连接 ,需要时用左连接 + 右连接再 UNION 拼出来。

sql 复制代码
-- 左外连接,表1 完全显示
SELECT 字段 FROM 表1 LEFT JOIN 表2 ON 连接条件;

-- 右外连接,表2 完全显示
SELECT 字段 FROM 表1 RIGHT JOIN 表2 ON 连接条件;

5.1 查没有参加考试的同学

sql 复制代码
SELECT s.*
FROM student s LEFT JOIN score sc ON sc.student_id = s.id
WHERE sc.score IS NULL;

思路是"左连接 + 判空":先用左连接保留全部学生,没有成绩的那位在右表侧全是 NULL,再用 WHERE sc.score IS NULL 把其他人滤掉。结果是 8 号"不想毕业"。

这个"左连接 + IS NULL"是找差集的通用套路 :想找"A 里有、B 里没有"的记录,就用 A 左连 B,然后过滤 B 的主键或关键字段为 NULL。注意判断的字段要选右表中一定不为空的列 (比如主键),用可能本身为空的 score 列会误判。

5.2 查没有学生的班级

sql 复制代码
SELECT c.*
FROM student s RIGHT JOIN class c ON c.id = s.class_id
WHERE s.id IS NULL;

右连接保留全部班级,3 号"前端001班"没有学生,左表侧全为 NULL,过滤后得到它。

6、自连接

自连接是同一张表跟自己取笛卡尔积,用途有两个:把行转成列、实现行与行之间的比较。

做自连接时必须给表起不同的别名,否则直接报错:

sql 复制代码
SELECT * FROM score, score;
-- ERROR 1066 (42000): Not unique table/alias: 'score'

SELECT * FROM score s1, score s2;   -- 正确

6.1 查 MySQL 成绩比 Java 成绩高的记录

课程 id 里 Java = 1、MySQL = 3,所以要让"课程 3 的分数"大于"课程 1 的分数",本质是把同一行学生的两个分数拉出来比较:

sql 复制代码
SELECT s1.*
FROM score s1, score s2
WHERE s1.student_id = s2.student_id
  AND s1.course_id = 3
  AND s2.course_id = 1
  AND s1.score > s2.score;

结果两行:唐三藏 98.5、猪悟能 68。

写成不用硬编码课程 id 的版本:

sql 复制代码
SELECT s1.*
FROM score s1, score s2, course c1, course c2
WHERE s1.student_id = s2.student_id
  AND s1.course_id = c1.id
  AND s2.course_id = c2.id
  AND s1.score > s2.score
  AND c1.`name` = 'MySQL'
  AND c2.`name` = 'Java';

四张表里两张是同一张 score 的两个别名,另外两张是 course 的两个别名。name 加反引号是因为它是常见列名,用反引号可避免与关键字冲突。

6.2 再叠加学生和班级信息

在自连接基础上继续 join,就能把姓名、班级一起带出来:

sql 复制代码
SELECT stu.name AS 姓名, c.name AS 班级,
       s1.score AS MySQL分数, s2.score AS Java分数
FROM score s1, score s2, course c1, course c2, student stu, class c
WHERE s1.student_id = s2.student_id
  AND s1.course_id = c1.id
  AND s2.course_id = c2.id
  AND s1.score > s2.score
  AND stu.id = s1.student_id
  AND stu.class_id = c.id
  AND c1.`name` = 'MySQL'
  AND c2.`name` = 'Java';
姓名 班级 MySQL分数 Java分数
唐三藏 Java001班 98.5 70.5
猪悟能 Java001班 68 33

六张表、两次自连接------写这种查询的关键是把每张表的别名和连接条件一一对应,别指望一次写对,先分步验证再合并。

7、子查询

子查询是把一个 SELECT 的结果当作另一个 SELECT 的条件,也叫嵌套查询:

sql 复制代码
SELECT * FROM table1 WHERE col_name1 {= | IN} (
  SELECT col_name1 FROM table2 WHERE col_name2 {= | IN} [(SELECT ...)]
);

7.1 单行子查询

内层只返回一行数据,用 =:

sql 复制代码
SELECT * FROM student
WHERE class_id = (SELECT class_id FROM student WHERE name = '不想毕业');

结果是 2 班的 4 位同学(宋江、武松、李逹、不想毕业)。

7.2 多行子查询

内层返回多行,必须用 IN / NOT IN,用 = 会报 Subquery returns more than 1 row:

sql 复制代码
-- 查 Java 或 MySQL 两门课的成绩
SELECT * FROM score
WHERE course_id IN (SELECT id FROM course WHERE name = 'Java' OR name = 'MySQL');

-- 取反
SELECT * FROM score
WHERE course_id NOT IN (SELECT id FROM course WHERE name = 'Java' OR name = 'MySQL');

7.3 多列子查询

内层返回多列时,外层要用括号把列打包,两边的列数和顺序必须一一对应:

sql 复制代码
SELECT * FROM score
WHERE (score, student_id, course_id) IN (
  SELECT score, student_id, course_id
  FROM score
  GROUP BY score, student_id, course_id
  HAVING COUNT(0) > 1
);

这段查的是"重复录入的分数":内层先找出"分数 + 学号 + 课程"三元组出现次数大于 1 的组合,外层再回表把这些组合对应的所有行取出来,所以重复的两条都能看到。

7.4 FROM 子句里的子查询

查询产生结果集时,MySQL 会自动建一张临时表存放它。FROM 后面的子查询就是把这临时表当成一张普通表来用,此时必须给别名:

sql 复制代码
-- 先算出 Java001班的平均分:66.65625
SELECT AVG(sc.score) FROM student s
JOIN class c ON s.class_id = c.id
JOIN score sc ON s.id = sc.student_id
WHERE c.name = 'Java001班';

-- 再把结果当临时表,与真实表比较
SELECT * FROM score s, (
  SELECT AVG(sc.score) score FROM student s
  JOIN class c ON s.class_id = c.id
  JOIN score sc ON s.id = sc.student_id
  WHERE c.name = 'Java001班'
) tmp
WHERE s.score > tmp.score;

tmp 是临时表的别名,不能省 ,否则报 Every derived table must have its own alias。

8、合并查询

UNION 用于把多个 SELECT 的结果纵向拼起来:

sql 复制代码
SELECT * FROM student WHERE id < 3
UNION
SELECT * FROM student1;
操作符 是否去重 说明
UNION 去重 完全相同的行只保留一条
UNION ALL 不去重 全部保留,速度快

示例里 student 表和 student1 表中都有唐三藏,用 UNION 只返回 1 条,用 UNION ALL 返回 2 条。

两个前提条件 :参与合并的每个 SELECT 必须列数相同 、对应列类型兼容 。UNION 因为要排序去重,通常会比 UNION ALL 慢;确定不会有重复(或不在乎重复)时优先用 UNION ALL。

9、插入查询结果

把 SELECT 的结果直接写进另一张表:

sql 复制代码
INSERT INTO table_name [(column, ...)] SELECT ...;
sql 复制代码
-- 把 C++001班的学生复制到 student1
INSERT INTO student1 (name, sno, age, gender, enroll_date, class_id)
SELECT s.name, s.sno, s.age, s.gender, s.enroll_date, s.class_id
FROM student s, class c
WHERE s.class_id = c.id AND c.name = 'C++001班';

要点:列的顺序必须与 SELECT 的输出顺序一致 ,而不是跟原表列顺序一致。这里没有插 id,自增主键会自动生成。

一条四表连接的 SQL 又长又难记,每次查都得复制一遍。视图就是给这段查询起个名字存进数据库,之后像查表一样查它。本文讲清视图到底是什么、怎么建、能改到什么程度、哪些视图改不了。

10、视图

10.1、视图是什么

视图是一个虚拟的表,它基于一个或多个基本表(或其他视图)的查询结果集。三个关键点:

  • 本身不存储数据 ,每次访问时通过执行查询动态生成;
  • 不占用物理存储空间,占的只是一个查询定义;
  • 物理上它完全依赖于基础表里的数据,基表变了视图立刻跟着变。

用一句话区分:真实表存的是数据,视图存的是查询。

对比项 真实表 视图
本质 物理表 虚拟表(存下来的查询)
是否存数据 存 不存,访问时动态生成
占用空间 占磁盘 只存定义,几乎不占空间
数据来源 自身 一个或多个基表 / 视图
能否增删改 可以 有条件可以(见第五节)
创建语句 CREATE TABLE CREATE VIEW ... AS SELECT ...

10.2、创建视图

sql 复制代码
CREATE VIEW view_name [(column_list)] AS select_statement;

column_list 可选。不写时,视图的列名就取 SELECT 里的列名或别名。

10.2、 不给列清单,靠别名定列名

把"学生 + 班级 + 课程 + 成绩"的四表连接封装成一个视图:

sql 复制代码
CREATE VIEW v_student_socre AS
SELECT s.id, s.name, s.sno, s.age, s.gender, s.enroll_date,
       c.id AS class_id,    c.`name` AS class_name,
       co.id AS course_id,  co.`name` AS course_name,
       sc.id AS score_id,   sc.score
FROM student s, class c, course co, score sc
WHERE s.class_id = c.id
  AND sc.student_id = s.id
  AND sc.course_id = co.id
ORDER BY s.id;

每个重名列都必须起别名 。student、class、course、score 四张表都有 id,class 和 course 还都有 name,不起别名建视图会直接失败(Duplicate column name 'id')。这也是 AS class_id、AS course_name 这些别名存在的真正原因------不是为了好看,是必须。

10.3 显式给出列清单

sql 复制代码
CREATE VIEW v_student_socre_v1
(id, name, sno, age, gender, enroll_date,
 class_id, class_name, course_id, course_name, score_id, score) AS
SELECT s.id, s.name, s.sno, s.age, s.gender, s.enroll_date,
       c.id, c.`name`, co.id, co.`name`, sc.id, sc.score
FROM student s, class c, course co, score sc
WHERE s.class_id = c.id
  AND sc.student_id = s.id
  AND sc.course_id = co.id;

column_list 的列数必须与 SELECT 的输出列数完全一致,且按顺序一一对应,否则建视图就会报错。

注意这两个视图唯一的实质差别 :v_student_socre 结尾有 ORDER BY s.id,v_student_socre_v1 没有。这个差别在第五节的 UPDATE 里会变成一个报错。

10.4、使用视图

建好之后,视图和普通表用法完全相同:

sql 复制代码
SELECT * FROM v_student_socre;
SELECT * FROM v_student_socre_v1;

视图还能参与连接,跟真实表并列写在 FROM 里:

sql 复制代码
SELECT * FROM v_student_total_points v, student s WHERE v.id = s.id;

10.5、 视图的核心价值:把列锁死

再看一个聚合视图,只保留姓名和总分:

sql 复制代码
CREATE VIEW v_student_total_points AS
SELECT s.id, s.name, SUM(sc.score) total
FROM student s, score sc
WHERE s.id = sc.student_id
GROUP BY s.id
ORDER BY s.id;
sql 复制代码
SELECT * FROM v_student_total_points;
name total
唐三藏 469
孙悟空 179.5
猪悟能 200
沙悟净 218
宋江 118
武松 178
李逹 172

用真实表查时,任何时候都能顺手加个学号字段;用视图查就只能拿到定义里那几个列,想加也加不上。 这不是限制,正是视图做权限隔离的原理------想藏起来的列,从视图里就不出现。

两处需要说明的地方(下文结论来自对课件数据的核对):

  1. 课件这张结果表只列了 name、total 两列,但视图定义写的是 s.id, s.name, SUM(...),且后面 v.id = s.id 的连接也用到了 id,所以实际结果应为三列,示例表格漏了这一列。
  2. 唐三藏总分 469 比上一篇《多表查询》里的 300 多了 169。原因是上一节插入过重复成绩(70.5、98.5、60 三条),唐三藏多出 70.5 + 98.5 = 169,300 + 169 = 469。后面 UPDATE ... WHERE student_id = 1 AND course_id = 1 显示影响 2 行,也印证了这两条重复记录。不是笔误,是课件里那批重复数据造成的。

10.6、修改数据:双向影响

视图不是快照,改哪边另一边都会跟着变。

10.7、 改基表 → 视图自动变

sql 复制代码
-- 把唐三藏的 Java 成绩改成 99
UPDATE score SET score = 99 WHERE student_id = 1 AND course_id = 1;
-- Query OK, 2 rows affected    ← 2 行,还是因为那份成绩有重复记录

SELECT * FROM v_student_socre;   -- 视图里对应的行已经变成 99

10.8、 改视图 → 基表跟着变

sql 复制代码
-- 改带 ORDER BY 的那个视图
UPDATE v_student_socre SET score = 99 WHERE score_id = 3;
-- ERROR 1221 (HY000): Incorrect usage of UPDATE and ORDER BY

-- 换不带 ORDER BY 的版本
UPDATE v_student_socre_v1 SET score = 99 WHERE score_id = 3;
-- Query OK, 1 row affected

-- 回基表验证,确实已改
SELECT * FROM score WHERE student_id = 1 AND course_id = 5;

同一个查询、同一批数据,只因为视图定义里多了一句 ORDER BY,一个改得了一个改不了。

这条报错值得单独记住:建视图时随手写的 ORDER BY 会让视图失去可更新性。排序需求应该留给查询时在外层写,视图定义里不要带。

10.9、哪些视图不能改

修改视图本质上是在改基表,所以凡是"一行视图对应不到一行基表"的视图,都无法更新:

情形 为什么改不了
用了聚合函数 SUM / COUNT / AVG / MAX / MIN 一行是多个基表行算出来的,改哪个说不清
用了 DISTINCT 去重后行与基表不再一一对应
用了 GROUP BY 或 HAVING 分组结果是计算出来的,不是某一行
用了 UNION / UNION ALL 一行的来源可能是任意一个子查询
查询列表里带子查询 该列的值不是基表某一列的直接映射
FROM 里引用了不可更新的视图 底层本身就已经不可更新

不可更新 ≠ 不能用 。这些视图照样能 SELECT,只是不能 INSERT / UPDATE / DELETE,改数据必须回到基表。上面那个 v_student_total_points 就属于这一类。

10.10、删除视图

sql 复制代码
DROP VIEW view_name;

删除视图只是删掉那份定义,基表和基表里的数据完全不受影响(与删表截然不同)。

补充两句实用写法:DROP VIEW IF EXISTS v_xxx; 在视图不存在时不会报错,脚本里可以放心重复执行;想确认视图定义,用 SHOW CREATE VIEW v_xxx; 或查 information_schema.views。

10.11、视图的优点

优点 说明
简单性 把复杂的多表连接封装成一次简单查询,使用者不必了解底层逻辑
安全性 隐藏敏感列。例如用户表建一个不含密码列的视图,普通账号只授视图权限,看不到原表
逻辑数据独立性 基表结构变化时只改视图定义,依赖视图的应用程序无需改动,实现应用与数据库解耦
重命名列 视图可以自定义列名,把 sno、enroll_date 换成更易读的名字

注意:

  1. 视图不存数据:每次查询都要重跑定义里的 SQL,复杂视图被频繁访问就是性能负担。MySQL 没有物化视图,别拿它当缓存用。
  2. ORDER BY 会毁掉可更新性:视图定义里带排序,UPDATE 直接报 1221,排序留给外层查询写。
  3. 聚合视图只读 :带 SUM、GROUP BY 的视图只能查,改数据必须回基表。
  4. 视图把列锁死了:想多查一个字段只能改视图定义,或直接查基表------这是安全性的代价。
  5. 改视图等于改基表 :UPDATE v_xxx 落到真实数据上,授予视图写权限时要和授表权限一样谨慎。
  6. 重名列必须起别名 :四张表都有 id,不写 AS class_id 这类别名,建视图时报 Duplicate column name。
  7. column_list 与 SELECT 列数必须一致:数量或顺序错位,建视图阶段就会失败。
  8. 删基表会让视图失效:查该视图时报"表不存在",得删掉视图或重建基表;反过来删视图不影响基表。
  9. 视图不提升查询性能:它只是逻辑封装,执行计划仍按展开后的 SQL 走,别指望用视图优化慢查询。
  10. 视图里的 ORDER BY 不保证最终顺序:外层再排序或再连接时可能被优化掉,需要顺序就在最外层显式写。
相关推荐
码农小河6616 分钟前
基于SSM+JSP的高校专业信息管理系统【附源码+数据库】
java·开发语言·数据库
张小姐的猫20 分钟前
【MySQL】访问 & 数据流动原理
android·数据库·mysql·adb·1024程序员节
这个DBA有点耶4 小时前
信创场景下数据同步软件怎么选?对比开源CDC与原生闭环工具的结构性差异
数据库·mysql·架构
多客:duokelijie5 小时前
代练订单系统选型思路:跨品类游戏接入、订单分层管理、自动结算逻辑拆解!
数据库·mysql·微信小程序·前端框架·uni-app·php·持续部署
PingCAP5 小时前
平凯星辰上榜!2026 年胡润全球独角兽企业
数据库
PingCAP5 小时前
轨道交通 AFC 系统升级,平凯 Loop 把跨部门协同变成可追溯闭环
数据库
java_logo6 小时前
Docker 部署 CloudBeaver:轻松搭建网页数据库管理平台
数据库·docker·容器·数据库管理·dbeaver·轩辕镜像·cloudbeaver
李白客7 小时前
AI 数据库落地 NL2SQL:准确率卡在四成,问题在表注释不在模型
数据库