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 必须事先决定语义:

两种都可以,关键是接口契约保持一致。

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 的设计检查表

12. 动手练习

  1. 使用 SHOW CREATE TABLE film_actor,逐项解释每个约束。
  2. 分别用 EXPLAIN 查看按 actor 和按 film 查询时选择的索引。
  3. 在练习库插入一条关系,再重复插入,记录 MySQL 1062。
  4. 使用不存在的 film_id 插入,记录 MySQL 1452。
  5. 删除有关联的 Actor,记录 MySQL 1451,然后回滚测试数据。
  6. 删除一条关系两次,观察第二次 affected rows 为 0。
  7. 在事务中执行“先删旧关系、再插入非法新关系”,确认 ROLLBACK 恢复旧数据。
  8. 解释为什么 Customer 与 Address 不需要中间表,而 Actor 与 Film 需要。

下一章把这些结构准确映射成 Sequelize Model 和 Association。