08A Actor 与 Film:先从 MySQL 理解关联表
这一章先不使用 Sequelize。目标是理解:为什么需要 film_actor、它保护了什么、
新增或删除一条“关系”时 MySQL 实际执行了什么。
后续 ORM 代码只是这些 SQL 的另一种表达。如果底层关系没有理解清楚,include、
addFilm()、setFilms() 很容易变成只能照抄、无法排错的“魔法”。
1. 先区分三种常见关系
1.1 一对多:Customer 与 Address
Sakila 中多个客户可以使用同一个地址,客户只保存一个当前地址:
address 1 ───── N customer
外键直接放在“多”的一侧:
customer.address_id -> address.address_id
这里不需要 customer_address 中间表。第 09 章的 Customer 就属于这种情况。
1.2 多对多:Actor 与 Film
一个演员可以出演多部电影,一部电影也可以有多个演员:
actor 1 ─── N film_actor N ─── 1 film
无法只在 actor 放一个 film_id,也无法只在 film 放一个 actor_id,否则只能
表达一个对象。正确做法是让中间表的每一行表达一条关系:
(actor_id=1, film_id=10) 演员 1 出演电影 10
(actor_id=1, film_id=20) 演员 1 出演电影 20
(actor_id=2, film_id=10) 演员 2 出演电影 10
1.3 一对一
一对一通常是在外键上增加唯一约束:
UNIQUE KEY uk_user_profile_user (user_id)
不要因为业务口头说“一对一”就只依赖应用代码。没有唯一约束,并发写入仍可能产生 两条关联记录。
2. 数据库、Sequelize、TypeScript 各负责什么
MySQL 外键与唯一约束
└─ 保护真实数据,任何客户端写入都必须遵守
Sequelize Association
└─ 告诉 ORM 如何 JOIN,并在运行时生成关联方法
TypeScript 类型
└─ 帮助编译器检查代码,不创建外键,也不验证运行时数据
只写 Actor.belongsToMany(Film) 不等于数据库已经有外键。反过来,数据库已经有
film_actor 外键,也不代表 Sequelize 自动知道 include: "films" 应该怎样 JOIN。
3. 先查看当前数据库,不要凭印象建模
SHOW CREATE TABLE actor;
SHOW CREATE TABLE film;
SHOW CREATE TABLE film_actor;
SHOW INDEX FROM film_actor;
当前练习库中的核心 DDL 如下:
CREATE TABLE film_actor (
actor_id SMALLINT UNSIGNED NOT NULL,
film_id SMALLINT UNSIGNED NOT NULL,
last_update TIMESTAMP NOT NULL
DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (actor_id, film_id),
KEY idx_fk_film_id (film_id),
CONSTRAINT fk_film_actor_actor
FOREIGN KEY (actor_id)
REFERENCES actor (actor_id)
ON DELETE RESTRICT
ON UPDATE CASCADE,
CONSTRAINT fk_film_actor_film
FOREIGN KEY (film_id)
REFERENCES film (film_id)
ON DELETE RESTRICT
ON UPDATE CASCADE
) ENGINE = InnoDB
DEFAULT CHARACTER SET utf8mb4
COLLATE utf8mb4_0900_ai_ci;
这份 DDL 是用来理解现有 Sakila 结构的。不要对已有练习库调用:
sequelize.sync({ force: true });
sequelize.sync({ alter: true });
它们可能删除数据或擅自改变官方示例表。正式项目应使用版本化 Migration;当前练习
至少要保存明确、可审查的 DDL,并确认目标是 sakila_training。
4. 为什么使用复合主键
PRIMARY KEY (actor_id, film_id)
这表示一条关系的身份由两个外键共同决定,同时禁止重复关系:
INSERT INTO film_actor (actor_id, film_id) VALUES (1, 10);
INSERT INTO film_actor (actor_id, film_id) VALUES (1, 10);
-- 第二次:ERROR 1062 Duplicate entry
不要只在 Service 中使用“先查询、没有再插入”:
请求 A 查询:不存在
请求 B 查询:不存在
请求 A 插入
请求 B 插入
没有复合主键或唯一约束时,两条重复记录都可能成功。预查询只能改善提示,数据库约束 才是并发情况下的最终保护。
4.1 为什么还需要 film_id 的单独索引
复合主键 (actor_id, film_id) 遵守最左前缀原则,适合:
WHERE actor_id = ?
WHERE actor_id = ? AND film_id = ?
它不适合只从 film_id 开始查询。因此 Sakila 还提供:
KEY idx_fk_film_id (film_id)
这样两个方向的关系查询都有合适入口:
查询演员的电影:PRIMARY KEY(actor_id, film_id)
查询电影的演员:idx_fk_film_id(film_id)
使用下面的命令验证,不要只背结论:
EXPLAIN
SELECT film_id FROM film_actor WHERE actor_id = 1;
EXPLAIN
SELECT actor_id FROM film_actor WHERE film_id = 1;
5. 创建关联:INSERT 的真实行为
为已有演员和已有电影建立关系:
INSERT INTO film_actor (actor_id, film_id)
VALUES (1, 10);
可能得到三类结果:
| 结果 | MySQL 行为 | 应用层语义 |
|---|---|---|
| 两端都存在且关系不存在 | 插入 1 行 | 创建成功 |
(1,10) 已存在 |
1062 duplicate key | 关系已经存在 |
| actor 或 film 不存在 | 1452 foreign key error | 关联目标不存在 |
你可以提前查询演员和电影,让错误更明确:
SELECT actor_id FROM actor WHERE actor_id = 1;
SELECT film_id FROM film WHERE film_id = 10;
但检查和 INSERT 之间仍有并发窗口,所以最终仍要处理数据库异常。
6. 查询关联:两种方向的 JOIN
6.1 查询某演员出演的电影
SELECT
f.film_id,
f.title,
f.release_year,
fa.last_update AS relation_last_update
FROM film_actor AS fa
JOIN film AS f
ON f.film_id = fa.film_id
WHERE fa.actor_id = ?
ORDER BY f.title ASC, f.film_id ASC
LIMIT ? OFFSET ?;
这里把 film 当成分页结果的主体,比先加载 Actor 的全部电影再在 Node.js 内存中分页
更可靠。
6.2 查询某电影的演员
SELECT
a.actor_id,
a.first_name,
a.last_name
FROM film_actor AS fa
JOIN actor AS a
ON a.actor_id = fa.actor_id
WHERE fa.film_id = ?
ORDER BY a.last_name ASC, a.first_name ASC, a.actor_id ASC;
稳定排序仍然重要。姓名相同的情况下,最后追加主键才能保证分页结果稳定。
7. 删除关联不等于删除实体
解除演员与电影的关系:
DELETE FROM film_actor
WHERE actor_id = ? AND film_id = ?;
这个语句只删除中间表的一行:
Actor 仍然存在
Film 仍然存在
只有“出演关系”消失
MySQL 删除不存在的关系不会抛错,只会返回:
affected rows = 0
API 必须事先决定语义:
- 严格模式:0 行表示
ACTOR_FILM_NOT_FOUND; - 幂等模式:关系本来就不存在,也返回成功。
两种都可以,关键是接口契约保持一致。
8. “更新关系”通常是什么意思
film_actor 只有两个主键和维护时间,没有可修改的业务属性。把:
(actor_id=1, film_id=10)
改成:
(actor_id=1, film_id=20)
本质不是更新电影 10,而是删除一条关系,再新增另一条关系:
START TRANSACTION;
DELETE FROM film_actor
WHERE actor_id = 1 AND film_id = 10;
INSERT INTO film_actor (actor_id, film_id)
VALUES (1, 20);
COMMIT;
只有当中间表包含关系本身的属性时,PATCH 才更自然,例如:
character_name 饰演角色
billing_order 演员表排序
valid_from 关系有效期
此时更新的是这些属性,而不是随意修改组成身份的两个外键。
9. 删除 Actor 为什么会失败
当前外键使用:
ON DELETE RESTRICT
只要 film_actor 还有引用:
DELETE FROM actor WHERE actor_id = 1;
-- ERROR 1451 Cannot delete or update a parent row
这正是第 08 章 deleteActor() 应捕获 ForeignKeyConstraintError 的原因。它不需要
FilmActor Sequelize Model 才会生效,因为约束存在于 MySQL。
不要为了“删成功”执行:
SET FOREIGN_KEY_CHECKS = 0;
这样可能制造指向不存在演员的孤儿记录。
9.1 RESTRICT、CASCADE、SET NULL 如何选
| 策略 | 删除父记录时 | 适用场景 |
|---|---|---|
RESTRICT |
有引用就拒绝 | 删除需要人工确认,Sakila 当前选择 |
CASCADE |
自动删除子表关系 | 子记录没有独立保留价值 |
SET NULL |
外键改为 NULL | 需要保留历史,且外键允许 NULL |
CASCADE 不是更高级的配置。它可能一次删除大量数据,必须来自明确的生命周期设计。
10. 批量替换关系为什么需要事务
把演员 1 的电影完整集合替换成 [10, 20, 30]:
START TRANSACTION;
-- 可选:统一约定所有“替换演员电影集合”的操作先锁演员行。
SELECT actor_id
FROM actor
WHERE actor_id = 1
FOR UPDATE;
DELETE FROM film_actor
WHERE actor_id = 1;
INSERT INTO film_actor (actor_id, film_id)
VALUES (1, 10), (1, 20), (1, 30);
COMMIT;
如果电影 30 不存在,INSERT 会触发外键错误。事务回滚后,之前删除的旧关系也会恢复。 没有事务就可能留下“旧关系已经删掉,新关系只插入一部分”的状态。
所有会修改同一组关系的代码必须遵守一致的锁顺序,才能真正减少并发覆盖和死锁。 即使如此,生产系统仍应对可重试的死锁进行有限次数重试。
11. DDL 和 Model 的设计检查表
- [ ] 两个外键与父表主键类型完全兼容,包括
UNSIGNED。 - [ ] 使用 InnoDB,而不是不支持外键语义的存储引擎。
- [ ] 复合主键或联合唯一约束阻止重复关系。
- [ ] 根据两个查询方向检查索引,而不是认为一个复合索引解决一切。
- [ ] 明确
ON DELETE和ON UPDATE,不要依赖默认行为。 - [ ] 没有为了 ORM 方便额外添加无意义的自增
id。 - [ ] 没有使用 JSON 或逗号分隔字符串保存多个电影 ID。
- [ ] 没有对现有 Sakila 运行
sync({ force: true })或alter: true。
12. 动手练习
- 使用
SHOW CREATE TABLE film_actor,逐项解释每个约束。 - 分别用
EXPLAIN查看按 actor 和按 film 查询时选择的索引。 - 在练习库插入一条关系,再重复插入,记录 MySQL 1062。
- 使用不存在的 film_id 插入,记录 MySQL 1452。
- 删除有关联的 Actor,记录 MySQL 1451,然后回滚测试数据。
- 删除一条关系两次,观察第二次 affected rows 为 0。
- 在事务中执行“先删旧关系、再插入非法新关系”,确认 ROLLBACK 恢复旧数据。
- 解释为什么 Customer 与 Address 不需要中间表,而 Actor 与 Film 需要。
下一章把这些结构准确映射成 Sequelize Model 和 Association。