Sequelize v6 教程 08:Associations(模型关联)

主线环境:Node.js、TypeScript、Sequelize v6、MySQL、mysql2
关联的本质仍然是 MySQL 外键和 JOIN;Sequelize 负责描述关系、生成关联方法并组织查询结果。

1. 学习目标

完成本章后,你应该能够:

2. 先从 MySQL 外键理解关联

以 Sakila 为例:

country 1 ─── N city 1 ─── N address 1 ─── N customer

actor N ─── N film
          film_actor

city.country_id 存在于 city 表,因此:

film_actor 同时保存 actor_idfilm_id,因此 Actor 与 Film 是多对多关系。

最重要的判断方法不是背 API,而是先问:外键实际存在哪张表?

3. 四种关联 API

Sequelize API 关系 外键位置
A.hasOne(B) A 一对一拥有 B B 表
A.belongsTo(B) A 属于 B A 表
A.hasMany(B) A 一对多拥有 B B 表
A.belongsToMany(B) A 与 B 多对多 中间表

hasOnebelongsTo 都能表达“一对一”,区别是外键方向。hasManybelongsTo 通常成对出现。belongsToMany 通常也要在双方各定义一次。

4. 定义 World 的一对多关系

下面只保留本章相关字段:

import {
  Association,
  CreationOptional,
  DataTypes,
  ForeignKey,
  HasManyGetAssociationsMixin,
  InferAttributes,
  InferCreationAttributes,
  Model,
  NonAttribute,
  Sequelize,
} from "sequelize";

const sequelize = new Sequelize("world", "app_user", "password", {
  host: "127.0.0.1",
  dialect: "mysql",
});

class Country extends Model<
  InferAttributes<Country, { omit: "cities" }>,
  InferCreationAttributes<Country, { omit: "cities" }>
> {
  declare code: string;
  declare name: string;

  declare cities?: NonAttribute<City[]>;
  declare getCities: HasManyGetAssociationsMixin<City>;

  declare static associations: {
    cities: Association<Country, City>;
  };
}

class City extends Model<
  InferAttributes<City, { omit: "country" }>,
  InferCreationAttributes<City, { omit: "country" }>
> {
  declare id: CreationOptional<number>;
  declare name: string;
  declare countryCode: ForeignKey<Country["code"]>;
  declare population: number;

  declare country?: NonAttribute<Country>;
}

Country.init(
  {
    code: {
      type: DataTypes.CHAR(3),
      primaryKey: true,
      field: "Code",
    },
    name: {
      type: DataTypes.STRING(52),
      allowNull: false,
      field: "Name",
    },
  },
  {
    sequelize,
    tableName: "country",
    timestamps: false,
  },
);

City.init(
  {
    id: {
      type: DataTypes.INTEGER,
      primaryKey: true,
      autoIncrement: true,
      field: "ID",
    },
    name: {
      type: DataTypes.STRING(35),
      allowNull: false,
      field: "Name",
    },
    countryCode: {
      type: DataTypes.CHAR(3),
      allowNull: false,
      field: "CountryCode",
    },
    population: {
      type: DataTypes.INTEGER,
      allowNull: false,
      field: "Population",
    },
  },
  {
    sequelize,
    tableName: "city",
    timestamps: false,
  },
);

Country.hasMany(City, {
  as: "cities",
  foreignKey: "countryCode",
  sourceKey: "code",
});

City.belongsTo(Country, {
  as: "country",
  foreignKey: "countryCode",
  targetKey: "code",
});

这里有几个 TypeScript 关键点:

Country.hasMany(City) 不会自动等价于 City.belongsTo(Country)。如果两个方向都要查询和生成关联方法,应明确把两边都定义出来。

5. sourceKeytargetKeyforeignKey

在上例中:

主键关联时 Sequelize 通常能推断 sourceKey/targetKey,显式书写有助于初学者理解,也适用于关联唯一非主键字段的情况。

注意区分模型属性名和数据库列名:关联配置通常使用模型属性 countryCode,而不是物理列名 CountryCode。列名转换由 field 负责。

6. 一对一:hasOnebelongsTo

假设业务表设计如下:

CREATE TABLE user_profile (
  user_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
  bio VARCHAR(500) NULL,
  CONSTRAINT fk_profile_user
    FOREIGN KEY (user_id) REFERENCES app_user(id)
);

外键在 user_profile,因此:

User.hasOne(UserProfile, {
  as: "profile",
  foreignKey: "userId",
});

UserProfile.belongsTo(User, {
  as: "user",
  foreignKey: "userId",
});

若要保证真正的一对一,MySQL 层的外键列还必须有 PRIMARY KEYUNIQUE 约束。仅在 Sequelize 中调用 hasOne,不等于数据库已经能阻止同一个用户插入多份 profile。

7. 多对多:Sakila 的 Actor 与 Film

7.1 显式定义中间模型

import {
  BelongsToManyGetAssociationsMixin,
  ForeignKey,
  NonAttribute,
} from "sequelize";

class Actor extends Model<
  InferAttributes<Actor, { omit: "films" }>,
  InferCreationAttributes<Actor, { omit: "films" }>
> {
  declare actorId: CreationOptional<number>;
  declare firstName: string;
  declare lastName: string;

  declare films?: NonAttribute<Film[]>;
  declare getFilms: BelongsToManyGetAssociationsMixin<Film>;
}

class Film extends Model<
  InferAttributes<Film, { omit: "actors" }>,
  InferCreationAttributes<Film, { omit: "actors" }>
> {
  declare filmId: CreationOptional<number>;
  declare title: string;

  declare actors?: NonAttribute<Actor[]>;
}

class FilmActor extends Model<
  InferAttributes<FilmActor>,
  InferCreationAttributes<FilmActor>
> {
  declare actorId: ForeignKey<Actor["actorId"]>;
  declare filmId: ForeignKey<Film["filmId"]>;
}

各模型的 init() 省略后,关联定义如下:

Actor.belongsToMany(Film, {
  through: FilmActor,
  as: "films",
  foreignKey: "actorId",
  otherKey: "filmId",
});

Film.belongsToMany(Actor, {
  through: FilmActor,
  as: "actors",
  foreignKey: "filmId",
  otherKey: "actorId",
});

through 指定中间模型;当前调用方在中间表中的键是 foreignKey,另一方是 otherKey。反向定义时二者对调。

Sakila 的 film_actor 使用 (actor_id, film_id) 复合主键,能够防止相同演员和电影关系重复。生产项目中,中间表应至少具有联合唯一约束,不能只依靠应用代码防重。

7.2 什么时候必须显式定义中间模型

如果中间表只有两侧外键,through 可以使用名称让 Sequelize 创建;但已有数据库和生产项目更建议显式模型。以下情况尤其需要显式模型:

8. 关联自动生成的方法

定义关联后,Sequelize 会在实例上添加方法。以 Country.hasMany(City, { as: "cities" }) 为例,常见方法包括:

方法名称受关联别名的单复数影响。TypeScript 不会自动知道运行时新增的方法,需要使用 Sequelize 提供的 mixin 类型逐项声明。

不要为了省类型声明而把模型写成 any。可以只声明当前业务实际调用的方法,不必一次性声明全部 mixin。

9. 预加载:使用 include 生成 JOIN

9.1 查询国家及其城市

const countries = await Country.findAll({
  attributes: ["code", "name"],
  include: [
    {
      model: City,
      as: "cities",
      attributes: ["id", "name", "population"],
      required: false,
    },
  ],
  where: {
    code: ["CHN", "JPN"],
  },
  order: [
    ["code", "ASC"],
    [{ model: City, as: "cities" }, "population", "DESC"],
  ],
});

近似 SQL:

SELECT ...
FROM country AS Country
LEFT OUTER JOIN city AS cities
  ON Country.Code = cities.CountryCode
WHERE Country.Code IN ('CHN', 'JPN')
ORDER BY Country.Code ASC, cities.Population DESC;

required: false 通常生成 LEFT OUTER JOINrequired: true 通常生成 INNER JOIN,只保留有关联行的父记录。如果 include 自身设置 where,Sequelize 通常会把它视为必需关联,除非显式设置 required: false

9.2 别名必须一致

关联定义用了 as: "cities",查询时也必须使用同一个别名:

include: [{ model: City, as: "cities" }]

别名拼错是初学者最常遇到的关联错误之一。一个模型与同一目标模型有多条关系时,别名更是必需的,例如订单同时具有 buyerseller

10. 懒加载与 N+1 查询

下面的代码会先查国家,再为每个国家单独查一次城市:

const countries = await Country.findAll({ limit: 20 });

for (const country of countries) {
  const cities = await country.getCities();
  console.log(country.name, cities.length);
}

如果有 20 个国家,就可能执行 21 次 SQL,这就是常见的 N+1 查询。

改进方式通常是:

const countries = await Country.findAll({
  limit: 20,
  include: [{ model: City, as: "cities" }],
});

include 也不是越多越好。多个一对多关联同时 JOIN 会造成行数乘法膨胀。应查看生成 SQL和返回数据量,必要时拆成少量批量查询,而不是在循环中逐条查询。

11. 关联查询的分页陷阱

父表与一对多子表 JOIN 后,一条父记录会展开为多行。limit 20 到底限制父记录还是 JOIN 后的行,会影响分页结果。Sequelize 可能使用子查询保证父模型分页,但复杂 include 下仍应检查生成 SQL。

生产建议:

  1. 对父模型使用稳定排序,例如主键;
  2. 先分页取得父表主键;
  3. 再用第二次查询批量加载关联;
  4. 避免 offset 很深的分页,必要时改用游标分页;
  5. 不要仅因为加了 distinct: true 就认为所有计数问题都解决。

findAndCountAll 与一对多 include 搭配时尤其要测试 count 是否符合“父记录数量”的业务定义。

12. 创建关联数据与事务

创建电影并添加演员属于多步写操作,应放进事务:

await sequelize.transaction(async (transaction) => {
  const film = await Film.create(
    {
      title: "SEQUELIZE PRACTICE",
    },
    { transaction },
  );

  const actors = await Actor.findAll({
    where: {
      actorId: [1, 2],
    },
    transaction,
  });

  await film.addActors(actors, { transaction });
});

示例假设 Film 已声明相应的 addActors mixin,且创建所需的其他 Sakila 必填字段已按实际模型补齐。

必须把同一个 transaction 传给创建、查询和关联写入。数据库外键、唯一约束会在并发场景中提供最终保护。

13. 删除、级联和外键动作

关联选项可以涉及:

Country.hasMany(City, {
  foreignKey: {
    name: "countryCode",
    allowNull: false,
  },
  onUpdate: "CASCADE",
  onDelete: "RESTRICT",
});

不要机械使用 CASCADE

sequelize.sync({ alter: true }) 适合学习实验,不应作为生产数据库变更方案。生产环境应使用迁移脚本,代码评审后明确创建外键与索引。

14. 索引与关联性能

MySQL InnoDB 通常要求外键列有索引,但业务查询还可能需要复合索引。例如:

SELECT *
FROM city
WHERE CountryCode = 'CHN'
ORDER BY Population DESC
LIMIT 20;

可评估 (CountryCode, Population) 复合索引,而不只是分别创建两个单列索引。是否需要必须结合数据分布和 EXPLAIN

关联性能排查顺序:

15. Model 定义顺序与循环依赖

真实项目常把模型拆成多个文件。如果每个模型文件导入另一个模型并立即创建关联,容易形成 JavaScript 循环依赖。

适合学习项目的简单做法:

  1. 先定义并初始化所有模型;
  2. 在一个集中函数中建立所有关联;
  3. 最后导出已经完成关联配置的模型集合。

这不是要求引入复杂架构,而是把“定义字段”和“连接模型关系”分成两个清楚阶段。

16. 与 Redis 的边界

关联数据的事实来源仍是 MySQL。若以后缓存国家及城市等聚合 DTO:

关联越复杂,缓存失效范围越难判断。因此先优化 SQL、索引和 N+1,再决定是否引入 Redis。

17. 常见错误清单

18. 本章小结

19. 练习题(暂不提供答案)

以下练习使用 MySQL Sakila 或 World 示例数据库:

  1. 为 World 的 CountryCity 定义双向一对多关联,要求显式配置别名、外键和目标键,并补充必要的 TypeScript 关联属性。
  2. 为 Sakila 的 CustomerAddress 定义关联。先说明外键在哪张表,再选择 belongsTohasOne,最后定义双向关系。
  3. 使用 include 查询 World 中的国家及城市,只返回人口大于 100 万的城市;分别尝试 required: truerequired: false,观察国家结果的差异。
  4. 为 Sakila 的 ActorFilmFilmActor 定义多对多关系,确保两边的 foreignKeyotherKey 正确对调。
  5. 查询某位演员参演的所有电影,并只返回电影 ID、标题和中间表中的关联字段。记录 Sequelize 生成的 SQL。
  6. 编写一个会产生 N+1 的“查询国家及其城市”示例,再改为预加载或两次批量查询,并比较 SQL 次数。
  7. 查询 Sakila 中每位客户及其租赁记录并分页。分析一对多 JOIN 对 limit 和 count 的影响,提出可靠分页方案。
  8. 在事务中创建一条多对多关系;故意插入重复关系,观察 MySQL 联合唯一约束如何保护数据一致性。
  9. 对 World 的“按国家查询人口最多的 20 个城市”使用 EXPLAIN,评估 (CountryCode, Population) 复合索引是否有帮助。
  10. 设计一个缓存“国家详情及热门城市”的方案,只写缓存边界、键和失效时机;说明为什么修改城市数据也会影响国家详情缓存。

官方文档