MySQL不看视频 · SSH 实操

第七单元 · 约束与外键 · 第 16 / 21 节

外键:两张表怎么拴在一起

建 lesson16_fk_parent(3 个部门)与 lesson16_fk_child(4 名员工),看子表填了父表没有的编号报 1452、父表被有引用的行删掉报 1451;再把外键换成 ON DELETE SET NULL + ON UPDATE CASCADE,实测删部门只断关系、改部门号子表跟着改,并说清四种删除/更新行为的取舍。

2 个步骤 · 进度只存本机

约 22 分钟。这一节建一对父子表,先看“子表填了父表没有的编号”会怎样,再看父表被删、被改时子表跟着做什么——这是外键真正难的地方。

SSH / Linux 终端MySQL 终端输出示例(只读)复制按钮只复制命令,不含 $ / mysql> 提示符和输出。

已完成 0 / 2 步

你现在在哪个提示符

应该在 mysql>。断了就先回到学习终端(PowerShell):

SSH / Linux 终端bash
ssh root@8.148.204.152
SSH / Linux 终端bash
mysql-learning

进去以后确认当前库:

MySQL 终端sql
SELECT DATABASE();
输出示例(只读,不要粘贴执行)text不可复制
+------------+
| DATABASE() |
+------------+
| itcast     |
+------------+
1 row in set (0.00 sec)

显示 itcast 就对了。如果是 NULL,执行 USE itcast;——它只切换当前连接用哪个库,不动数据。

第一步:外键就是“只能填父表有的值”

上一节的五类约束都只管自己这一行。第六类不一样:它管两张表之间的关系。

场景很常见:一张部门表,一张员工表。员工的 parent_id 填的是“我在哪个部门”,这个值必须是部门表里真实存在的编号。没有外键,你可以往员工表里写 parent_id = 99,数据库一声不吭,等查“部门 99 的员工”时才发现这是个不存在的部门——这种行叫“孤儿数据”。

先建父表:

MySQL 终端sql
SHOW TABLES LIKE 'lesson16_%';
输出示例(只读,不要粘贴执行)text不可复制
Empty set (0.00 sec)

Empty set 说明可以建。已经有这两张表,跳到本步末尾核对行数,不要删表重建。

MySQL 终端sql
CREATE TABLE lesson16_fk_parent (
  id   INT PRIMARY KEY,
  name VARCHAR(20) NOT NULL,
  CONSTRAINT uk_lesson16_parent_name UNIQUE (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
输出示例(只读,不要粘贴执行)text不可复制
Query OK, 0 rows affected (0.03 sec)
MySQL 终端sql
INSERT INTO lesson16_fk_parent (id, name) VALUES (1, '研发部'), (2, '测试部'), (3, '运营部');
输出示例(只读,不要粘贴执行)text不可复制
Query OK, 3 rows affected (0.01 sec)
Records: 3  Duplicates: 0  Warnings: 0
MySQL 终端sql
SELECT * FROM lesson16_fk_parent ORDER BY id;
输出示例(只读,不要粘贴执行)text不可复制
+----+-----------+
| id | name      |
+----+-----------+
|  1 | 研发部    |
|  2 | 测试部    |
|  3 | 运营部    |
+----+-----------+
3 rows in set (0.00 sec)

再建子表,parent_id 上写外键。注意写法:FOREIGN KEY (子表列) REFERENCES 父表 (父表列),顺序别写反。

MySQL 终端sql
CREATE TABLE lesson16_fk_child (
  id        INT PRIMARY KEY,
  name      VARCHAR(20) NOT NULL,
  parent_id INT,
  CONSTRAINT fk_lesson16_child_parent
    FOREIGN KEY (parent_id) REFERENCES lesson16_fk_parent (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
输出示例(只读,不要粘贴执行)text不可复制
Query OK, 0 rows affected (0.04 sec)
MySQL 终端sql
INSERT INTO lesson16_fk_child (id, name, parent_id) VALUES
  (1, '小林', 1), (2, '小周', 1), (3, '小陈', 2), (4, '小李', NULL);
输出示例(只读,不要粘贴执行)text不可复制
Query OK, 4 rows affected (0.00 sec)
Records: 4  Duplicates: 0  Warnings: 0
MySQL 终端sql
SELECT id, name, parent_id FROM lesson16_fk_child ORDER BY id;
输出示例(只读,不要粘贴执行)text不可复制
+----+--------+-----------+
| id | name   | parent_id |
+----+--------+-----------+
|  1 | 小林   |         1 |
|  2 | 小周   |         1 |
|  3 | 小陈   |         2 |
|  4 | 小李   |      NULL |
+----+--------+-----------+
4 rows in set (0.00 sec)

四行。小李的 parent_id 是 NULL——外键不检查 NULL,NULL 的意思是“没有引用任何部门”,所以能留下。

现在试一次“填了父表没有的编号”:

MySQL 终端sql
INSERT INTO lesson16_fk_child (id, name, parent_id) VALUES (9, '小测试', 99);
输出示例(只读,不要粘贴执行)text不可复制
ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails (`itcast`.`lesson16_fk_child`, CONSTRAINT `fk_lesson16_child_parent` FOREIGN KEY (`parent_id`) REFERENCES `lesson16_fk_parent` (`id`))

1452,99 不在父表里。这条插入什么也没写进去。外键的三条硬要求,缺一条都建不起来:

  1. 父表被引用的列必须是主键或 UNIQUE(否则无法唯一定位一行);
  2. 两边的数据类型要能对上(这里都是 INT);
  3. 子表里已有的数据必须都能在父表找到,否则加约束会失败——所以真实项目里常常要先把脏数据洗掉再加外键。

第二步:父表被删、被改,子表怎么办

外键建好以后,父表那边就不能随便动了。先试默认行为——删掉一个有员工的部门:

MySQL 终端sql
DELETE FROM lesson16_fk_parent WHERE id = 2;
输出示例(只读,不要粘贴执行)text不可复制
ERROR 1451 (23000): Cannot delete or update a parent row: a foreign key constraint fails (`itcast`.`lesson16_fk_child`, CONSTRAINT `fk_lesson16_child_parent` FOREIGN KEY (`parent_id`) REFERENCES `lesson16_fk_parent` (`id`))

1451:测试部下面还有小陈,父表这一行不能删。这个默认行为叫 RESTRICT(拒绝),NO ACTION 是它的同义词(MySQL 里效果一样)。

四种可选行为,写在 ON DELETE / ON UPDATE 后面:

行为父表删一行时父表改主键时
RESTRICT / NO ACTION子表还有引用就拒绝(默认)同上,拒绝
CASCADE子表对应的行一起删掉子表的值跟着改
SET NULL子表外键列置 NULL(该列必须允许 NULL)子表外键列置 NULL
SET DEFAULT设计上置默认值,但 InnoDB 不支持,别在 MySQL 里指望它同左

默认的 RESTRICT 最安全:它逼你先处理子数据,不会因为一次误删把下游一起带走。要看另外两种真的发生,得先把这条外键换掉——ALTER TABLE ... DROP FOREIGN KEY 删的是约束,不是表,也不是数据:

MySQL 终端sql
ALTER TABLE lesson16_fk_child DROP FOREIGN KEY fk_lesson16_child_parent;
输出示例(只读,不要粘贴执行)text不可复制
Query OK, 0 rows affected (0.01 sec)
Records: 0  Duplicates: 0  Warnings: 0

再按“删除置空、更新跟随”重建它:

MySQL 终端sql
ALTER TABLE lesson16_fk_child
  ADD CONSTRAINT fk_lesson16_child_parent
  FOREIGN KEY (parent_id) REFERENCES lesson16_fk_parent (id)
  ON DELETE SET NULL
  ON UPDATE CASCADE;
输出示例(只读,不要粘贴执行)text不可复制
Query OK, 4 rows affected (0.09 sec)
Records: 4  Duplicates: 0  Warnings: 0

现在再删一次测试部:

MySQL 终端sql
DELETE FROM lesson16_fk_parent WHERE id = 2;
输出示例(只读,不要粘贴执行)text不可复制
Query OK, 1 row affected (0.00 sec)

成功了。小陈没有被删掉,只是 parent_id 变成了 NULL:

MySQL 终端sql
SELECT id, name, parent_id FROM lesson16_fk_child ORDER BY id;
输出示例(只读,不要粘贴执行)text不可复制
+----+--------+-----------+
| id | name   | parent_id |
+----+--------+-----------+
|  1 | 小林   |         1 |
|  2 | 小周   |         1 |
|  3 | 小陈   |      NULL |
|  4 | 小李   |      NULL |
+----+--------+-----------+
4 rows in set (0.00 sec)

这就是 SET NULL 与 CASCADE 的分水岭:SET NULL 保留人、只断开关系;如果刚才写的是 ON DELETE CASCADE,删测试部会连小陈这一行一起删掉——数据直接消失,很难救回来。所以“为了删起来方便”选 CASCADE 是很危险的习惯。

再改父表的主键,看 ON UPDATE CASCADE:

MySQL 终端sql
UPDATE lesson16_fk_parent SET id = 10 WHERE id = 1;
输出示例(只读,不要粘贴执行)text不可复制
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0
MySQL 终端sql
SELECT * FROM lesson16_fk_parent ORDER BY id;
输出示例(只读,不要粘贴执行)text不可复制
+----+-----------+
| id | name      |
+----+-----------+
|  3 | 运营部    |
| 10 | 研发部    |
+----+-----------+
2 rows in set (0.00 sec)
MySQL 终端sql
SELECT id, name, parent_id FROM lesson16_fk_child ORDER BY id;
输出示例(只读,不要粘贴执行)text不可复制
+----+--------+-----------+
| id | name   | parent_id |
+----+--------+-----------+
|  1 | 小林   |        10 |
|  2 | 小周   |        10 |
|  3 | 小陈   |      NULL |
|  4 | 小李   |      NULL |
+----+--------+-----------+
4 rows in set (0.00 sec)

研发部的编号从 1 变成 10,小林和小周那两行的 parent_id 自动跟着变成 10——没人手动改子表。这就是 ON UPDATE CASCADE。

最后核对一下两边的行数:

MySQL 终端sql
SELECT COUNT(*) AS 父表行数 FROM lesson16_fk_parent;
输出示例(只读,不要粘贴执行)text不可复制
+--------------+
| 父表行数     |
+--------------+
|            2 |
+--------------+
1 row in set (0.00 sec)
MySQL 终端sql
SELECT COUNT(*) AS 子表行数 FROM lesson16_fk_child;
输出示例(只读,不要粘贴执行)text不可复制
+--------------+
| 子表行数     |
+--------------+
|            4 |
+--------------+
1 row in set (0.00 sec)
MySQL 终端sql
SELECT COUNT(parent_id) AS 有部门的子行 FROM lesson16_fk_child;
输出示例(只读,不要粘贴执行)text不可复制
+--------------------+
| 有部门的子行       |
+--------------------+
|                  2 |
+--------------------+
1 row in set (0.00 sec)

父表 2 行(3、10),子表 4 行,有部门的是 2 行(小林、小周)。小陈和小李的 parent_id 都是 NULL,分别来自“SET NULL 断开的”和“一开始就没填的”。

小结:P36 约束小结

六类约束到这里讲完:NOT NULL 管空值,UNIQUE 管重复(允许多个 NULL),PRIMARY KEY = 唯一 + 非空且一张表只能一个,DEFAULT 管缺省值,CHECK 管取值范围(8.0.16 起生效,且不拦 NULL),FOREIGN KEY 管表间引用。前五种可以建表时写、也可以用 ALTER TABLE 补;外键除了建约束,还要决定父表被删被改时子表怎么办(RESTRICT / CASCADE / SET NULL,InnoDB 不支持 SET DEFAULT)。已有脏数据时要先清洗再加约束,否则 ALTER 会直接失败。约束保证的是“数据符合你声明的规则”,不是“业务永远正确”。

常见报错怎么办

看到意思怎么办
1452 Cannot add or update a child row子表填了父表没有的值先 SELECT 父表核对,或把该列写成 NULL
1451 Cannot delete or update a parent row子表还有引用,父表不能删/改先处理子表数据,或改成 ON DELETE SET NULL / CASCADE(想清楚再改)
说明
MySQL 终端sql
INSERT INTO lesson16_fk_child (id, name, parent_id) VALUES (10, '小测试2', 99);
输出示例(只读,不要粘贴执行)text不可复制
ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails (`itcast`.`lesson16_fk_child`, CONSTRAINT `fk_lesson16_child_parent` FOREIGN KEY (`parent_id`) REFERENCES `lesson16_fk_parent` (`id`) ON DELETE SET NULL ON UPDATE CASCADE)

错误信息里带着库名、表名、约束名和外键名,排查时照着读:itcast.lesson16_fk_child 的 fk_lesson16_child_parent 拒绝了这个 99。

练习

先自己做,再展开提示和对答案。前两题动手(都是查询),后两题只问概念。

1. 每个部门各有几个员工?没有员工的部门也要显示 0。

说明

部门要全部留下:部门在左,用 LEFT JOIN;数人用 COUNT(c.id),不要用 COUNT(*)。

说明
MySQL 终端sql
SELECT p.id AS 部门号, p.name AS 部门, COUNT(c.id) AS 人数
FROM lesson16_fk_parent p
LEFT JOIN lesson16_fk_child c ON c.parent_id = p.id
GROUP BY p.id, p.name
ORDER BY p.id;
输出示例(只读,不要粘贴执行)text不可复制
+-----------+-----------+--------+
| 部门号    | 部门      | 人数   |
+-----------+-----------+--------+
|         3 | 运营部    |      0 |
|        10 | 研发部    |      2 |
+-----------+-----------+--------+
2 rows in set (0.00 sec)

按部门号排序,先运营部(编号 3,0 人)再研发部(编号 10,2 人)。COUNT(c.id) 数的是“子表真有值”的行;用 COUNT(*) 会把“没人”算成 1。

说明

把子表放左边:变成“所有员工 + 部门”,运营部又不见了。先问“要保住谁”,再决定谁在左边。

2. 现在有哪些子表行没有部门?用一条查询列出来。

说明

外键列是 NULL 就是“没有引用”:WHERE parent_id IS NULL。不要写 = NULL。

说明
MySQL 终端sql
SELECT id, name FROM lesson16_fk_child WHERE parent_id IS NULL ORDER BY id;
输出示例(只读,不要粘贴执行)text不可复制
+----+--------+
| id | name   |
+----+--------+
|  3 | 小陈   |
|  4 | 小李   |
+----+--------+
2 rows in set (0.00 sec)

小陈(被 SET NULL 断开的)和小李(一开始就没填)。NULL 表示“没有部门”,它不参与外键检查,也不参与 = 任何值 的比较。

说明

写 WHERE parent_id = NULL,永远返回空——NULL = NULL 的结果是“未知”,不是“真”。判断空值只能 IS NULL / IS NOT NULL。

**3. 为什么 SET NULL 要求子表的外键列允许 NULL?如果建表时写成 NOT NULL 会怎样?**

说明

SET NULL 的动作就是“把子表这一列改成 NULL”,所以这一列必须允许 NULL。如果建表时把它写成 NOT NULL,MySQL 在建外键这一步就直接拒绝,报 1830:Column ... cannot be NOT NULL: needed in a foreign key constraint ... SET NULL,根本走不到删父行那一步——SET NULL 从一开始就建不起来。所以建表时要先想清楚:需要“保留人、断开关系”就用可空列;坚持 NOT NULL 就只能选 RESTRICT 或 CASCADE。

**4. RESTRICT 和 CASCADE 有什么区别?为什么默认是前者?**

说明

RESTRICT 在子表还有引用时拒绝父表的删除/修改,逼你先处理子数据;CASCADE 会扩散——删一个部门,把它下面所有员工行一起删掉,改主键时也会同步改子表。默认 RESTRICT 是因为误删的代价不对称:拒绝只是操作失败,CASCADE 可能一次带走大量业务数据,而且不好恢复。

本单元验收

做到下面 5 条,这一节就完成了。每条都给出你应该看到的真实结果。

  1. lesson16_fk_parent 2 行(id 10 研发部、id 3 运营部),lesson16_fk_child 4 行,其中 COUNT(parent_id) 是 2。
  2. 子表插入 parent_id = 99 报 1452,且不留行;删还有子行的父行(默认 RESTRICT)报 1451。
  3. 把外键换成 ON DELETE SET NULL ON UPDATE CASCADE 后,删测试部只把小陈的 parent_id 置空;改研发部 id 1 → 10 时,小林、小周的 parent_id 自动变 10。
  4. 能说出 SET NULL 与 CASCADE 在“删父行”时的不同后果,以及 SET NULL 为什么要求外键列可空。
  5. 能用 LEFT JOIN + COUNT(c.id) 数出“研发部 2 人、运营部 0 人”。

做到这里

做到这里可以停。你现在能建一对父子表,能读懂 1452 和 1451 在说什么,也能按需要把删除/更新行为换成 SET NULL 或 CASCADE——并且知道为什么默认的 RESTRICT 往往是更稳的选择。

下一节开始把这些表放在一起查:一个部门多个员工这种关系,在查询里怎么变成一行行对得上的结果(多表查询)。

来源与边界

本节对应 BV1Kr4y1i7ru 平台 part 34「34. 基础-约束-外键约束」、part 35「35. 基础-约束-外键删除更新行为」、part 36「36. 基础-约束-小结」(P34–P36,在 P01–P50 范围内),主题与同版讲义《MySQL-基础篇》4.3(p64–68)对应;讲义为公开镜像,仅作主题与字段语义核对,本节未复制讲义正文。

无逐字稿,本节不声称听过讲解、不引用原话、不写时间戳。讲义 4.3 用 dept / emp 演示外键;本节自建 lesson16_fk_parent / lesson16_fk_child,字段语义对应(部门编号与名称、员工编号与所属部门),表名、3 个部门、4 名员工的数据与练习均为本教程原创。P36 是约束小结集,讲义没有对应“小结”正文,本节的「小结」段是按 P32–P35 已讲内容整理的,不冒充讲义原文。

本节每段 text 输出都在本机隔离的 MySQL 8.0.46 实例上逐条实跑(与学习服务器同版本号,不是同一台机器);0.00 sec 是测试机耗时,你屏幕上可能是 0.01 sec,行列内容一致。本节只对 lesson16_* 两张表做 DELETE / UPDATE / ALTER(删约束、加约束),不删表、不重置库;重跑靠 SHOW TABLES LIKE 'lesson16_%'; 与行数核对。若你已经在别的会话里把外键改成了 SET NULL,重复 DROP FOREIGN KEY 会报“约束不存在”,此时直接跳到 ADD CONSTRAINT 那一步即可。