第七单元 · 约束与外键 · 第 16 / 21 节
外键:两张表怎么拴在一起
建 lesson16_fk_parent(3 个部门)与 lesson16_fk_child(4 名员工),看子表填了父表没有的编号报 1452、父表被有引用的行删掉报 1451;再把外键换成 ON DELETE SET NULL + ON UPDATE CASCADE,实测删部门只断关系、改部门号子表跟着改,并说清四种删除/更新行为的取舍。
约 22 分钟。这一节建一对父子表,先看“子表填了父表没有的编号”会怎样,再看父表被删、被改时子表跟着做什么——这是外键真正难的地方。
$ / mysql> 提示符和输出。已完成 0 / 2 步
你现在在哪个提示符
应该在 mysql>。断了就先回到学习终端(PowerShell):
ssh root@8.148.204.152
mysql-learning
进去以后确认当前库:
SELECT DATABASE();
+------------+
| DATABASE() |
+------------+
| itcast |
+------------+
1 row in set (0.00 sec)
显示 itcast 就对了。如果是 NULL,执行 USE itcast;——它只切换当前连接用哪个库,不动数据。
第一步:外键就是“只能填父表有的值”
上一节的五类约束都只管自己这一行。第六类不一样:它管两张表之间的关系。
场景很常见:一张部门表,一张员工表。员工的 parent_id 填的是“我在哪个部门”,这个值必须是部门表里真实存在的编号。没有外键,你可以往员工表里写 parent_id = 99,数据库一声不吭,等查“部门 99 的员工”时才发现这是个不存在的部门——这种行叫“孤儿数据”。
先建父表:
SHOW TABLES LIKE 'lesson16_%';
Empty set (0.00 sec)
Empty set 说明可以建。已经有这两张表,跳到本步末尾核对行数,不要删表重建。
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;
Query OK, 0 rows affected (0.03 sec)
INSERT INTO lesson16_fk_parent (id, name) VALUES (1, '研发部'), (2, '测试部'), (3, '运营部');
Query OK, 3 rows affected (0.01 sec)
Records: 3 Duplicates: 0 Warnings: 0
SELECT * FROM lesson16_fk_parent ORDER BY id;
+----+-----------+
| id | name |
+----+-----------+
| 1 | 研发部 |
| 2 | 测试部 |
| 3 | 运营部 |
+----+-----------+
3 rows in set (0.00 sec)
再建子表,parent_id 上写外键。注意写法:FOREIGN KEY (子表列) REFERENCES 父表 (父表列),顺序别写反。
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;
Query OK, 0 rows affected (0.04 sec)
INSERT INTO lesson16_fk_child (id, name, parent_id) VALUES
(1, '小林', 1), (2, '小周', 1), (3, '小陈', 2), (4, '小李', NULL);
Query OK, 4 rows affected (0.00 sec)
Records: 4 Duplicates: 0 Warnings: 0
SELECT id, name, parent_id FROM lesson16_fk_child ORDER BY id;
+----+--------+-----------+
| id | name | parent_id |
+----+--------+-----------+
| 1 | 小林 | 1 |
| 2 | 小周 | 1 |
| 3 | 小陈 | 2 |
| 4 | 小李 | NULL |
+----+--------+-----------+
4 rows in set (0.00 sec)
四行。小李的 parent_id 是 NULL——外键不检查 NULL,NULL 的意思是“没有引用任何部门”,所以能留下。
现在试一次“填了父表没有的编号”:
INSERT INTO lesson16_fk_child (id, name, parent_id) VALUES (9, '小测试', 99);
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 不在父表里。这条插入什么也没写进去。外键的三条硬要求,缺一条都建不起来:
- 父表被引用的列必须是主键或
UNIQUE(否则无法唯一定位一行); - 两边的数据类型要能对上(这里都是
INT); - 子表里已有的数据必须都能在父表找到,否则加约束会失败——所以真实项目里常常要先把脏数据洗掉再加外键。
第二步:父表被删、被改,子表怎么办
外键建好以后,父表那边就不能随便动了。先试默认行为——删掉一个有员工的部门:
DELETE FROM lesson16_fk_parent WHERE id = 2;
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 删的是约束,不是表,也不是数据:
ALTER TABLE lesson16_fk_child DROP FOREIGN KEY fk_lesson16_child_parent;
Query OK, 0 rows affected (0.01 sec)
Records: 0 Duplicates: 0 Warnings: 0
再按“删除置空、更新跟随”重建它:
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;
Query OK, 4 rows affected (0.09 sec)
Records: 4 Duplicates: 0 Warnings: 0
现在再删一次测试部:
DELETE FROM lesson16_fk_parent WHERE id = 2;
Query OK, 1 row affected (0.00 sec)
成功了。小陈没有被删掉,只是 parent_id 变成了 NULL:
SELECT id, name, parent_id FROM lesson16_fk_child ORDER BY id;
+----+--------+-----------+
| 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:
UPDATE lesson16_fk_parent SET id = 10 WHERE id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
SELECT * FROM lesson16_fk_parent ORDER BY id;
+----+-----------+
| id | name |
+----+-----------+
| 3 | 运营部 |
| 10 | 研发部 |
+----+-----------+
2 rows in set (0.00 sec)
SELECT id, name, parent_id FROM lesson16_fk_child ORDER BY id;
+----+--------+-----------+
| 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。
最后核对一下两边的行数:
SELECT COUNT(*) AS 父表行数 FROM lesson16_fk_parent;
+--------------+
| 父表行数 |
+--------------+
| 2 |
+--------------+
1 row in set (0.00 sec)
SELECT COUNT(*) AS 子表行数 FROM lesson16_fk_child;
+--------------+
| 子表行数 |
+--------------+
| 4 |
+--------------+
1 row in set (0.00 sec)
SELECT COUNT(parent_id) AS 有部门的子行 FROM lesson16_fk_child;
+--------------------+
| 有部门的子行 |
+--------------------+
| 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(想清楚再改) |
说明
INSERT INTO lesson16_fk_child (id, name, parent_id) VALUES (10, '小测试2', 99);
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(*)。
说明
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;
+-----------+-----------+--------+
| 部门号 | 部门 | 人数 |
+-----------+-----------+--------+
| 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。
说明
SELECT id, name FROM lesson16_fk_child WHERE parent_id IS NULL ORDER BY id;
+----+--------+
| 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 条,这一节就完成了。每条都给出你应该看到的真实结果。
lesson16_fk_parent2 行(id 10 研发部、id 3 运营部),lesson16_fk_child4 行,其中COUNT(parent_id)是 2。- 子表插入
parent_id = 99报1452,且不留行;删还有子行的父行(默认RESTRICT)报1451。 - 把外键换成
ON DELETE SET NULL ON UPDATE CASCADE后,删测试部只把小陈的parent_id置空;改研发部 id 1 → 10 时,小林、小周的parent_id自动变 10。 - 能说出
SET NULL与CASCADE在“删父行”时的不同后果,以及SET NULL为什么要求外键列可空。 - 能用
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 那一步即可。